The endpoint was self-scoped and fail-closed, but the record it guarded is a
property on the user row, and other writers reach that row.
ONE WRITER. users.Update is a full-row write any org admin may perform on any
member, and its server-owned carry-forward list did not include the consent
record — so one request both FORGED an answer (by sending one) and DESTROYED a
real one (by sending a body with no properties, which is what a partial client
sends), silently and unaudited. It now carries the stored record, and only that
record: every other property still comes from the body, so the console's admin
properties editor keeps working. users.Create dropped nothing, so provisioning
an account could pre-grant training permission in the new member's name; a
create body's consent is now discarded the way a body's credential already was.
The one caller entitled to state an answer at create time is the signup screen,
where the person answers for themselves, and it says so through a seam that is
off the wire. update-preferences shallow-merged any key including this one, an
unvalidated and unaudited second writer of the record that most needs a single
one; it now refuses the key and says where to answer instead.
AN ANSWER YOU DID NOT SEND IS NOT AN ANSWER YOU CHANGED. The wire shape took a
plain bool and a plain string, so a screen saving one switch silently revoked
the other — {"training":"granted"} also said insights=false. Both fields are
pointers now and the record merges field-wise under the row lock, so absent
means untouched. The published description said it merged; now it does.
EVIDENCE. The audit row is what makes a grant demonstrable, and it was
best-effort: written after the fact, dropped on error, and only for a change to
the training answer. It now covers the whole record, commits on the SAME
transaction as the answer, and fails the request if it cannot be written — a
consent we cannot evidence is worth less than one we never claimed. Its action
is reserved, so the generic audit CRUD can no longer mint a grant nobody gave or
delete the row recording a refusal. The ingress address is dropped: behind
hanzoai/ingress it identified our own pod while still being personal data we
would owe a retention answer for.
The write half also refused nothing, so a value the read half normalizes away
could be stored for a later reader to guess at. Encoding an answer now validates
it, in the one place a Consent becomes bytes.
scripts/mutate.py lands the strict runner beside the table it scores: a mutant
counts as killed only if the anchor is unique, the tree builds, the named test
matches and then fails. The table grows 11 -> 20 rows covering the new guards.
20/20 killed. make test green, race-clean.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Hanzo IAM
Identity & access for the Hanzo cloud — OpenID Connect / OAuth2 with PKCE, standards only.
Hanzo IAM is the identity service behind every Hanzo sign-in: OpenID Connect
discovery, the authorize + token endpoints (authorization code + PKCE, refresh,
client_credentials, RFC 8693 token exchange), UserInfo, JWKS, SCIM 2.0
provisioning, MFA / WebAuthn, service accounts, and social federation
(Google, GitHub).
It is a clean-room, native rewrite on the Hanzo stack — zip over
hanzoai/orm, no the legacy surface, no Beego, no xorm. The identity binary owns its
source outright and collapses to one way of doing each thing. The retired
the legacy surface/Beego fork lives at
hanzoai/iam-v1 and is out of every graph.
Clients never hand-roll OAuth. They authenticate through the @hanzo/iam
SDK against the endpoints below — one way, no legacy paths (HIP-0111).
Stack
| Concern | Component | Notes |
|---|---|---|
| HTTP | zap-proto/zip |
Typed zip.Get[In,Out] handlers on the zap-proto/fiber/v3 engine; specificity routing; OpenAPI 3.1 at the edge |
| Storage | hanzoai/orm |
Typed Go records + KV cache over one orm.DB abstraction. Embedded SQLite by default (hanzoai/sqlite, pure-Go, WAL) — never Postgres |
| OIDC / OAuth2 | in-tree | RS256 today; ML-DSA-65 hybrid JWT + real JWKS from the Cert entity. No external OIDC library |
| Password verify | internal/cred |
Algorithm resolved from the stored row — argon2id + bcrypt, verify-only, fail-closed |
| Authorization | hanzoai/authz |
One canonical policy engine, called over ZAP RPC |
| Inter-service | zap-proto |
Binary RPC service↔service. HTTPS is the external edge only |
Endpoints — RFC / OIDC standard (no /api/, no vendor verbs)
The HTTP contract is RFC/OpenID-standard only. There are no legacy verb aliases
(get-users, add-user, issue-user-token, …) and no /api/ prefix — /v1/
throughout. Paths are relative to the brand serverUrl.
| Capability | Standard | Endpoint |
|---|---|---|
| Discovery / AS metadata | RFC 8414 · OIDC Discovery | /.well-known/openid-configuration |
| JWKS | RFC 7517 | /v1/iam/.well-known/jwks |
| Authorize | RFC 6749 (code + PKCE S256) |
/v1/iam/oauth/authorize |
| Token | RFC 6749 (code, refresh, client_credentials, password) |
/v1/iam/oauth/token |
| Token exchange / on-behalf-of | RFC 8693 | /v1/iam/oauth/token (grant_type=…token-exchange) |
| Introspection / revocation | RFC 7662 / RFC 7009 | /v1/iam/oauth/{introspect,revoke} |
| UserInfo (account claims) | OIDC UserInfo | /v1/iam/oauth/userinfo |
| Logout | OIDC RP-initiated logout | /v1/iam/oauth/logout |
| Identity provisioning | SCIM 2.0 (RFC 7644 / 7643) | /v1/iam/scim/v2/Users |
| Social sign-in / federation | OIDC/OAuth2 Relying Party | /v1/iam/oauth/authorize?provider=<name> → /v1/iam/oauth/callback |
PKCE S256 always; client_secret_basic; scopes openid profile email.
client_id is <org>-<app> (globally unique); redirectUris must be the
framework's exact callback.
Brands (set serverUrl): hanzo → iam.hanzo.ai · lux → lux.id ·
zoo → zoo.id · bootnode → id.bootno.de · pars → pars.id. Shared infra
white-labels by domain — never the Hanzo mark on a Lux or Zoo surface.
Storage — one orm.DB, backend-pluggable
Every handler and the drift tool are written once against orm.DB, never a
driver. Pick the backend at boot with --store:
sqlite(default) — embedded, pure-Go, WAL. No Postgres.sql—hanzoai/sqlover ZAP.datastore—hanzoai/datastoreover ZAP (ZAP-native persistence + snapshots, zero code change).
Build & run
go build ./...
# Seed real config + serve OIDC / login (SQLite by default)
go run . serve --init-data init_data.json
# ZAP-native persistence instead of embedded SQLite
go run . serve --store datastore --init-data init_data.json
# Read-only v1 → v2 drift report (needs a `-tags migration` build)
go run . compare --legacy postgres://…/iam
go run . version
serve flags: --store (sqlite|sql|datastore), --db (SQLite path),
--zap (ZAP listen), --http (HTTP edge), --init-data (new-only seed;
${VAR} expands from env). Deploy env: IAM_ISSUER=https://<brand-id>,
IAM_KEY_MINT_ALLOWED_APPS, IAM_ADMIN_MINT_ALLOWED_APPS (matched by the
globally-unique client_id).
The service is embeddable via server.Route and builds on Hanzo CI
(ghcr.io/hanzoai/iam).
Client auth (HIP-0111)
Authenticate only through @hanzo/iam against the canonical OIDC endpoints —
no hand-rolled OAuth, no genericOAuth({discoveryUrl}), no per-app path strings,
no legacy paths. SDK subpaths cover every runtime: @hanzo/iam/server
(validateToken / getServerSession), @hanzo/iam/betterauth,
@hanzo/iam/nextauth, @hanzo/iam/react + @hanzo/iam/browser (SPA PKCE),
@hanzo/iam/passport. Keep originFrontend empty in prod so discovery is
host-relative.
Status
OIDC/OAuth2 core is live and tested end to end (login → PKCE code → token → JWT):
discovery + JWKS, credential login (argon2id / bcrypt), the token endpoint,
introspection / revocation, UserInfo, RFC 8693 token exchange, SCIM 2.0
provisioning, and Google / GitHub federation. Current release line: v1.33.x.
See MIGRATION.md for the phased plan and the full RFC surface
table.
License
Dual-licensed under MIT or Apache-2.0 at your option, as the OSS core tier of HIP-0130.
Hanzo — the Open AI Cloud
Open source · every language · on-chain settlement. hanzo.ai · docs.hanzo.ai
SDKs in every language — Python (flagship) · TypeScript · Go · Rust · C++ · Swift · Kotlin · umbrella