Compare commits
4
Commits
main
...
blue/lifecycle
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc1500599f | ||
|
|
a94fe6d551 | ||
|
|
aced54594f | ||
|
|
0217e4f60e |
@@ -321,6 +321,70 @@ issued to and a refresh token was being presented under a different id.
|
||||
- `internal/{oidc,routes}` — OAuth2/OIDC surface; `internal/{scim,mfa,webauthn,providers,sessions,tokens,cred,authz,certs,keys}`.
|
||||
- `internal/{users,organizations,applications,roles,permission,memberships}` — entities; `pkg/model`, `pkg/store`; `MIGRATION.md` (RFC surface + phases).
|
||||
|
||||
## Sign-up is risk-gated (`internal/risk`, `internal/oidc/signup_gate.go`)
|
||||
|
||||
`POST /v1/iam/signup` asks the platform scoring plane — `POST /v1/risk/decide`,
|
||||
stage `signup` — before it writes anything. IAM does **not** score: velocity,
|
||||
address/ASN reputation, disposable-mailbox lists and multi-account linkage live
|
||||
in the risk plane over a per-org feature surface IAM cannot see, and a second
|
||||
scorer would be a second answer to one question.
|
||||
|
||||
**Order is the design.** Every deterministic check (app policy, reserved org,
|
||||
tenant gate, username, uniqueness, email, password floor) runs FIRST, then the
|
||||
gate, then every write. So a typo costs no screen, and a refused sign-up leaves
|
||||
nothing behind — including the organization, which self-serve creation used to
|
||||
mint *before* the user was validated.
|
||||
|
||||
**Outcomes.** `allow`/`review` → the account is created. `challenge` → answered
|
||||
with the protocol string `RequiredVerify` (beside `RequiredMfa`/`NextMfa`); the
|
||||
client calls `send-verification-code` and re-posts the sign-up with `code`.
|
||||
Presenting a code SPENDS it (`SpendVerificationCode`, was `CheckVerificationCode`):
|
||||
a code that survived being presented would let one proven address open account
|
||||
after account for the rest of its ten-minute window, which is exactly the
|
||||
multi-account abuse the challenge exists to stop. Burned before the answer
|
||||
returns, mirroring authorization-code redemption; a burn that cannot be written
|
||||
fails closed. `block`/`restrict` → one opaque
|
||||
refusal carrying a decision reference and nothing else.
|
||||
|
||||
**Fail policy — one function, `risk.unavailable`.** An ordinary sign-up ALLOWS
|
||||
when the scorer is unreachable (never break login). A sign-up that would MINT A
|
||||
TENANT is a grant of standing authority and REFUSES — but only on an ARMED
|
||||
deployment. `RISK_URL` unset means no risk plane was ever wired here, and
|
||||
refusing to onboard because a component does not exist is an outage, not a
|
||||
defense; that case allows and is recorded as `scorer-absent`. Same semantic as
|
||||
the cloud edge, whose arming signal is the per-org `mode=live`.
|
||||
|
||||
**Records.** Durable first: every decision is a `schema.AuditLog` row
|
||||
(`signup.risk.<action>`) written before the client is answered, carrying the
|
||||
decision id, action, score, cause, refusal and `scored` — never the request body,
|
||||
because the sign-up form carries a password. The analytics COPY goes to
|
||||
`/v1/event` afterwards, best-effort, on its own background context.
|
||||
|
||||
**The tenant is the SERVER's answer, never the body's** (`signupTenant`). Sign-up
|
||||
is the one endpoint where nobody has authenticated, so `f.Organization` is a string
|
||||
an anonymous caller typed. Using it as the owner of a durable row let anyone choose
|
||||
which tenant's append-only audit trail received a write and which tenant's per-org
|
||||
risk state was touched: a SHARED application admits any existing organization by
|
||||
design, so the choice reached real tenants, and an org-choice application admits
|
||||
names that do not exist yet, so rows could be pre-seeded under a name someone would
|
||||
later be given. The APPLICATION is resolved server-side from the presented
|
||||
clientId, and its organization owns the event — the tenant whose front door was
|
||||
actually knocked on. The organization the caller asked for is kept as evidence: a
|
||||
`requestedOrg` signal to the scorer and a field in the record's detail, where a
|
||||
claim belongs, not as the key.
|
||||
|
||||
**The client address is `httpx.ClientIP`** — the socket peer for a direct caller,
|
||||
else the right-most X-Forwarded-For entry that is not one of our own proxies
|
||||
(`IAM_TRUSTED_PROXIES`, defaulting to private space). It feeds a per-address
|
||||
velocity counter and a durable audit column, and the LEFT-most entry is the one the
|
||||
client writes: reading it let one host present a fresh address per attempt (evading
|
||||
its own velocity) or repeatedly name a victim's address (poisoning theirs). Same
|
||||
rule as `hanzoai/cloud`'s `ClientIP`; the shared home for it is the zip framework,
|
||||
which owns the Ctx.
|
||||
|
||||
Config: `RISK_URL` (scorer origin), `EVENT_URL` (analytics door), credential =
|
||||
the unified service token (`httpx.ServiceToken`). All three unset = inert gate.
|
||||
|
||||
## OPEN P0 — self-service signup enrolls strangers in the staff tenant
|
||||
|
||||
`hanzo-console` / `hanzo-cloud` / `hanzo-gitea` / `hanzo-bot` carry
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package httpx
|
||||
|
||||
// The caller's address, and the ONE rule IAM derives it by.
|
||||
//
|
||||
// An address is not a header. X-Forwarded-For is a list a client may write the
|
||||
// first entries of and each hop appends to, so the LEFT-MOST entry is whatever
|
||||
// the client typed — the one value in the chain that is always attacker
|
||||
// controlled. Reading it as "the client" is how a sign-up flood from one host
|
||||
// becomes a million distinct clients: it defeats per-address velocity, it writes
|
||||
// a chosen address into a durable audit row, and it lets one caller poison
|
||||
// another address's reputation while evading its own.
|
||||
//
|
||||
// THE RULE:
|
||||
//
|
||||
// the socket peer is the truth. If the peer is not one of OUR proxies, it IS
|
||||
// the client — a TCP source address cannot be forged inside an established
|
||||
// connection.
|
||||
//
|
||||
// only a trusted peer's chain is readable. When the peer IS one of ours, walk
|
||||
// X-Forwarded-For from the RIGHT — the end each hop appends to — and take the
|
||||
// first entry that is not itself one of ours. Everything to its left was
|
||||
// written before our infrastructure saw the request.
|
||||
//
|
||||
// our own traffic has no client. A chain that is entirely our own addresses is
|
||||
// an in-cluster caller; it has no client address, and an empty address is
|
||||
// honest where a proxy's own address in a velocity counter is not.
|
||||
//
|
||||
// This is the same rule cloud applies (hanzoai/cloud clientip.go). Two services,
|
||||
// one rule — stated twice because the two repos share no HTTP-boundary package
|
||||
// today; the shared home for it is the zip framework, which owns the Ctx.
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// TrustedProxiesEnv names the operator knob: a comma-separated list of CIDRs and
|
||||
// bare addresses that are OUR OWN forwarding hops. Set it when a deployment is
|
||||
// fronted by a proxy on a PUBLIC address; the default below covers private space,
|
||||
// which is every hop inside a cluster.
|
||||
const TrustedProxiesEnv = "IAM_TRUSTED_PROXIES"
|
||||
|
||||
// defaultTrustedProxies is the address space our own hops live in when nobody
|
||||
// says otherwise: loopback, the unspecified address (never a real peer), RFC1918
|
||||
// private space, carrier-grade NAT, link-local, and IPv6 unique-local. A public
|
||||
// address is NEVER trusted by default.
|
||||
var defaultTrustedProxies = []string{
|
||||
"127.0.0.0/8", "::1/128",
|
||||
"0.0.0.0/32", "::/128",
|
||||
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
||||
"100.64.0.0/10",
|
||||
"169.254.0.0/16", "fe80::/10",
|
||||
"fc00::/7",
|
||||
}
|
||||
|
||||
// maxForwardedHops bounds how much of a chain is read. Read from the right, so
|
||||
// the bound only ever discards the least trustworthy end.
|
||||
const maxForwardedHops = 32
|
||||
|
||||
var trustedProxies = sync.OnceValue(func() proxySet {
|
||||
if s := parseProxySet(os.Getenv(TrustedProxiesEnv)); len(s.nets) > 0 {
|
||||
return s
|
||||
}
|
||||
// An unset — or entirely unparseable — knob falls back to the defaults rather
|
||||
// than to an EMPTY set: trusting nothing would make the ingress itself the
|
||||
// "client", collapsing every caller into one address.
|
||||
return parseProxySet(strings.Join(defaultTrustedProxies, ","))
|
||||
})
|
||||
|
||||
type proxySet struct{ nets []netip.Prefix }
|
||||
|
||||
func parseProxySet(spec string) proxySet {
|
||||
var s proxySet
|
||||
for _, raw := range strings.Split(spec, ",") {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
if p, err := netip.ParsePrefix(raw); err == nil {
|
||||
s.nets = append(s.nets, p.Masked())
|
||||
continue
|
||||
}
|
||||
if a, err := netip.ParseAddr(raw); err == nil {
|
||||
s.nets = append(s.nets, netip.PrefixFrom(a.Unmap(), a.Unmap().BitLen()))
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s proxySet) has(a netip.Addr) bool {
|
||||
for _, n := range s.nets {
|
||||
if n.Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ClientIP is the caller's own address: the socket peer for a direct caller, the
|
||||
// right-most non-proxy entry of the forwarded chain for a proxied one, and "" for
|
||||
// an in-cluster caller that never transited the edge.
|
||||
//
|
||||
// It reads EVERY X-Forwarded-For header line, not just the first: fasthttp keeps
|
||||
// repeated headers apart, and a client that sends its own line before the proxy
|
||||
// appends to a second would otherwise hide the real address behind its own.
|
||||
func ClientIP(c *zip.Ctx) string {
|
||||
return clientAddr(c.Fiber().IP(), c.Fiber().Request().Header.PeekAll("X-Forwarded-For"), trustedProxies())
|
||||
}
|
||||
|
||||
// clientAddr IS the rule, as a pure function of the three facts it turns on: the
|
||||
// socket peer, the forwarded chain, and which addresses are ours.
|
||||
func clientAddr(peerAddr string, forwarded [][]byte, tp proxySet) string {
|
||||
peer, ok := parseClientAddr(peerAddr)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if !tp.has(peer) {
|
||||
return peer.String()
|
||||
}
|
||||
seen := 0
|
||||
for i := len(forwarded) - 1; i >= 0; i-- {
|
||||
hops := strings.Split(string(forwarded[i]), ",")
|
||||
for j := len(hops) - 1; j >= 0; j-- {
|
||||
if seen++; seen > maxForwardedHops {
|
||||
return ""
|
||||
}
|
||||
a, ok := parseClientAddr(hops[j])
|
||||
if !ok || tp.has(a) {
|
||||
continue
|
||||
}
|
||||
return a.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseClientAddr parses one entry into a canonical address, accepting a bare
|
||||
// address or an address:port pair and UNMAPPING IPv4-in-IPv6 so one address is
|
||||
// one key rather than two.
|
||||
func parseClientAddr(s string) (netip.Addr, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
if a, err := netip.ParseAddr(s); err == nil {
|
||||
return a.Unmap(), true
|
||||
}
|
||||
if ap, err := netip.ParseAddrPort(s); err == nil {
|
||||
return ap.Addr().Unmap(), true
|
||||
}
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package httpx
|
||||
|
||||
// The client-address rule, and the attack it exists to stop: the LEFT-MOST
|
||||
// X-Forwarded-For entry is whatever the caller typed, so reading it as "the
|
||||
// client" lets one host present a million addresses to a per-address velocity
|
||||
// counter and write a chosen address into a durable audit row.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
var ourProxies = parseProxySet("10.0.0.0/8,127.0.0.0/8,0.0.0.0/32,::1/128")
|
||||
|
||||
func xff(lines ...string) [][]byte {
|
||||
out := make([][]byte, 0, len(lines))
|
||||
for _, l := range lines {
|
||||
out = append(out, []byte(l))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestClientAddr(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
peer string
|
||||
fwd [][]byte
|
||||
want string
|
||||
}{
|
||||
{"a forged left-most entry is ignored", "10.0.0.5", xff("1.2.3.4, 203.0.113.9"), "203.0.113.9"},
|
||||
{"a whole forged chain is ignored", "10.0.0.5", xff("1.2.3.4, 5.6.7.8, 203.0.113.9"), "203.0.113.9"},
|
||||
{"a forged second header line does not hide the real hop", "10.0.0.5", xff("1.2.3.4", "203.0.113.9"), "203.0.113.9"},
|
||||
{"internal hops are skipped", "10.0.0.5", xff("203.0.113.9, 10.0.0.6"), "203.0.113.9"},
|
||||
{"a direct caller is its own peer", "198.51.100.4", xff("1.2.3.4"), "198.51.100.4"},
|
||||
{"an in-cluster caller has no client address", "10.0.0.5", nil, ""},
|
||||
{"a chain of only our own hops has no client address", "10.0.0.5", xff("10.0.0.6, 127.0.0.1"), ""},
|
||||
{"an unparseable entry is skipped, not keyed", "10.0.0.5", xff("not-an-ip, 203.0.113.9"), "203.0.113.9"},
|
||||
{"an IPv4-mapped address is the same key as its IPv4 form", "10.0.0.5", xff("::ffff:203.0.113.9"), "203.0.113.9"},
|
||||
{"an entry with a port is the address without it", "10.0.0.5", xff("203.0.113.9:44321"), "203.0.113.9"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := clientAddr(tc.peer, tc.fwd, ourProxies); got != tc.want {
|
||||
t.Fatalf("clientAddr(%q, %q) = %q, want %q", tc.peer, tc.fwd, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The walk is bounded, from the right, so it can only ever discard the least
|
||||
// trustworthy end of a chain.
|
||||
func TestClientAddr_ChainIsBounded(t *testing.T) {
|
||||
long := strings.Repeat("1.2.3.4, ", maxForwardedHops*4) + "203.0.113.9"
|
||||
if got := clientAddr("10.0.0.5", xff(long), ourProxies); got != "203.0.113.9" {
|
||||
t.Fatalf("got %q, want the right-most hop", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The default set trusts our own space and nothing public.
|
||||
func TestDefaultTrustedProxies(t *testing.T) {
|
||||
s := parseProxySet(strings.Join(defaultTrustedProxies, ","))
|
||||
for _, ours := range []string{"10.42.0.1", "172.16.5.5", "192.168.1.1", "127.0.0.1", "0.0.0.0", "fd00::1"} {
|
||||
a, ok := parseClientAddr(ours)
|
||||
if !ok || !s.has(a) {
|
||||
t.Fatalf("%s must be trusted by default (ours)", ours)
|
||||
}
|
||||
}
|
||||
for _, theirs := range []string{"203.0.113.9", "198.51.100.4", "2001:db8::1"} {
|
||||
a, ok := parseClientAddr(theirs)
|
||||
if !ok || s.has(a) {
|
||||
t.Fatalf("%s must NOT be trusted by default (public)", theirs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// End to end over a real request on the process default set.
|
||||
func TestClientIP_OverARealRequest(t *testing.T) {
|
||||
var got string
|
||||
app := zip.New(zip.Config{})
|
||||
app.Get("/probe", func(c *zip.Ctx) error {
|
||||
got = ClientIP(c)
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "1"})
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
|
||||
req.Header.Add("X-Forwarded-For", "1.2.3.4")
|
||||
req.Header.Add("X-Forwarded-For", "203.0.113.9, 10.0.0.6")
|
||||
if _, err := app.Fiber().Test(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "203.0.113.9" {
|
||||
t.Fatalf("ClientIP = %q, want the right-most untrusted hop", got)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/internal/risk"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
@@ -33,7 +34,11 @@ func routeFrontDoor(r zip.Router, db orm.DB) {
|
||||
zip.Alias(r.Get, PathAccount, LegacyPathAccount, getAccount(db))
|
||||
// Account creation + email/phone OTP send. signup is JSON; the OTP send is
|
||||
// multipart/form-data (HIP-0111 §4 invariant), read via fiber's FormValue.
|
||||
r.Post(PathSignup, signupHandler(db))
|
||||
// ONE scorer client for the process: it holds a connection pool and reads its
|
||||
// configuration once. An unconfigured deployment gets a client that answers
|
||||
// "absent", which the fail policy in internal/risk turns into an allow for an
|
||||
// ordinary sign-up and a refusal for one that would mint a tenant.
|
||||
r.Post(PathSignup, signupHandler(db, risk.New(httpx.ServiceToken())))
|
||||
zip.Alias(r.Post, PathVerificationCodes, LegacyPathVerificationCodes, sendVerificationCode(db))
|
||||
|
||||
// The session/identity front door the console drives once a user is signed in:
|
||||
|
||||
@@ -154,13 +154,25 @@ func generateCode(n int) (string, error) {
|
||||
return fmt.Sprintf("%0*d", n, k), nil
|
||||
}
|
||||
|
||||
// CheckVerificationCode reports whether code matches the latest unused,
|
||||
// unexpired verification record sent to receiver — the check side of the OTP
|
||||
// surface, which the signup email/phone gate calls ahead of account creation at
|
||||
// cutover. The compare is constant-time; an expired or absent record fails
|
||||
// closed. It does NOT consume the record (the caller marks it used on the flow
|
||||
// it gates).
|
||||
func CheckVerificationCode(ctx context.Context, db orm.DB, receiver, code string) (bool, error) {
|
||||
// SpendVerificationCode verifies code against the latest unused, unexpired
|
||||
// record sent to receiver AND CONSUMES IT — the check side of the OTP surface,
|
||||
// which the sign-up risk gate calls before an account is created.
|
||||
//
|
||||
// PRESENTING A CODE SPENDS IT. That is what makes a one-time code one-time, and
|
||||
// it is not a detail: the gate asks for a code precisely to establish that ONE
|
||||
// person controls ONE address, so a code that stayed valid for the rest of its
|
||||
// ten minutes would let one proven address open account after account — defeating
|
||||
// the multi-account control it was issued to enforce.
|
||||
//
|
||||
// The record is burned BEFORE the answer is returned, mirroring the
|
||||
// authorization-code redemption beside it (signin.go): a replay loses the race
|
||||
// and gets nothing. A burn that cannot be written fails CLOSED — an unspendable
|
||||
// code is not a spent one, and answering true would hand out the reuse this
|
||||
// function exists to prevent.
|
||||
//
|
||||
// The compare is constant-time; an expired, absent or already-spent record fails
|
||||
// closed with one opaque answer, so a prober cannot tell them apart.
|
||||
func SpendVerificationCode(ctx context.Context, db orm.DB, receiver, code string) (bool, error) {
|
||||
if receiver == "" || code == "" {
|
||||
return false, nil
|
||||
}
|
||||
@@ -174,5 +186,12 @@ func CheckVerificationCode(ctx context.Context, db orm.DB, receiver, code string
|
||||
if nowFunc().Unix()-rec.Time > int64(verificationCodeTTL/time.Second) {
|
||||
return false, nil
|
||||
}
|
||||
return cred.ConstantTimeEqual(rec.Code, code), nil
|
||||
if !cred.ConstantTimeEqual(rec.Code, code) {
|
||||
return false, nil
|
||||
}
|
||||
rec.IsUsed = true
|
||||
if err := rec.UpdateCtx(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
@@ -40,7 +42,8 @@ func sendCode(t *testing.T, app *zip.App, fields map[string]string) (int, map[st
|
||||
|
||||
// The happy path parses the multipart form, persists a 6-digit unused code
|
||||
// bound to the receiver, and reports ok — and that code then verifies through
|
||||
// CheckVerificationCode while a wrong one fails closed.
|
||||
// SpendVerificationCode while a wrong one fails closed AND the right one is
|
||||
// spent by being presented.
|
||||
func TestSendVerificationCode_PersistsAndVerifies(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret"})
|
||||
@@ -72,16 +75,32 @@ func TestSendVerificationCode_PersistsAndVerifies(t *testing.T) {
|
||||
t.Errorf("record.User = %q, want hanzo/alice (resolved from the dest)", rec.User)
|
||||
}
|
||||
|
||||
// The validation surface: the persisted code verifies, a wrong one does not.
|
||||
if ok, err := CheckVerificationCode(ctx, db, "alice@hanzo.ai", rec.Code); err != nil || !ok {
|
||||
t.Fatalf("correct code must verify: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if ok, _ := CheckVerificationCode(ctx, db, "alice@hanzo.ai", "000000"); ok {
|
||||
// The validation surface: a wrong code and a wrong receiver both fail closed,
|
||||
// and neither spends anything.
|
||||
if ok, _ := SpendVerificationCode(ctx, db, "alice@hanzo.ai", "000000"); ok {
|
||||
t.Error("a wrong code must not verify")
|
||||
}
|
||||
if ok, _ := CheckVerificationCode(ctx, db, "nobody@hanzo.ai", rec.Code); ok {
|
||||
if ok, _ := SpendVerificationCode(ctx, db, "nobody@hanzo.ai", rec.Code); ok {
|
||||
t.Error("a code must not verify for a different receiver")
|
||||
}
|
||||
|
||||
// The right code verifies ONCE. Presenting it spends it, which is what makes a
|
||||
// one-time code one-time — without this, one proven address could answer a
|
||||
// sign-up challenge again and again for the rest of the ten-minute window and
|
||||
// open account after account.
|
||||
if ok, err := SpendVerificationCode(ctx, db, "alice@hanzo.ai", rec.Code); err != nil || !ok {
|
||||
t.Fatalf("correct code must verify: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if ok, _ := SpendVerificationCode(ctx, db, "alice@hanzo.ai", rec.Code); ok {
|
||||
t.Fatal("a spent code must not verify a second time")
|
||||
}
|
||||
spent, err := orm.Get[schema.VerificationRecord](db, rec.Owner+"/"+rec.Name)
|
||||
if err != nil {
|
||||
t.Fatalf("re-read the record: %v", err)
|
||||
}
|
||||
if !spent.IsUsed {
|
||||
t.Fatal("the record must be marked used, not merely unreachable by the lookup")
|
||||
}
|
||||
}
|
||||
|
||||
// A urlencoded body reaches the same handler (fiber's FormValue reads both) —
|
||||
|
||||
+44
-10
@@ -14,9 +14,10 @@ import (
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/internal/risk"
|
||||
"github.com/hanzoai/iam/internal/users"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
"github.com/hanzoai/iam/internal/users"
|
||||
)
|
||||
|
||||
// The native front-door signup: POST /v1/iam/signup. The @hanzo/iam SDK + the
|
||||
@@ -48,14 +49,25 @@ type signupForm struct {
|
||||
Phone string `json:"phone"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
Affiliation string `json:"affiliation"`
|
||||
// Code answers a RequiredVerify challenge: the verification code sent to
|
||||
// Email by POST /v1/iam/send-verification-code. Absent on an unchallenged
|
||||
// sign-up, which is most of them.
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// signupHandler creates an account from the sign-up form and applies the
|
||||
// signupHandler creates an account from the sign-up form, applies the
|
||||
// application's own sign-up rules — whether self-service registration is open at
|
||||
// all, and which fields it requires.
|
||||
// all, and which fields it requires — and has the registration judged before
|
||||
// anything is written.
|
||||
//
|
||||
// THE ORDER IS THE DESIGN: every deterministic check, then the risk gate, then
|
||||
// every write. A sign-up that breaks a rule is refused without costing a screen,
|
||||
// and a sign-up that is refused leaves nothing behind — including the
|
||||
// organization, which self-serve creation used to mint before the user was
|
||||
// validated at all.
|
||||
//
|
||||
// The password is hashed before it is stored and is never returned.
|
||||
func signupHandler(db orm.DB) zip.Handler {
|
||||
func signupHandler(db orm.DB, sc *risk.Client) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
var f signupForm
|
||||
if err := c.Bind(&f); err != nil {
|
||||
@@ -109,7 +121,12 @@ func signupHandler(db orm.DB) zip.Handler {
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if org == nil {
|
||||
// mintsTenant: this sign-up would create the ORGANIZATION as well as the
|
||||
// user. Decided here, from a read, and acted on further down — because it is
|
||||
// both the thing that must be validated before anything is written AND the
|
||||
// thing that makes the risk gate fail CLOSED. A tenant is standing authority.
|
||||
mintsTenant := org == nil
|
||||
if mintsTenant {
|
||||
// Self-serve org creation — the founder signs up and their org is minted
|
||||
// with them. It is OPT-IN per application (orgChoiceMode == orgChoiceCreate)
|
||||
// so an app that names one tenant can never mint another: the tenant gate
|
||||
@@ -128,11 +145,11 @@ func signupHandler(db orm.DB) zip.Handler {
|
||||
if msg := orgNamePolicyError(f.Organization); msg != "" {
|
||||
return httpx.Err(c, msg)
|
||||
}
|
||||
created, err := store.CreateOrganization(ctx, db, f.Organization)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
org = created
|
||||
// The org does not exist yet, so it has no PasswordOptions of its own. The
|
||||
// platform floor in passwordPolicyError still applies — it is the invariant
|
||||
// options can only make stricter, never an option itself. The row itself is
|
||||
// written after the gate, so a refused registration leaves no orphan org.
|
||||
org = &schema.Organization{Name: f.Organization}
|
||||
} else if f.Organization != app.Organization && !app.IsShared {
|
||||
// The org ALREADY EXISTS and belongs to someone else. Org choice grants the
|
||||
// right to name YOUR OWN org — to mint one above, or to land in the app's
|
||||
@@ -188,6 +205,23 @@ func signupHandler(db orm.DB) zip.Handler {
|
||||
return httpx.Err(c, msg)
|
||||
}
|
||||
|
||||
// THE RISK GATE. Every deterministic refusal is behind us, so this is the
|
||||
// first cost the sign-up incurs and the last decision before anything is
|
||||
// written. It answers the request itself when it challenges or refuses.
|
||||
if answered, err := signupGate(c, db, sc, app, f, mintsTenant); answered || err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// FIRST WRITE. The tenant is minted only now, after the sign-up has passed
|
||||
// every rule and the gate — so a refused registration leaves no orphan org.
|
||||
if mintsTenant {
|
||||
created, err := store.CreateOrganization(ctx, db, f.Organization)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
org = created
|
||||
}
|
||||
|
||||
// Create through the ONE canonical user path (users.Create): argon2id-hash the
|
||||
// password once, persist, return the REDACTED row (no plaintext, no digest ever
|
||||
// stored or returned). PasswordType is stamped "argon2id" — exactly what
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
// The sign-up risk gate — the ONE place a registration is judged before an
|
||||
// account exists. It is the counterpart to the second-factor gate beside it
|
||||
// (mfa_gate.go): same shape, same rule that a gate present in one branch is not a
|
||||
// gate, and the same answer form the client already knows how to read.
|
||||
//
|
||||
// IT DOES NOT SCORE. The question goes to /v1/risk (internal/risk), which holds
|
||||
// the per-org feature surface a score needs — velocity per address, subnet and
|
||||
// email domain, address and ASN reputation, disposable-mailbox lists, prior
|
||||
// accounts on the same device, and the org's own history. Nothing here
|
||||
// re-derives any of that: a second scorer would be a second answer to one
|
||||
// question, and the two would disagree without anyone noticing.
|
||||
//
|
||||
// WHERE IT RUNS. After every deterministic policy check and BEFORE the first
|
||||
// write. That ordering is load-bearing twice over:
|
||||
// - a sign-up that fails a username, email or password rule costs no screen,
|
||||
// because those refusals need no judgement;
|
||||
// - a refused sign-up leaves NOTHING behind. Self-serve org creation used to
|
||||
// mint the organization before the user was validated, so a sign-up that
|
||||
// failed the password floor left an empty tenant. Now every write happens
|
||||
// after the gate, so a refusal is a no-op.
|
||||
//
|
||||
// THREE OUTCOMES, and each is a real path rather than a label:
|
||||
//
|
||||
// allow / review — the account is created. A review is recorded and a person
|
||||
// looks at it; it does not stop a legitimate sign-up.
|
||||
// challenge — the account is NOT created until the address is proven.
|
||||
// The client is told RequiredVerify, calls
|
||||
// POST /v1/iam/send-verification-code, and re-posts the
|
||||
// sign-up with the code. Verification is the EXISTING
|
||||
// primitive (SpendVerificationCode) — one way to prove an
|
||||
// address, whoever asked for the proof — and presenting a code
|
||||
// SPENDS it, or one proven address would open account after
|
||||
// account and the control would prove nothing.
|
||||
// block — refused, opaquely, with a reference the person can quote.
|
||||
//
|
||||
// FAIL POLICY. Owned by internal/risk, in one function, so it cannot drift:
|
||||
// an unreachable or unconfigured scorer ALLOWS an ordinary sign-up and REFUSES
|
||||
// one that would mint a tenant.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/internal/risk"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
// RequiredVerify is the protocol string a challenged sign-up answers with. Like
|
||||
// RequiredMfa and NextMfa beside it, the client STRING-COMPARES `data` against
|
||||
// it, so it is serialized format: it names the proof still owed, and the client
|
||||
// diverts to send-verification-code and re-posts with the code.
|
||||
const RequiredVerify = "RequiredVerify"
|
||||
|
||||
// signupRefusal is what a blocked sign-up is told. Deliberately one sentence with
|
||||
// no detail: a refusal that explains itself is a refusal an attacker tunes
|
||||
// against, and it would also be an oracle for whether an address, an org or a
|
||||
// device is already known. The reference is the appeal path — support can fetch
|
||||
// the whole judgement from GET /v1/risk/decisions/{id}.
|
||||
const signupRefusal = "we could not complete this sign-up"
|
||||
|
||||
// signupGate judges a registration. It reports true when it ANSWERED the request
|
||||
// (challenged or refused), in which case the caller must not proceed — the same
|
||||
// contract as the MFA gate, so the two read alike at their call sites.
|
||||
//
|
||||
// app is the SERVER-RESOLVED application: the row the handler looked up by the
|
||||
// presented clientId or name. Its Organization is the one tenant on this request
|
||||
// that no client chose, and it is therefore the tenant every side effect below is
|
||||
// keyed to — the decision record, the scorer's scope, the analytics copy.
|
||||
//
|
||||
// mintsTenant says this sign-up would create the organization as well as the
|
||||
// user. That is a grant of standing authority — a new tenant, its wallet, its
|
||||
// billing identity, its first admin — so it takes the FAIL-CLOSED branch of the
|
||||
// policy in internal/risk.
|
||||
func signupGate(c *zip.Ctx, db orm.DB, sc *risk.Client, app *schema.Application, f signupForm, mintsTenant bool) (bool, error) {
|
||||
email := strings.ToLower(strings.TrimSpace(f.Email))
|
||||
tenant := signupTenant(app)
|
||||
q := risk.Query{
|
||||
Stage: risk.StageSignup,
|
||||
Org: tenant,
|
||||
Subject: risk.Subject{Kind: "account", ID: tenant + "/" + f.Username},
|
||||
Privileged: mintsTenant,
|
||||
Signals: signupSignals(c, f, email, mintsTenant),
|
||||
}
|
||||
v := sc.Decide(c.Context(), q)
|
||||
|
||||
// DURABLE FIRST. The decision is a security record: it is written to the IAM
|
||||
// store before the outcome reaches the client, and the analytics copy is emitted
|
||||
// afterwards and best-effort. Wired the other way — record via the event door —
|
||||
// a bus hiccup would lose the evidence and the loss would be invisible.
|
||||
recordSignupDecision(c, db, tenant, f, v, mintsTenant)
|
||||
emitSignupEvent(tenant, v, q.Subject.ID)
|
||||
|
||||
if v.Allowed() {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if v.Action == risk.ActionChallenge {
|
||||
// A challenge is answerable: prove the address. A sign-up that already
|
||||
// carries a valid code for the address it claims has answered it, and
|
||||
// proceeds. Anything else is told what is owed.
|
||||
if ok, err := signupCodeVerified(c.Context(), db, email, f.Code); err != nil {
|
||||
// One opaque answer: a store failure must not become an oracle for
|
||||
// whether an address has a code outstanding.
|
||||
return true, httpx.Err(c, "the verification code is invalid or expired")
|
||||
} else if ok {
|
||||
return false, nil
|
||||
}
|
||||
return true, httpx.Ok(c, RequiredVerify)
|
||||
}
|
||||
|
||||
// block and restrict both refuse. They are one wire answer on purpose: a
|
||||
// client that could tell them apart could tell how close it got.
|
||||
return true, httpx.Err(c, refusalWithRef(v))
|
||||
}
|
||||
|
||||
// signupCodeVerified reports whether the sign-up presented a valid verification
|
||||
// code for the address it claims, and SPENDS it when it did. It reuses
|
||||
// SpendVerificationCode — the ONE way an address is proven in this service —
|
||||
// rather than introducing a second.
|
||||
//
|
||||
// Spending here rather than after the account is written is deliberate. By the
|
||||
// time the gate runs, every rule the sign-up could break has already passed, so
|
||||
// almost nothing between here and the create can fail; and verifying without
|
||||
// spending leaves a window in which two concurrent sign-ups answer one challenge.
|
||||
// A one-time code is spent by being presented.
|
||||
//
|
||||
// An empty address or an empty code is simply "not proven": a challenge to a
|
||||
// sign-up that supplied no email cannot be answered, and saying so is honest.
|
||||
func signupCodeVerified(ctx context.Context, db orm.DB, email, code string) (bool, error) {
|
||||
if email == "" || strings.TrimSpace(code) == "" {
|
||||
return false, nil
|
||||
}
|
||||
return SpendVerificationCode(ctx, db, email, strings.TrimSpace(code))
|
||||
}
|
||||
|
||||
func refusalWithRef(v risk.Verdict) string {
|
||||
if v.ID == "" {
|
||||
return signupRefusal
|
||||
}
|
||||
return signupRefusal + " (reference " + v.ID + ")"
|
||||
}
|
||||
|
||||
// signupSignals are the FACTS the scorer is given. Facts only: this function
|
||||
// derives no reputation, computes no velocity and consults no list — those are
|
||||
// the risk plane's, over data IAM cannot see.
|
||||
//
|
||||
// The password is never a signal, in any form. Neither is a hash of it: a
|
||||
// per-signup digest travelling to another service is a credential-shaped value
|
||||
// leaving the only process that should ever hold one.
|
||||
func signupSignals(c *zip.Ctx, f signupForm, email string, mintsTenant bool) map[string]string {
|
||||
s := map[string]string{
|
||||
"ip": clientIP(c),
|
||||
"forwardedBy": c.Header("X-Forwarded-For"),
|
||||
"language": c.Header("Accept-Language"),
|
||||
"application": f.Application,
|
||||
"clientId": f.ClientId,
|
||||
"username": f.Username,
|
||||
"mintsTenant": boolString(mintsTenant),
|
||||
// The organization the caller ASKED to join. A signal, deliberately —
|
||||
// signals are the scorer's evidence and may be anything the request said,
|
||||
// where the tenant (Query.Org) is the keyspace the scorer works in and must
|
||||
// be the server's own answer. Sending it here keeps the fact without letting
|
||||
// the fact choose the tenant.
|
||||
"requestedOrg": f.Organization,
|
||||
}
|
||||
if email != "" {
|
||||
s["email"] = email
|
||||
// The domain is sent SEPARATELY as well as inside the address, because
|
||||
// domain-level velocity and disposable-mailbox lists key on it and a scorer
|
||||
// should not have to re-parse an address to ask its own question.
|
||||
if at := strings.LastIndexByte(email, '@'); at >= 0 && at+1 < len(email) {
|
||||
s["emailDomain"] = email[at+1:]
|
||||
}
|
||||
}
|
||||
if f.Phone != "" {
|
||||
s["phone"] = f.Phone
|
||||
s["countryCode"] = f.CountryCode
|
||||
}
|
||||
// A fact we do not have is ABSENT, never empty — the header that did not
|
||||
// arrive, the address the load balancer did not pass on. An empty string is a
|
||||
// value a scorer can group by, and grouping by it puts every signup with no
|
||||
// address in one bucket.
|
||||
return risk.Facts(s)
|
||||
}
|
||||
|
||||
func boolString(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
// clientIP is the caller's address, by the ONE rule — httpx.ClientIP. It goes
|
||||
// into a velocity counter and into a durable audit row, and both are places a
|
||||
// client-chosen value must never reach: the LEFT-most X-Forwarded-For entry is
|
||||
// whatever the caller typed, so reading it let one host present a million
|
||||
// addresses, poison another address's reputation, and evade its own.
|
||||
func clientIP(c *zip.Ctx) string { return httpx.ClientIP(c) }
|
||||
|
||||
// signupTenant is the tenant a sign-up's side effects belong to: the organization
|
||||
// that owns the APPLICATION the registration was posted to.
|
||||
//
|
||||
// IT IS NEVER f.Organization. That field is a request body written by an
|
||||
// unauthenticated caller — the whole point of a sign-up is that nobody has
|
||||
// authenticated yet — and using it as the owner of a durable row let anyone on
|
||||
// the internet choose which tenant's append-only audit trail received a write. A
|
||||
// shared application admits any existing organization by design, so the choice
|
||||
// reached real tenants; an org-choice application admits names that do not exist
|
||||
// yet, so it also let rows be pre-seeded under a name someone would later be
|
||||
// given. Either way the tenant column meant "whatever was typed".
|
||||
//
|
||||
// The application is resolved SERVER-SIDE, by a store lookup on the presented
|
||||
// clientId, and its Organization is a value the request cannot set. Choosing a
|
||||
// different application still only ever writes to the tenant whose front door was
|
||||
// actually knocked on, which is a true fact about the event rather than a claim
|
||||
// about it. The organization the caller REQUESTED is kept — as a detail of the
|
||||
// record, where a claim belongs, not as its key.
|
||||
func signupTenant(app *schema.Application) string {
|
||||
if app == nil {
|
||||
return ""
|
||||
}
|
||||
return app.Organization
|
||||
}
|
||||
|
||||
// recordSignupDecision writes the judgement to the append-only audit trail — the
|
||||
// durable record, in IAM's own store, written before the client is answered.
|
||||
//
|
||||
// tenant is the server-derived owner (signupTenant). It records the DECISION,
|
||||
// never the request body: the sign-up form carries a password, and an audit row is
|
||||
// exactly the kind of place a password must never reach. A failed write is logged
|
||||
// into the response of nothing — it must not turn a legitimate sign-up into an
|
||||
// error, because the alternative to an unrecorded allow is a person who cannot
|
||||
// create an account.
|
||||
func recordSignupDecision(c *zip.Ctx, db orm.DB, tenant string, f signupForm, v risk.Verdict, mintsTenant bool) {
|
||||
if tenant == "" {
|
||||
// No resolved application means no tenant to attribute the event to, and an
|
||||
// unattributable row in a per-tenant trail is worse than no row: it is a
|
||||
// record nobody owns and nobody reviews. The handler refuses such a request
|
||||
// before the gate runs, so this is a guard, not a path.
|
||||
return
|
||||
}
|
||||
detail, err := json.Marshal(map[string]any{
|
||||
"decision": v.ID,
|
||||
"action": v.Action,
|
||||
"score": v.Score,
|
||||
"cause": v.Cause,
|
||||
"refusal": v.Refusal,
|
||||
"scored": v.Scored(),
|
||||
"mintsTenant": mintsTenant,
|
||||
// The organization the CALLER asked for — a claim, recorded as one. It is
|
||||
// what makes the row still answer "who did they say they were" without
|
||||
// letting that answer choose the row's owner.
|
||||
"requestedOrg": f.Organization,
|
||||
"application": f.Application,
|
||||
"clientId": f.ClientId,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
id, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
row := orm.New[schema.AuditLog](db)
|
||||
row.Owner = tenant
|
||||
row.Name = id
|
||||
row.CreatedTime = time.Now().UTC().Format(time.RFC3339)
|
||||
row.Organization = tenant
|
||||
row.User = f.Username
|
||||
row.ClientIp = clientIP(c)
|
||||
row.Method = http.MethodPost
|
||||
row.RequestUri = PathSignup
|
||||
row.Action = "signup.risk." + v.Action
|
||||
row.Object = string(detail)
|
||||
row.Response = v.Refusal
|
||||
row.SetId(tenant + "/" + id)
|
||||
_ = row.CreateCtx(c.Context())
|
||||
}
|
||||
|
||||
// eventURLEnv names the analytics door's origin. Same api host as the scorer; a
|
||||
// separate variable so a deployment can point the two apart without either
|
||||
// silently becoming the other.
|
||||
const eventURLEnv = "EVENT_URL"
|
||||
|
||||
// emitSignupEvent sends the ANALYTICS COPY. Best-effort by construction and by
|
||||
// intent: /v1/event is a lossy door with an anonymous lane that drops by design,
|
||||
// so it is never the record — recordSignupDecision above already wrote that. This
|
||||
// exists so the decision joins the org's own event surface, which is what the
|
||||
// per-org model learns from.
|
||||
//
|
||||
// It runs on its own background context: the request context is recycled the
|
||||
// moment the handler returns, so a copy that borrowed it would be cancelled
|
||||
// before it left.
|
||||
func emitSignupEvent(org string, v risk.Verdict, subject string) {
|
||||
base := strings.TrimRight(strings.TrimSpace(os.Getenv(eventURLEnv)), "/")
|
||||
token := httpx.ServiceToken()
|
||||
if base == "" || token == "" {
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"event": "risk.decision",
|
||||
"type": "event",
|
||||
"distinctId": subject,
|
||||
"properties": map[string]any{
|
||||
"stage": risk.StageSignup,
|
||||
"action": v.Action,
|
||||
"score": v.Score,
|
||||
"decision": v.ID,
|
||||
"refusal": v.Refusal,
|
||||
"scored": v.Scored(),
|
||||
"product": "iam",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/v1/event", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if org != "" {
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
}
|
||||
resp, err := eventClient.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
}
|
||||
|
||||
// eventClient is the analytics door's own client: short timeout, no retry. A
|
||||
// copy that retried would outlive the thing it describes.
|
||||
var eventClient = &http.Client{Timeout: 2 * time.Second}
|
||||
@@ -0,0 +1,454 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
// The sign-up gate end to end: a real scorer over a real HTTP hop, a real store,
|
||||
// and the real handler. What is asserted is what the @hanzo/iam SDK would see.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/risk"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
// gateServer stands up a scorer that answers v, then builds the IAM app against
|
||||
// it. The environment is set BEFORE newServer, because the front door constructs
|
||||
// one scorer client per process at route time.
|
||||
//
|
||||
// asked collects the queries the scorer received, so a test can assert what IAM
|
||||
// sent as well as what it did with the answer.
|
||||
func gateServer(t *testing.T, answer func(q map[string]any) risk.Verdict) (*zip.App, orm.DB, *[]map[string]any) {
|
||||
t.Helper()
|
||||
seen := &[]map[string]any{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var q map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&q)
|
||||
q["_org"] = r.Header.Get("X-Org-Id")
|
||||
q["_auth"] = r.Header.Get("Authorization")
|
||||
*seen = append(*seen, q)
|
||||
_ = json.NewEncoder(w).Encode(answer(q))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
t.Setenv(risk.URLEnv, srv.URL)
|
||||
t.Setenv("HANZO_API_KEY", "service-token")
|
||||
|
||||
app, db := newServer(t)
|
||||
return app, db, seen
|
||||
}
|
||||
|
||||
// seedVerification files a live verification code for receiver, exactly as the
|
||||
// send endpoint would, and returns it — so a test can ANSWER a challenge rather
|
||||
// than assert around it.
|
||||
func seedVerification(t *testing.T, db orm.DB, receiver string) string {
|
||||
t.Helper()
|
||||
const code = "424242"
|
||||
rec := orm.New[schema.VerificationRecord](db)
|
||||
rec.Owner = "admin"
|
||||
rec.Name = "vr-" + receiver
|
||||
rec.Type = "email"
|
||||
rec.Receiver = receiver
|
||||
rec.Code = code
|
||||
rec.Time = time.Now().Unix()
|
||||
rec.SetId("admin/vr-" + receiver)
|
||||
if err := rec.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed verification: %v", err)
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func always(action string) func(map[string]any) risk.Verdict {
|
||||
return func(map[string]any) risk.Verdict {
|
||||
return risk.Verdict{ID: "d-" + action, Action: action, Score: 0.8, Cause: "test"}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- policy outcomes
|
||||
|
||||
func TestSignupGate_AllowCreatesTheAccount(t *testing.T) {
|
||||
app, db, asked := gateServer(t, always(risk.ActionAllow))
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s", redirectURIs: []string{testRedirect}, signup: true})
|
||||
seedOrg(t, db, "hanzo")
|
||||
|
||||
status, env := signupReq(t, app, signupBody("hanzo", "newbie"))
|
||||
if status != 200 || env["status"] != "ok" {
|
||||
t.Fatalf("allow must create the account: status=%d env=%v", status, env)
|
||||
}
|
||||
if u, _ := store.GetUserByName(context.Background(), db, "hanzo", "newbie"); u == nil {
|
||||
t.Fatal("the user was not created")
|
||||
}
|
||||
if len(*asked) != 1 {
|
||||
t.Fatalf("the scorer was asked %d times, want 1", len(*asked))
|
||||
}
|
||||
}
|
||||
|
||||
// Review proceeds: it summons a person, it does not stop a legitimate sign-up.
|
||||
func TestSignupGate_ReviewStillCreatesTheAccount(t *testing.T) {
|
||||
app, db, _ := gateServer(t, always(risk.ActionReview))
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s", redirectURIs: []string{testRedirect}, signup: true})
|
||||
seedOrg(t, db, "hanzo")
|
||||
|
||||
if status, env := signupReq(t, app, signupBody("hanzo", "newbie")); status != 200 || env["status"] != "ok" {
|
||||
t.Fatalf("review must not stop a sign-up: status=%d env=%v", status, env)
|
||||
}
|
||||
if u, _ := store.GetUserByName(context.Background(), db, "hanzo", "newbie"); u == nil {
|
||||
t.Fatal("the user was not created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignupGate_BlockRefusesAndLeavesNothingBehind(t *testing.T) {
|
||||
app, db, _ := gateServer(t, always(risk.ActionBlock))
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s", redirectURIs: []string{testRedirect}, signup: true})
|
||||
seedOrg(t, db, "hanzo")
|
||||
|
||||
_, env := signupReq(t, app, signupBody("hanzo", "newbie"))
|
||||
if env["status"] != "error" {
|
||||
t.Fatalf("block must refuse: %v", env)
|
||||
}
|
||||
msg, _ := env["msg"].(string)
|
||||
if !strings.Contains(msg, "d-block") {
|
||||
t.Fatalf("a refusal must carry a reference so it can be appealed: %q", msg)
|
||||
}
|
||||
// It must say nothing else — a refusal that explains itself is one an
|
||||
// attacker tunes against, and an oracle for what is already known.
|
||||
for _, leak := range []string{"0.8", "test", "score", "velocity"} {
|
||||
if strings.Contains(msg, leak) {
|
||||
t.Fatalf("the refusal read out the model (%q): %q", leak, msg)
|
||||
}
|
||||
}
|
||||
if u, _ := store.GetUserByName(context.Background(), db, "hanzo", "newbie"); u != nil {
|
||||
t.Fatal("a refused sign-up created the user anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// A challenge is ANSWERABLE. The account is not created until the address is
|
||||
// proven, and it is proven with the ONE existing primitive.
|
||||
func TestSignupGate_ChallengeIsAnsweredByVerifyingTheAddress(t *testing.T) {
|
||||
app, db, _ := gateServer(t, always(risk.ActionChallenge))
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s", redirectURIs: []string{testRedirect}, signup: true})
|
||||
seedOrg(t, db, "hanzo")
|
||||
|
||||
body := signupBody("hanzo", "newbie")
|
||||
body["email"] = "newbie@example.com"
|
||||
|
||||
_, env := signupReq(t, app, body)
|
||||
if env["status"] != "ok" || env["data"] != RequiredVerify {
|
||||
t.Fatalf("a challenge must answer %q in data: %v", RequiredVerify, env)
|
||||
}
|
||||
if u, _ := store.GetUserByName(context.Background(), db, "hanzo", "newbie"); u != nil {
|
||||
t.Fatal("a challenged sign-up created the account before the address was proven")
|
||||
}
|
||||
|
||||
// A wrong code does not answer it.
|
||||
body["code"] = "000000"
|
||||
if _, env := signupReq(t, app, body); env["data"] != RequiredVerify {
|
||||
t.Fatalf("a wrong code must not answer the challenge: %v", env)
|
||||
}
|
||||
|
||||
// The real code does.
|
||||
code := seedVerification(t, db, "newbie@example.com")
|
||||
body["code"] = code
|
||||
if _, env := signupReq(t, app, body); env["status"] != "ok" || env["data"] == RequiredVerify {
|
||||
t.Fatalf("a valid code must complete the sign-up: %v", env)
|
||||
}
|
||||
if u, _ := store.GetUserByName(context.Background(), db, "hanzo", "newbie"); u == nil {
|
||||
t.Fatal("the verified sign-up did not create the account")
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ fail policy
|
||||
|
||||
// A sign-up into an EXISTING org is ordinary: it must survive the risk plane
|
||||
// being down. Never break login.
|
||||
func TestSignupGate_OrdinarySignupSurvivesAScorerOutage(t *testing.T) {
|
||||
app, db, _ := gateServer(t, func(map[string]any) risk.Verdict { return risk.Verdict{} }) // silent scorer
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s", redirectURIs: []string{testRedirect}, signup: true})
|
||||
seedOrg(t, db, "hanzo")
|
||||
|
||||
if status, env := signupReq(t, app, signupBody("hanzo", "newbie")); status != 200 || env["status"] != "ok" {
|
||||
t.Fatalf("an ordinary sign-up must FAIL OPEN: status=%d env=%v", status, env)
|
||||
}
|
||||
}
|
||||
|
||||
// A sign-up that would MINT A TENANT is a grant of standing authority. On an
|
||||
// armed deployment whose scorer is down, it waits.
|
||||
func TestSignupGate_TenantCreationFailsClosedOnAScorerOutage(t *testing.T) {
|
||||
app, db, _ := gateServer(t, func(map[string]any) risk.Verdict { return risk.Verdict{} }) // silent scorer
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s", redirectURIs: []string{testRedirect}, signup: true, orgChoice: "create"})
|
||||
|
||||
_, env := signupReq(t, app, signupBody("brandnew", "founder"))
|
||||
if env["status"] != "error" {
|
||||
t.Fatalf("minting a tenant must FAIL CLOSED when the scorer is down: %v", env)
|
||||
}
|
||||
if o, _ := store.GetOrganizationByName(context.Background(), db, "brandnew"); o != nil {
|
||||
t.Fatal("a refused sign-up minted the organization anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// The privileged flag is SERVER-DERIVED from what the sign-up would do. A caller
|
||||
// cannot clear it, and it is not sent on the wire for the scorer to be talked out
|
||||
// of either.
|
||||
func TestSignupGate_PrivilegedIsDerivedNotDeclared(t *testing.T) {
|
||||
app, db, asked := gateServer(t, always(risk.ActionAllow))
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s", redirectURIs: []string{testRedirect}, signup: true, orgChoice: "create"})
|
||||
|
||||
body := signupBody("brandnew", "founder")
|
||||
body["privileged"] = "false" // a caller trying to talk its way out of the branch
|
||||
signupReq(t, app, body)
|
||||
|
||||
if len(*asked) != 1 {
|
||||
t.Fatalf("the scorer was asked %d times, want 1", len(*asked))
|
||||
}
|
||||
q := (*asked)[0]
|
||||
if _, present := q["privileged"]; present {
|
||||
t.Fatalf("privileged must not travel on the wire: %v", q)
|
||||
}
|
||||
sig, _ := q["signals"].(map[string]any)
|
||||
if sig["mintsTenant"] != "true" {
|
||||
t.Fatalf("the tenant-minting fact must reach the scorer as a signal: %v", sig)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- tenant scoping
|
||||
|
||||
// REGRESSION — the tenant a sign-up is judged and recorded under is the SERVER's
|
||||
// answer, never the body's. It used to be f.Organization: a request field, on the
|
||||
// one endpoint where by definition nobody has authenticated. A SHARED application
|
||||
// admits any existing organization by design, so that field reached real tenants —
|
||||
// an unauthenticated POST chose which tenant's per-org risk state was touched and
|
||||
// which tenant's append-only audit trail received a durable row.
|
||||
//
|
||||
// The application is resolved server-side from the presented clientId, and its
|
||||
// organization is the tenant. The organization the caller asked for is still sent —
|
||||
// as a SIGNAL, which is evidence, not as the keyspace.
|
||||
func TestSignupGate_ScoresUnderTheApplicationsTenantNotTheBodys(t *testing.T) {
|
||||
app, db, asked := gateServer(t, always(risk.ActionAllow))
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s", redirectURIs: []string{testRedirect}, signup: true, shared: true})
|
||||
seedOrg(t, db, "hanzo") // the application's own org
|
||||
seedOrg(t, db, "globex")
|
||||
|
||||
signupReq(t, app, signupBody("hanzo", "ann"))
|
||||
signupReq(t, app, signupBody("globex", "bob")) // names a DIFFERENT existing tenant
|
||||
|
||||
if len(*asked) != 2 {
|
||||
t.Fatalf("asked %d times, want 2", len(*asked))
|
||||
}
|
||||
for i, q := range *asked {
|
||||
if q["_org"] != "hanzo" {
|
||||
t.Fatalf("query %d was scored under %v — the body moved the tenant", i, q["_org"])
|
||||
}
|
||||
sub, _ := q["subject"].(map[string]any)
|
||||
want := []string{"hanzo/ann", "hanzo/bob"}[i]
|
||||
if sub["id"] != want {
|
||||
t.Fatalf("subject %d = %v, want %q", i, sub["id"], want)
|
||||
}
|
||||
}
|
||||
// The requested org is not lost — it is evidence, in the signals.
|
||||
sig, _ := (*asked)[1]["signals"].(map[string]any)
|
||||
if sig["requestedOrg"] != "globex" {
|
||||
t.Fatalf("the requested org must still reach the scorer as a signal: %v", sig)
|
||||
}
|
||||
}
|
||||
|
||||
// The same property on the DURABLE side: an unauthenticated caller must not be
|
||||
// able to write a row into another tenant's audit trail by naming it.
|
||||
func TestSignupGate_TheAuditRowCannotBeAimedAtAnotherTenant(t *testing.T) {
|
||||
app, db, _ := gateServer(t, always(risk.ActionBlock))
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s", redirectURIs: []string{testRedirect}, signup: true, shared: true})
|
||||
seedOrg(t, db, "hanzo") // the application's own org
|
||||
seedOrg(t, db, "globex")
|
||||
|
||||
signupReq(t, app, signupBody("globex", "mallory"))
|
||||
|
||||
victim, err := orm.TypedQuery[schema.AuditLog](db).Filter("owner", "globex").GetAll(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("query audit: %v", err)
|
||||
}
|
||||
if len(victim) != 0 {
|
||||
t.Fatalf("an unauthenticated request wrote %d row(s) into globex's audit trail", len(victim))
|
||||
}
|
||||
mine, err := orm.TypedQuery[schema.AuditLog](db).Filter("owner", "hanzo").GetAll(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("query audit: %v", err)
|
||||
}
|
||||
if len(mine) != 1 {
|
||||
t.Fatalf("the event belongs to the application's own tenant: got %d rows", len(mine))
|
||||
}
|
||||
if mine[0].Organization != "hanzo" || !strings.Contains(mine[0].Object, `"requestedOrg":"globex"`) {
|
||||
t.Fatalf("the row must be owned by the app's tenant and RECORD the claim: %+v", mine[0])
|
||||
}
|
||||
// The row id is keyed on the same server-derived tenant, so the natural key
|
||||
// cannot be aimed either.
|
||||
if !strings.HasPrefix(mine[0].Id(), "hanzo/") {
|
||||
t.Fatalf("the record's id must be keyed under the server-derived tenant: %q", mine[0].Id())
|
||||
}
|
||||
}
|
||||
|
||||
// REGRESSION — the address a sign-up is judged and recorded by is the one OUR
|
||||
// edge observed, not the one the caller typed. It was the LEFT-MOST
|
||||
// X-Forwarded-For entry: a field the client writes, feeding a per-address
|
||||
// velocity counter and a durable audit column, so one host could present a fresh
|
||||
// address per attempt (evading its own velocity) or repeatedly name a victim's
|
||||
// address (poisoning theirs).
|
||||
func TestSignupGate_TheClientAddressCannotBeForged(t *testing.T) {
|
||||
app, db, asked := gateServer(t, always(risk.ActionBlock))
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s", redirectURIs: []string{testRedirect}, signup: true})
|
||||
seedOrg(t, db, "hanzo")
|
||||
|
||||
req := jsonReq("POST", PathSignup, signupBody("hanzo", "newbie"))
|
||||
// The client writes the first entry; our edge appends what it actually saw.
|
||||
req.Header.Set("X-Forwarded-For", "9.9.9.9, 203.0.113.9")
|
||||
if _, err := app.Fiber().Test(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(*asked) != 1 {
|
||||
t.Fatalf("asked %d times, want 1", len(*asked))
|
||||
}
|
||||
sig, _ := (*asked)[0]["signals"].(map[string]any)
|
||||
if sig["ip"] != "203.0.113.9" {
|
||||
t.Fatalf("the scorer was given ip=%v, want the observed 203.0.113.9", sig["ip"])
|
||||
}
|
||||
rows, err := orm.TypedQuery[schema.AuditLog](db).Filter("owner", "hanzo").GetAll(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("query audit: %v", err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].ClientIp != "203.0.113.9" {
|
||||
t.Fatalf("the durable record must carry the observed address, got %d rows / %q", len(rows), rows[0].ClientIp)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- the record
|
||||
|
||||
// The decision is a durable, append-only record in IAM's own store, written
|
||||
// before the client is answered — and it never carries the password.
|
||||
func TestSignupGate_RecordsTheDecisionDurablyAndWithoutTheSecret(t *testing.T) {
|
||||
app, db, _ := gateServer(t, always(risk.ActionBlock))
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s", redirectURIs: []string{testRedirect}, signup: true})
|
||||
seedOrg(t, db, "hanzo")
|
||||
|
||||
body := signupBody("hanzo", "newbie")
|
||||
signupReq(t, app, body)
|
||||
|
||||
rows, err := orm.TypedQuery[schema.AuditLog](db).Filter("owner", "hanzo").GetAll(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("query audit: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("want exactly one decision record, got %d", len(rows))
|
||||
}
|
||||
r := rows[0]
|
||||
if r.Action != "signup.risk.block" {
|
||||
t.Fatalf("action = %q, want signup.risk.block", r.Action)
|
||||
}
|
||||
if r.Organization != "hanzo" || r.User != "newbie" {
|
||||
t.Fatalf("the record must name the tenant and the account: %+v", r)
|
||||
}
|
||||
blob := r.Object + r.Response
|
||||
if strings.Contains(blob, body["password"]) {
|
||||
t.Fatalf("the password reached the audit record: %s", blob)
|
||||
}
|
||||
var detail map[string]any
|
||||
if err := json.Unmarshal([]byte(r.Object), &detail); err != nil {
|
||||
t.Fatalf("the record must be structured: %v", err)
|
||||
}
|
||||
if detail["decision"] != "d-block" || detail["scored"] != true {
|
||||
t.Fatalf("the record must carry the decision and whether it was scored: %v", detail)
|
||||
}
|
||||
}
|
||||
|
||||
// An unscored allow is recorded AS unscored. Silence must never read as a clean
|
||||
// result — this is the only way an operator learns the risk plane is dark.
|
||||
func TestSignupGate_AnUnscoredAllowIsRecordedAsUnscored(t *testing.T) {
|
||||
t.Setenv(risk.URLEnv, "")
|
||||
t.Setenv("HANZO_API_KEY", "service-token")
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s", redirectURIs: []string{testRedirect}, signup: true})
|
||||
seedOrg(t, db, "hanzo")
|
||||
|
||||
if status, env := signupReq(t, app, signupBody("hanzo", "newbie")); status != 200 || env["status"] != "ok" {
|
||||
t.Fatalf("an unarmed deployment must still sign people up: %v", env)
|
||||
}
|
||||
rows, _ := orm.TypedQuery[schema.AuditLog](db).Filter("owner", "hanzo").GetAll(context.Background())
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("want one record, got %d", len(rows))
|
||||
}
|
||||
var detail map[string]any
|
||||
_ = json.Unmarshal([]byte(rows[0].Object), &detail)
|
||||
if detail["scored"] != false || detail["refusal"] != risk.RefusalAbsent {
|
||||
t.Fatalf("an unscored allow must say so: %v", detail)
|
||||
}
|
||||
}
|
||||
|
||||
// A sign-up that breaks a deterministic rule must be refused WITHOUT costing a
|
||||
// screen: the scorer is asked about registrations, not about typos.
|
||||
func TestSignupGate_InvalidSignupsAreNotScreened(t *testing.T) {
|
||||
app, db, asked := gateServer(t, always(risk.ActionAllow))
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s", redirectURIs: []string{testRedirect}, signup: true})
|
||||
seedOrg(t, db, "hanzo")
|
||||
|
||||
short := signupBody("hanzo", "newbie")
|
||||
short["password"] = "abc" // under the platform floor
|
||||
if _, env := signupReq(t, app, short); env["status"] != "error" {
|
||||
t.Fatalf("a short password must be refused: %v", env)
|
||||
}
|
||||
reserved := signupBody("admin", "root")
|
||||
if _, env := signupReq(t, app, reserved); env["status"] != "error" {
|
||||
t.Fatalf("a reserved org must be refused: %v", env)
|
||||
}
|
||||
if len(*asked) != 0 {
|
||||
t.Fatalf("the scorer was asked %d times about invalid sign-ups; want 0", len(*asked))
|
||||
}
|
||||
}
|
||||
|
||||
func signupBody(org, user string) map[string]string {
|
||||
return map[string]string{
|
||||
"application": "conf",
|
||||
"organization": org,
|
||||
"username": user,
|
||||
"password": "correct horse battery staple",
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of challenging a sign-up is to establish that ONE person
|
||||
// controls ONE address. A code that stayed valid after being presented would let
|
||||
// one proven address open account after account for the rest of its ten-minute
|
||||
// window — defeating exactly the multi-account control the challenge exists for.
|
||||
func TestSignupGate_AVerificationCodeAnswersOneChallengeOnly(t *testing.T) {
|
||||
app, db, _ := gateServer(t, always(risk.ActionChallenge))
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s", redirectURIs: []string{testRedirect}, signup: true})
|
||||
seedOrg(t, db, "hanzo")
|
||||
|
||||
code := seedVerification(t, db, "one@example.com")
|
||||
|
||||
first := signupBody("hanzo", "accountone")
|
||||
first["email"] = "one@example.com"
|
||||
first["code"] = code
|
||||
if _, env := signupReq(t, app, first); env["status"] != "ok" || env["data"] == RequiredVerify {
|
||||
t.Fatalf("the first sign-up must complete: %v", env)
|
||||
}
|
||||
if u, _ := store.GetUserByName(context.Background(), db, "hanzo", "accountone"); u == nil {
|
||||
t.Fatal("the first account was not created")
|
||||
}
|
||||
|
||||
// The SAME code, the same address, a different username: refused.
|
||||
second := signupBody("hanzo", "accounttwo")
|
||||
second["email"] = "two@example.com" // a different address, so uniqueness does not mask it
|
||||
second["code"] = code
|
||||
if _, env := signupReq(t, app, second); env["data"] != RequiredVerify {
|
||||
t.Fatalf("a spent code must not answer a second challenge: %v", env)
|
||||
}
|
||||
if u, _ := store.GetUserByName(context.Background(), db, "hanzo", "accounttwo"); u != nil {
|
||||
t.Fatal("a replayed code created a second account")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package risk asks the platform's scoring plane — POST /v1/risk/decide — what
|
||||
// to do about a lifecycle moment, and applies ONE fail policy to the answer.
|
||||
//
|
||||
// IAM does not score. It cannot: the features that separate a real sign-up from
|
||||
// the tenth account on one card — velocity per address, subnet and email domain,
|
||||
// address and ASN reputation, disposable-mailbox lists, the shape of the org's
|
||||
// own history — live in the risk plane, over a per-org feature surface IAM has no
|
||||
// view of. Duplicating even a slice of that here would produce a SECOND answer to
|
||||
// one question, and the two would disagree silently. So this package sends
|
||||
// signals and receives an action; everything about how the action was reached
|
||||
// belongs to /v1/risk.
|
||||
//
|
||||
// THE FAIL POLICY, in one function, for the same reason:
|
||||
//
|
||||
// ordinary sign-up — an unreachable, erroring or unconfigured scorer ALLOWS.
|
||||
// A risk plane that is down must never be able to stop
|
||||
// people signing in or signing up.
|
||||
// privileged grant — the same conditions REFUSE. A sign-up that MINTS A TENANT
|
||||
// is a grant of standing authority: a new org, its wallet,
|
||||
// its billing identity, its first admin. Handing that out
|
||||
// unjudged because the judge is out is not resilience.
|
||||
//
|
||||
// Every answer carries a Refusal naming why it is not a scored one, so an
|
||||
// allow-because-nobody-was-listening is never recorded as a clean result.
|
||||
package risk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The lifecycle stages. A stage selects the feature window and the rule set on
|
||||
// the scorer's side.
|
||||
const (
|
||||
StageSignup = "signup"
|
||||
)
|
||||
|
||||
// The action vocabulary. A scorer answering anything else is treated as having
|
||||
// not answered — the fail policy applies — rather than guessed at.
|
||||
const (
|
||||
ActionAllow = "allow"
|
||||
ActionReview = "review"
|
||||
ActionChallenge = "challenge"
|
||||
ActionRestrict = "restrict"
|
||||
ActionBlock = "block"
|
||||
)
|
||||
|
||||
// The reasons an answer is not a scored one.
|
||||
const (
|
||||
RefusalAbsent = "scorer-absent"
|
||||
RefusalError = "scorer-error"
|
||||
RefusalSilent = "scorer-silent"
|
||||
RefusalUnknown = "scorer-unknown"
|
||||
)
|
||||
|
||||
// Budget bounds how long a decision may take. Sign-up is an interactive path, so
|
||||
// the ceiling is what a person will not notice; past it the fail policy answers.
|
||||
const Budget = 300 * time.Millisecond
|
||||
|
||||
// URLEnv names the scorer's origin — the api host that serves /v1/risk, e.g.
|
||||
// https://api.hanzo.ai. UNSET MEANS UNSCORED, deliberately: a deployment that has
|
||||
// not been pointed at a risk plane gets the fail policy rather than a fabricated
|
||||
// verdict, and its ordinary sign-ups keep working.
|
||||
const URLEnv = "RISK_URL"
|
||||
|
||||
// Subject names what is being judged.
|
||||
type Subject struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// Query is one question. Org is sent as the tenant HEADER rather than a body
|
||||
// field, mirroring every other cloud call: a tenant in the body is a tenant the
|
||||
// caller asserted for itself.
|
||||
type Query struct {
|
||||
Stage string `json:"stage"`
|
||||
Subject Subject `json:"subject"`
|
||||
Signals map[string]string `json:"signals,omitempty"`
|
||||
|
||||
// Privileged marks a grant of standing authority and selects the FAIL-CLOSED
|
||||
// branch. Server-derived from what the request would do, never from its body.
|
||||
Privileged bool `json:"-"`
|
||||
// Org is the tenant the decision is about. Not serialized — see above.
|
||||
Org string `json:"-"`
|
||||
}
|
||||
|
||||
// Verdict is the answer.
|
||||
type Verdict struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
Agency string `json:"agency,omitempty"`
|
||||
Cause string `json:"cause,omitempty"`
|
||||
Refusal string `json:"refusal,omitempty"`
|
||||
}
|
||||
|
||||
// Scored reports whether the verdict came from the scorer rather than the fail
|
||||
// policy.
|
||||
func (v Verdict) Scored() bool { return v.Refusal == "" }
|
||||
|
||||
// Allowed reports whether the lifecycle moment may proceed unchanged. Review
|
||||
// proceeds: it summons a person, it does not stop the request.
|
||||
func (v Verdict) Allowed() bool { return v.Action == ActionAllow || v.Action == ActionReview }
|
||||
|
||||
// Client is the scorer seam. The zero value and a nil pointer both behave as "no
|
||||
// scorer configured", so a caller never needs to branch on whether risk is wired.
|
||||
type Client struct {
|
||||
base string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New builds the client from the environment: RISK_URL for the origin and the
|
||||
// unified service token for the credential. Returns a client that answers
|
||||
// "absent" when either is missing — which is a working, safe deployment, not an
|
||||
// error to start up over.
|
||||
func New(token string) *Client {
|
||||
return &Client{
|
||||
base: strings.TrimRight(strings.TrimSpace(os.Getenv(URLEnv)), "/"),
|
||||
token: strings.TrimSpace(token),
|
||||
http: &http.Client{Timeout: Budget},
|
||||
}
|
||||
}
|
||||
|
||||
// Configured reports whether this deployment has a scorer to ask. For a health
|
||||
// report or a log line — never as a gate, since Decide handles absence itself.
|
||||
func (c *Client) Configured() bool { return c != nil && c.base != "" && c.token != "" }
|
||||
|
||||
// Decide asks the scorer and applies the fail policy. It never returns an error:
|
||||
// a caller needs an action, and "I could not tell you" IS an action — stated by
|
||||
// q.Privileged and named in Refusal.
|
||||
func (c *Client) Decide(ctx context.Context, q Query) Verdict {
|
||||
if !c.Configured() {
|
||||
return unavailable(q, RefusalAbsent)
|
||||
}
|
||||
v, err := c.ask(ctx, q)
|
||||
switch {
|
||||
case err != nil:
|
||||
return unavailable(q, RefusalError)
|
||||
case v.Action == "":
|
||||
return unavailable(q, RefusalSilent)
|
||||
case !known(v.Action):
|
||||
return unavailable(q, RefusalUnknown)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (c *Client) ask(ctx context.Context, q Query) (Verdict, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, Budget)
|
||||
defer cancel()
|
||||
|
||||
body, err := json.Marshal(q)
|
||||
if err != nil {
|
||||
return Verdict{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/v1/risk/decide", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Verdict{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
if q.Org != "" {
|
||||
req.Header.Set("X-Org-Id", q.Org)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return Verdict{}, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
// Bounded read: an unexpected upstream must not be able to make a sign-up
|
||||
// allocate without limit.
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||||
if err != nil {
|
||||
return Verdict{}, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return Verdict{}, &statusError{code: resp.StatusCode}
|
||||
}
|
||||
var v Verdict
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return Verdict{}, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
type statusError struct{ code int }
|
||||
|
||||
func (e *statusError) Error() string { return "risk: scorer answered HTTP " + itoa(e.code) }
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b [8]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
|
||||
// unavailable IS the fail policy, in one place.
|
||||
//
|
||||
// It turns on TWO facts, not one, and the second is the one that keeps this a
|
||||
// defense rather than an outage:
|
||||
//
|
||||
// privileged — the request grants standing authority, so silence must deny.
|
||||
// armed — this deployment HAS a scorer (RISK_URL and a credential are
|
||||
// configured). RefusalAbsent is the one refusal that means it does
|
||||
// not, and refusing to mint a tenant because a component was never
|
||||
// installed is not security: it is a product that cannot onboard.
|
||||
// Every other refusal means the scorer exists and did not answer,
|
||||
// which is exactly when a grant must wait.
|
||||
//
|
||||
// This is the same rule the cloud edge applies with a different arming signal:
|
||||
// there, the abuse gate only reaches its fail-closed branch for an org an
|
||||
// operator armed, and arming is refused while no scorer is installed. Two
|
||||
// mechanisms, one semantic — fail closed once armed, allow before.
|
||||
//
|
||||
// An unarmed deployment is not silently unarmed: every decision is recorded with
|
||||
// scored=false and this refusal, so "the risk plane is dark here" is a fact in
|
||||
// the audit trail rather than an inference.
|
||||
func unavailable(q Query, why string) Verdict {
|
||||
if q.Privileged && why != RefusalAbsent {
|
||||
return Verdict{Action: ActionBlock, Refusal: why}
|
||||
}
|
||||
return Verdict{Action: ActionAllow, Refusal: why}
|
||||
}
|
||||
|
||||
// Facts drops the empty values from a signal map, because a fact we do not have
|
||||
// must be ABSENT rather than empty. An empty string is a VALUE: a scorer keying
|
||||
// velocity on "ip" would group every signup whose client address never arrived —
|
||||
// behind a load balancer that terminates the connection without passing the peer,
|
||||
// that is all of them — into one very busy caller and refuse the lot. "We do not
|
||||
// know" and "it is the empty string" are different answers and only one of them
|
||||
// is true.
|
||||
//
|
||||
// Stated once, at the seam every question passes through, so no gate has to
|
||||
// remember it. The cloud edge states the same rule at its own seam
|
||||
// (cloud.Facts); the two are one rule with one meaning, in the two processes
|
||||
// that ask this scorer.
|
||||
func Facts(m map[string]string) map[string]string {
|
||||
for k, v := range m {
|
||||
if v == "" {
|
||||
delete(m, k)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func known(a string) bool {
|
||||
switch a {
|
||||
case ActionAllow, ActionReview, ActionChallenge, ActionRestrict, ActionBlock:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package risk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// scorer stands up a real HTTP scorer so the client is exercised end to end —
|
||||
// serialization, headers, status handling and the budget — rather than through a
|
||||
// fake that could agree with a wrong implementation.
|
||||
func scorer(t *testing.T, h http.HandlerFunc) *Client {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
t.Setenv(URLEnv, srv.URL)
|
||||
return New("service-token")
|
||||
}
|
||||
|
||||
// slow answers correctly but too late. It sleeps rather than blocking on the
|
||||
// request context so the test server can always shut down — a test that can hang
|
||||
// is a test that will.
|
||||
func slow(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(4 * Budget)
|
||||
_ = json.NewEncoder(w).Encode(Verdict{Action: ActionAllow})
|
||||
}
|
||||
|
||||
func answers(v Verdict) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_PassesAScoredVerdictThrough(t *testing.T) {
|
||||
c := scorer(t, answers(Verdict{ID: "d-1", Action: ActionBlock, Score: 0.93, Cause: "velocity"}))
|
||||
v := c.Decide(context.Background(), Query{Stage: StageSignup, Org: "acme"})
|
||||
if !v.Scored() || v.Action != ActionBlock || v.ID != "d-1" {
|
||||
t.Fatalf("verdict was reshaped in transit: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// The tenant is a HEADER, never a body field: a tenant in the body is a tenant
|
||||
// the caller asserted for itself.
|
||||
func TestDecide_SendsTheTenantAsAHeaderAndNeverInTheBody(t *testing.T) {
|
||||
var gotOrg string
|
||||
var body map[string]any
|
||||
c := scorer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
gotOrg = r.Header.Get("X-Org-Id")
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
_ = json.NewEncoder(w).Encode(Verdict{Action: ActionAllow})
|
||||
})
|
||||
c.Decide(context.Background(), Query{Stage: StageSignup, Org: "acme", Privileged: true})
|
||||
|
||||
if gotOrg != "acme" {
|
||||
t.Fatalf("X-Org-Id = %q, want acme", gotOrg)
|
||||
}
|
||||
for _, forbidden := range []string{"org", "Org", "organization", "privileged", "Privileged"} {
|
||||
if _, ok := body[forbidden]; ok {
|
||||
t.Fatalf("%q must not be serialized into the body: %v", forbidden, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_FailsOpenOnTheOrdinaryPath(t *testing.T) {
|
||||
ordinary := Query{Stage: StageSignup, Org: "acme"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
build func(*testing.T) *Client
|
||||
refusal string
|
||||
}{
|
||||
{"unconfigured", func(t *testing.T) *Client {
|
||||
t.Setenv(URLEnv, "")
|
||||
return New("service-token")
|
||||
}, RefusalAbsent},
|
||||
{"no credential", func(t *testing.T) *Client {
|
||||
t.Setenv(URLEnv, "https://api.example.test")
|
||||
return New("")
|
||||
}, RefusalAbsent},
|
||||
{"scorer 500s", func(t *testing.T) *Client {
|
||||
return scorer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(500) })
|
||||
}, RefusalError},
|
||||
{"scorer answers garbage", func(t *testing.T) *Client {
|
||||
return scorer(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("<html>")) })
|
||||
}, RefusalError},
|
||||
{"scorer answers with no action", func(t *testing.T) *Client {
|
||||
return scorer(t, answers(Verdict{Score: 0.5}))
|
||||
}, RefusalSilent},
|
||||
{"scorer answers outside the vocabulary", func(t *testing.T) *Client {
|
||||
return scorer(t, answers(Verdict{Action: "quarantine"}))
|
||||
}, RefusalUnknown},
|
||||
{"scorer answers past the budget", func(t *testing.T) *Client {
|
||||
return scorer(t, slow)
|
||||
}, RefusalError},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
v := tc.build(t).Decide(context.Background(), ordinary)
|
||||
if v.Action != ActionAllow {
|
||||
t.Fatalf("an ordinary sign-up must FAIL OPEN: action = %q", v.Action)
|
||||
}
|
||||
if v.Refusal != tc.refusal {
|
||||
t.Fatalf("refusal = %q, want %q", v.Refusal, tc.refusal)
|
||||
}
|
||||
if v.Scored() {
|
||||
t.Fatal("an unscored allow must not report itself as scored")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An ARMED deployment whose scorer is momentarily down must not mint a tenant.
|
||||
func TestDecide_FailsClosedOnAPrivilegedGrantWhenArmed(t *testing.T) {
|
||||
grant := Query{Stage: StageSignup, Org: "acme", Privileged: true}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
build func(*testing.T) *Client
|
||||
}{
|
||||
{"scorer 500s", func(t *testing.T) *Client {
|
||||
return scorer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(500) })
|
||||
}},
|
||||
{"scorer unreachable", func(t *testing.T) *Client {
|
||||
t.Setenv(URLEnv, "http://127.0.0.1:1") // nothing listens here
|
||||
return New("service-token")
|
||||
}},
|
||||
{"scorer answers with no action", func(t *testing.T) *Client {
|
||||
return scorer(t, answers(Verdict{}))
|
||||
}},
|
||||
{"scorer answers outside the vocabulary", func(t *testing.T) *Client {
|
||||
return scorer(t, answers(Verdict{Action: "maybe"}))
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
v := tc.build(t).Decide(context.Background(), grant)
|
||||
if v.Action != ActionBlock {
|
||||
t.Fatalf("a privileged grant on an ARMED deployment must FAIL CLOSED: action = %q", v.Action)
|
||||
}
|
||||
if v.Refusal == "" {
|
||||
t.Fatal("a fail-closed block must name why it could not be scored")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The one refusal that does NOT fail closed. A deployment that was never pointed
|
||||
// at a risk plane must still be able to onboard a tenant; refusing there would be
|
||||
// an outage wearing a defense's clothes, and it is recorded rather than silent.
|
||||
func TestDecide_AnUnarmedDeploymentStillOnboards(t *testing.T) {
|
||||
t.Setenv(URLEnv, "")
|
||||
c := New("service-token")
|
||||
if c.Configured() {
|
||||
t.Fatal("an unset RISK_URL must not read as configured")
|
||||
}
|
||||
v := c.Decide(context.Background(), Query{Stage: StageSignup, Org: "acme", Privileged: true})
|
||||
if v.Action != ActionAllow {
|
||||
t.Fatalf("an unarmed deployment must allow tenant creation: action = %q", v.Action)
|
||||
}
|
||||
if v.Refusal != RefusalAbsent {
|
||||
t.Fatalf("refusal = %q, want %q — the darkness must be recorded", v.Refusal, RefusalAbsent)
|
||||
}
|
||||
}
|
||||
|
||||
// A scored allow on a privileged grant is honoured: Privileged selects a BRANCH
|
||||
// of the fail policy, it does not deny on its own.
|
||||
func TestDecide_PrivilegedIsNotItselfADenial(t *testing.T) {
|
||||
c := scorer(t, answers(Verdict{ID: "d-9", Action: ActionAllow}))
|
||||
if v := c.Decide(context.Background(), Query{Stage: StageSignup, Privileged: true}); v.Action != ActionAllow {
|
||||
t.Fatalf("a scored allow must be honoured, got %q", v.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// The budget is the caller's. A scorer that never answers must not hold a
|
||||
// sign-up open.
|
||||
func TestDecide_ReturnsInsideTheBudget(t *testing.T) {
|
||||
c := scorer(t, slow)
|
||||
start := time.Now()
|
||||
c.Decide(context.Background(), Query{Stage: StageSignup})
|
||||
if elapsed := time.Since(start); elapsed > 10*Budget {
|
||||
t.Fatalf("Decide took %s; the budget is %s", elapsed, Budget)
|
||||
}
|
||||
}
|
||||
|
||||
// A nil client is a working, safe client: a caller never has to branch on
|
||||
// whether risk is wired.
|
||||
func TestDecide_NilClientIsSafe(t *testing.T) {
|
||||
var c *Client
|
||||
if c.Configured() {
|
||||
t.Fatal("a nil client is not configured")
|
||||
}
|
||||
if v := c.Decide(context.Background(), Query{Stage: StageSignup}); v.Action != ActionAllow {
|
||||
t.Fatalf("a nil client must allow an ordinary sign-up, got %q", v.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// A fact we do not have is ABSENT, not empty. An empty string is a VALUE, and a
|
||||
// scorer keying velocity on one would group every signup whose client address
|
||||
// never arrived into a single very busy caller — and refuse the lot.
|
||||
func TestFacts_AMissingFactIsAbsent(t *testing.T) {
|
||||
got := Facts(map[string]string{"ip": "", "username": "ada", "language": "", "mintsTenant": "false"})
|
||||
if _, ok := got["ip"]; ok {
|
||||
t.Error("an empty ip was sent as a fact; the scorer will treat it as an identity")
|
||||
}
|
||||
if _, ok := got["language"]; ok {
|
||||
t.Error("an empty Accept-Language was sent as a fact")
|
||||
}
|
||||
if got["username"] != "ada" || got["mintsTenant"] != "false" {
|
||||
t.Errorf("a real fact was dropped: %v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user