Compare commits

...
4 Commits
Author SHA1 Message Date
hanzo-dev cc1500599f signup: a fact we do not have is absent, not empty
An empty string is a VALUE. A scorer keying velocity on "ip" groups every
signup whose client address never arrived into one very busy caller and refuses
the lot — and behind a balancer that terminates the connection without passing
the peer on, that is every signup there is. The same goes for the headers that
simply were not sent: Accept-Language, X-Forwarded-For, a country code with no
phone.

Facts drops them at the seam every question already passes through, so no gate
has to remember the rule. The cloud edge states the same rule at its own seam
(cloud.Facts); one rule with one meaning, in the two processes that ask this
one scorer.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:33:48 -07:00
blueandhanzo-dev a94fe6d551 signup: the tenant and the address are the server's answers, not the body's
Sign-up is the one endpoint where nobody has authenticated, so every field in
the request is a claim. Two of them were being used as facts.

THE TENANT. recordSignupDecision took row.Owner and row.Organization from
f.Organization, so an unauthenticated caller chose which tenant's append-only
audit trail received a durable write — and which tenant the scorer was asked
about, which is the keyspace its per-org features live in. 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 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 where a claim belongs — a
requestedOrg signal to the scorer, and a field in the record's detail — so the
row still answers "who did they say they were" without that answer choosing the
row's owner. The record's natural key is derived from the same value, so the id
cannot be aimed either.

THE ADDRESS. clientIP took the LEFT-MOST X-Forwarded-For entry, which the
client writes, and it feeds a per-address velocity counter and a durable audit
column: one host could present a fresh address per attempt to evade its own
velocity, or name a victim's address repeatedly to poison theirs. httpx.ClientIP
applies the same rule cloud does — the socket peer for a direct caller, else the
right-most chain entry that is not one of our own proxies (IAM_TRUSTED_PROXIES,
private space by default), all header lines read.

Both carry a regression test, and both were mutation-proven: restore the old
read and exactly those tests go red.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 17:03:22 -07:00
blueandhanzo-dev aced54594f signup: presenting a verification code spends it
The challenge asks for a code to establish that ONE person controls ONE address.
CheckVerificationCode did not consume the record — its doc said the gating flow
would — and the sign-up gate is its first and only caller, so as written one
proven address could answer a challenge again and again for the rest of the
ten-minute window and open account after account. The control would have proved
nothing, which is worse than not having it, because it would have looked like it
did.

Renamed to SpendVerificationCode, because a function called Check that mutates is
a lie, and made single-use: the record is burned BEFORE the answer returns —
mirroring the authorization-code redemption beside it, so a replay loses the race
— and a burn that cannot be written fails CLOSED, since an unspendable code is
not a spent one.

Spent at verify time rather than after the account is written, deliberately: by
then every rule the sign-up could break has already passed, and verifying without
spending leaves a window in which two concurrent sign-ups answer one challenge.

Also: a store failure on the challenge path no longer returns its own message,
which would have been an oracle for whether an address has a code outstanding.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 17:03:21 -07:00
blueandhanzo-dev 0217e4f60e signup: judge the registration before anything is written, and never score it here
POST /v1/iam/signup now asks the platform scoring plane — POST /v1/risk/decide,
stage signup — and applies its answer. IAM does not score and must not: the
features that separate a real registration from the tenth account on one card
live in the risk plane over a per-org surface IAM cannot see, and a second
scorer would be a second answer to one question.

The ORDER is the change that matters. Every deterministic check 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 had passed the username, email or password rules at
all. An empty tenant from a failed password is no longer reachable.

A challenge is answerable rather than a dead end: the client is told
RequiredVerify, beside the RequiredMfa and NextMfa it already knows, and
re-posts with a code checked by the one existing CheckVerificationCode. A block
is one opaque sentence carrying a decision reference and nothing else — a
refusal that explains itself is one an attacker tunes against, and an oracle for
what is already known.

The fail policy is one function. An ordinary sign-up allows when the scorer is
unreachable, because a risk plane that is down must not be able to stop people
signing up. A sign-up that would MINT A TENANT is a grant of standing authority
and refuses — but only on a deployment that HAS a scorer. RISK_URL unset means
one was never wired here, and refusing to onboard because a component does not
exist is an outage rather than a defense; that case allows and is recorded as
scorer-absent, so the darkness is a fact in the trail and not an inference.

Durable first: the decision is an append-only AuditLog row written before the
client is answered, carrying the decision, action, score, cause, refusal and
whether it was scored — never the request body, which holds a password. The
analytics copy to /v1/event follows, best-effort, on its own context.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 17:03:19 -07:00
11 changed files with 1716 additions and 26 deletions
+64
View File
@@ -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
+158
View File
@@ -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
}
+99
View File
@@ -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)
}
}
+6 -1
View File
@@ -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:
+27 -8
View File
@@ -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
}
+26 -7
View File
@@ -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
View File
@@ -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
+353
View File
@@ -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}
+454
View File
@@ -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")
}
}
+268
View File
@@ -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
}
+217
View File
@@ -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)
}
}