docs: state what the auth path actually checks, and register what it documents

GET /v1/aml/config was documented as live while Register never mentioned it, so
the only thing serving it was a test calling the handler directly. It is
registered (previous commit) and the table is true.

The rest of the section described an engine that took `owner` on faith and fell
back to Hanzo for an unrecognised Host. It now states the four checks a token
passes, why each one exists, the tenant key's shape and why the vault salt is
the sharp end of it, and the environment a deployment has to supply — including
that AML_CLIENT_ID has no default and the daemon refuses to start without one.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-29 22:31:46 -07:00
co-authored by Hanzo Dev
parent 11a44f6010
commit f7df6eb71e
+59 -19
View File
@@ -14,8 +14,9 @@ Real-time AML/CFT transaction monitoring engine. Pure Go, single binary, embedde
- **Scoring**: Weight-of-evidence (pure Go math)
- **Sanctions**: Jaro-Winkler + token-based fuzzy name matching
- **HTTP**: Hanzo Base router (net/http)
- **Auth**: IAM access token — the `owner` claim of a bearer JWT the request's own brand issued (`api.IAMIdentity`); or, behind an authenticating gateway, the header it writes from that same claim (`api.TrustedProxyHeader`)
- **White-label**: brand per request Host (`pkg/brand`, mirroring HIP-0111) — one `amld` serves lux.network, hanzo.ai, zoo.ngo, pars.network
- **Auth**: IAM access token verified in-process (`api.IAMIdentity`) — the brand of the request's own Host decides the issuer, `aud` must be this deployment's IAM clientId, `tokenType` must be `access-token`, and the `owner` claim names the tenant. `api.TrustedProxyHeader` remains for a deployment that can prove a gateway is its only route in
- **Tenant**: `<brand>/<org>` — the brand qualifies the org, in the store index and in the tokenisation vault's HKDF salt alike
- **White-label**: brand per request Host (`pkg/brand`, mirroring HIP-0111) — one `amld` serves lux.network, hanzo.ai, zoo.ngo, pars.network. A Host no brand claims is refused, never defaulted
## Build & Run
@@ -93,9 +94,14 @@ pkg/
api/routes.go -- /v1/aml/* HTTP routes + SanctionsStore on Base
api/records.go -- The one place retention, token and replay are joined
api/anomaly.go -- Model state for governance + candidate scoring
api/iam.go -- Identity from an IAM token: brand of the Host pins the
issuer, `owner` claim is the tenant, JWKS with a
bounded cache
api/tenant.go -- The Identity seam and the tenant key: <brand>/<org>,
minted in one place and checked at the boundary
api/iam.go -- Identity from an IAM token: Host pins the issuer, aud
pins the application, tokenType refuses an id_token,
`owner` must be an org the caller belongs to
api/jwks.go -- Published signing keys: RSA/EC/ML-DSA-65, per-issuer
single-flight cache, refresh + staleness bounds
api/mldsa.go -- ML-DSA-65 (FIPS 204) JWT verification
api/brand.go -- GET /v1/aml/config: the brand identity of this Host
brand/brand.go -- Brand id and request Host -> issuer + domains
(HIP-0111; the same registry as hanzoai/cloud brand)
@@ -180,17 +186,30 @@ scores, learns and reports what it *would* have alerted on, changing nothing.
| GET | /v1/aml/health | Health check, 503 when records cannot be kept |
| GET | /v1/aml/config | Brand identity of the request's Host: brand, display name, issuer, domain |
Every route resolves its tenant through `Handler.Identity`, and `/v1/aml/config`
is the one exception: it names the issuer a caller needs in order to obtain a
token, so requiring one would be a lock whose key is behind it.
Every route resolves its tenant through `Handler.Identity`. `/v1/aml/config` and
`/v1/aml/rules` are the exceptions: the first names the issuer a caller needs in
order to obtain a token, so requiring one would be a lock whose key is behind it,
and the second is the deployment's rule catalog rather than a tenant's data.
Two identities, one seam. `api.IAMIdentity(api.JWKS(ttl, stale))` takes the tenant
from the `owner` claim of a bearer token, verified against the JWKS of the issuer
belonging to the request's own Host — so a Lux token does not authenticate on a
Zoo host even where one in-cluster IAM publishes both brands' signing certificates.
`api.TrustedProxyHeader("X-Org-Id")` takes it from the header a gateway writes from
that same claim, which is sound only where the gateway authenticates the caller,
strips any client-supplied copy, and is the only route to this service.
Two identities, one seam, one tenant key.
`api.IAMIdentity(api.JWKS(ttl, stale), clientId)` is what `amld` runs: it verifies
the bearer itself, and four things must hold before a token names a tenant.
| Check | Why |
|---|---|
| signature under a key the brand of THIS Host publishes | a Lux token does not authenticate on a Zoo host, even where one in-cluster IAM publishes both brands' signing certificates. A Host no brand claims refuses |
| `aud` contains `AML_CLIENT_ID` | IAM stamps aud = the app's clientId, so without the pin a token minted for a marketing site — or any other tenant's app on the same issuer — is a credential here (RFC 9068 §4) |
| `tokenType == "access-token"` | an id_token is issued to a browser, carries the same `iss` and `aud`, and is not an API credential |
| `owner` ∈ `orgs`, when `orgs` is present | IAM stamps the APP's org as `owner`, so a shared or org-choice application would make all of its users one tenant. A machine token has no membership set: its org is its application's, and the application is pinned by `aud` |
`api.TrustedProxyHeader("X-Org-Id")` takes the ORG from the header a gateway writes
from that same claim — sound only where the gateway authenticates the caller, strips
any client-supplied copy, and is the only route to this service. It takes the BRAND
from the Host either way, so both identities produce the same key.
The AML application must be dedicated to one financial institution: not
`IsShared`, no `OrgChoiceMode`. IAM enforces same-org login for such an app
(`internal/oidc/login.go`), which is what makes `owner` the caller's own org.
## White-label
@@ -199,11 +218,22 @@ its OIDC issuer and the domains it serves on. It is a copy of the fleet's canoni
registry (HIP-0111, `hanzoai/cloud` `brand/brand.go`) kept as a leaf, so one
`amld` answers as Lux on lux.network, as Zoo on zoo.ngo and zoo.cloud, as Hanzo on
hanzo.ai, and as Pars on pars.network — brand, console identity and trusted issuer
all from the Host. An unrecognised Host falls back to Hanzo.
all from the Host. A Host no brand claims resolves to NO brand, and there is no
resolver that answers otherwise: the auth path is a caller that must not be handed
a default, or one brand's issuer authenticates every request arriving on a pod IP,
an in-cluster service name, localhost or a misrouted vhost.
Org names are unique within an issuer, not across issuers. One deployment serving
more than one brand from one record plane therefore needs its store scoped by
brand as well as by org; a deployment per brand needs nothing further.
Org names are unique within an issuer, not across issuers, so the tenant is the
KEY `<brand>/<org>` and never the bare org. It is minted in one place
(`pkg/api/tenant.go`, `qualify`) and is the same value that indexes alerts, cases
and retained records, scopes a history row, and salts the tokenisation vault. The
salt is the sharp end: keyed on the bare org, two brands' institutions of the same
name derive the SAME keys, so one brand's customer names tokenise to the other's
pseudonyms and its sealed records open under the other's tenant.
The isolation boundary is the deployment: each brand runs its own store, issuer and
ingress host. Brand-qualifying the key is what makes that boundary something other
than the only thing standing between two institutions.
## Records, tokenisation, and the sandbox
@@ -257,6 +287,16 @@ is no default. Without it an instance reports itself unfit on `/v1/aml/health` a
refuses to ingest, because a transaction that cannot be recorded must not be
processed.
## Environment
| Variable | Effect |
|---|---|
| `AML_CLIENT_ID` | This deployment's IAM application clientId, pinned as the token audience. **No default: `amld` refuses to start without it**, because an instance that cannot check which application a token was minted for cannot identify a caller |
| `AML_TOKEN_KEY` | KMS-held tokenisation root, hex, ≥32 bytes. No default |
| `AML_DEFAULT_ORG` | Label carried by the rule catalog. The catalog is the deployment's, not a tenant's |
| `AML_BUSINESS_ZONE` | Zone business-day and business-hour rules are answered in. Default UTC |
| `AML_ANOMALY` | `live` leaves shadow mode. Anything else scores without contributing |
## Embedded Admin UI
Served at `/_/aml/` via `go:embed`. Hash router (`/#/cases`, `/#/rules`, etc.) for SPA routing without server rewrites.