Compare commits

...
452 Commits
Author SHA1 Message Date
hanzo-dev 744d5e488d Merge: a machine credential names its payer
Hanzo CI/CD / cicd (push) Successful in 7m18s
CI/CD / cicd (push) Successful in 7m22s
image / build (push) Successful in 1m30s
image / test (push) Successful in 12m5s
Closes the 402 that killed every AI feature in Hanzo Insights: a
client_credentials token carried no billing_account claim, so account.Payer
fell to its shape rule and billed hanzo/hanzo-insights — a wallet no funding
path can name — while the hanzo org pool held $149,893.88.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 04:45:30 -07:00
hanzo-devandblue 7f2a91528f oidc: a machine credential names its payer
A client_credentials token carried no `billing_account` claim, so
account.Payer fell through to its shape rule — and that rule makes the
signup org special: anyone in it gets a PERSONAL wallet, because every
self-signup lands there and pooling them would let a $0 stranger spend the
platform's balance.

A machine has no person. The personal wallet it was handed, "hanzo/<app>",
is a ghost no funding path can name: an admin grant credits the pool, a
deposit names a real member. It reads $0 forever. Every first-party Hanzo
service authenticates this way and lives in the signup org, so all of them
were gated on an unfundable wallet while the org's balance sat one key
away — hanzo/hanzo-insights read $0 against a hanzo pool holding
$149,893.88, and every AI feature in Insights 402'd.

State the answer instead of inferring it. The app IS the org acting, so it
spends the org pool — which is already what the shape rule concludes for a
machine in every org but the signup one, so no existing tenant's money
moves. Payer only ever INFERRED machine-ness, from a User.Type a user can
set on themselves, and nothing populated it on the token path at all; a
signed claim cannot be forged or dropped.

The authority was already checked at registration: pointing an app at an
org requires SuperAdmin or that org's own admin (authz.CanSetOrg), the same
bar billingAccountFor applies to a person before naming the pool. A plain
member of the signup org cannot register an app there, so this does not
reopen the free-rider hole the personal-wallet rule exists to close. Only
the app's own organization is ever named, so no machine token can address
another tenant's ledger.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 04:39:19 -07:00
hanzo-devandzeekay e1791e16e9 ci: canonical pair — hanzo.yml + forge caller
github.com has no runner for hanzo-build-linux-amd64, so a caller under
.github/workflows is a gate that can never be scheduled. This is the ~7-line
caller on the plane that can (git.hanzo.ai git-runner fleet), pinned @v1.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:27:24 -07:00
zooqueenandhanzo-dev 93a0e3ac20 oidc: a sign-in runs at its issuer — the front door relocates off alias hosts
image / test (push) Successful in 5m37s
image / build (push) Successful in 1m16s
The hanzo_fed browser binding and the session are host-only cookies, while
the IdP callback and iss are pinned per brand. An authorize served on an
alias host (iam.hanzo.ai, auth.hanzo.ai, any host the map folds) set the
cookie where nothing returns: measured live, a begin on iam.hanzo.ai
registered the Google callback at hanzo.id, and every social sign-in begun
there failed closed at the callback with "the federation session could not
be verified" — the exact hop federationOriginIsReachable names as missing.

issuerRelocation answers an alias with the SAME request at the pinned
issuer, 307, before anything is minted or set. Trusted config only (never
the request), and fail-closed: nothing pinned, a blank or unparsable
issuer, or a fold that is not idempotent all serve in place as before.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:51:13 -07:00
hanzo-dev ddabaf0edd routes: a raw handler publishes nothing, so thirteen stop being one
cloud's apps/iam ratchet went red at 98 untyped operations against a ceiling of
88. The ceiling is not arbitrary and the answer is not to raise it: a route that
is not a typed op has no schema, no prose, no MCP tool, no CLI command and no SDK
method, so every raw handler here is a piece of this service its own customers
cannot discover.

The 88 dates from v1.33.37, which served 182 operations with 94 of them typed.
Since then ten canonical noun addresses arrived — account, auth/application,
preferences, verification-codes, tokens/issue, keys/mint, keys/revoke,
mfa/disable, mfa/preferred and oauth/device/info — each registered as a raw
handler beside the verb-noun spelling it replaces. Nothing regressed between
v1.34.5 and v1.34.20; that window's route table is byte-identical, measured. The
drift is older and it accumulated one honest alias at a time.

Thirteen addresses are typed ops now: the two login-screen descriptors and the
older spelling of one (auth/application, auth/methods, get-app-login), the two
operator upserts (admin/applications/upsert, admin/users/upsert), the five SCIM
discovery documents, and three reads whose whole input was a query string
(service-accounts, memberships, get-memberships). 98 untyped becomes 85; 94 typed
becomes 111.

A typed op RETURNS its answer, and that is the one thing this envelope could not
do. Response is written through a Ctx, a function has no Ctx, and a handler that
returned a bare Response would answer every refusal 200 — the exact defect the
status split closed one release ago. So the envelope gains a value form: Answer
carries a Response and the status it rides on, Good and Bad build the two
variants, and Ok and Fail become those builders plus a write. One envelope, one
place per variant, whether it is returned or written. Answer is a distinct type
rather than a method on Response because zip refuses a status an op did not
declare, and compat's typed ops already return Response declaring none.

Nothing moved on the wire. Each converted address answers the same status with
the same bytes, success and refusal alike, and refusals are returned as VALUES —
a returned error renders zip's {status, error} shape, which this surface has
never sent. The new tests pin bytes, not shapes: bootstrap's structs are
alphabetical because the maps they replaced were sorted by encoding/json.

What stays raw stays raw for a reason.

  - The OAuth/OIDC protocol endpoints, the .well-known documents and the browser
    redirects. authorize, callback and logout answer with a Location; token
    authenticates a client over application/x-www-form-urlencoded, which a typed
    op cannot decode.
  - The front door that resolves a caller from a session cookie — account,
    whoami, consent, preferences, linked-accounts, signin, signup, onboard.
    callerOf needs the request, not a context.
  - web3/nonce and web3/verify. c.Host() is canonicalized — lowercased,
    userinfo stripped — and a header:"Host" field is not, and that host is inside
    the string the wallet SIGNS. verify additionally resolves an optional
    principal from the request and binds a form-encoded body.
  - keys/mint, keys/revoke and tokens/issue, which authenticate a confidential
    client by client_secret_post: a form body, for the same reason as token.
  - admin/provision, which re-keys the browser's session cookie on the way out.
  - The mfa surface and the service-account and membership WRITES. These are
    typable as HTTP; what stops them is that a typed op also passes the op-invoke
    authorizer, which decides on a decoded (owner, name) their bodies do not
    carry and whose policy has no clause for self-service. Routing them through
    it changes WHO may call them, and that is a decision about authorization, not
    a projection of what already exists.
  - The legacy verb aliases. Kept reachable, taught nowhere; typing them would
    mint SDK methods and CLI commands for spellings we are retiring.

The public group is now the concrete *zip.App the guarded group has always been,
for the reason that one already was: zipdoc resolves an op's path prefix
statically and cannot see through a zip.Router parameter, so an op registered on
one has its prose filed under the wrong path and dropped from the document and
the MCP tool. The prefix is empty either way; nothing about the mount changes.

Two findings the work turned up, neither introduced here.

  - Every SCIM response sets Content-Type: application/scim+json and none of it
    reaches the wire: fiber's Res.JSON takes an optional content type and, given
    none, overwrites the header with application/json. Those SetHeader calls have
    always been dead, so RFC 7644 §3.1 has never been met on this surface. The
    still-raw /Users route is the control that proves it is the surface's
    deviation and not the conversion's, and it is pinned as such — fixing one
    half alone would split the surface, so change both together or neither.
  - A body that is not syntactically valid JSON never reaches a bootstrap op:
    encoding/json validates before it calls any Unmarshaler, so zip answers the
    same 400 with the same sentence in its own envelope. Every other refusal,
    including type mismatches inside valid JSON, keeps this surface's. There is
    no hook to reach it; TestWireDecode documents the seam.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 15:04:20 -07:00
blueandhanzo-dev 92b3bf64bb consent: the answer belongs to the person it is about
image / test (push) Successful in 5m36s
image / build (push) Successful in 1m3s
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>
2026-08-04 23:45:43 -07:00
blueandhanzo-dev 368a06c30f signup: record the training answer with the account
image / test (push) Successful in 6m0s
image / build (push) Successful in 1m13s
The consent record had a home and no path that asked. Signup now carries the
answer the screen collected and writes it into the account's preferences blob at
creation, so a new user starts with an explicit answer rather than silence.

Absent stays unanswered — a client that does not ask cannot accidentally grant —
and a non-empty value that is not a known answer fails the signup instead of being
coerced, so no account is ever persisted next to an answer this version cannot
interpret.

Three more mutants, all KILLED: 11/11 across the consent surface.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 22:49:50 -07:00
blueandhanzo-dev 9bac65ad12 consent: silence is refusal — a tri-state training answer with one predicate
The training answer was a bool, so "never asked" and "asked and declined" shared
a value. Nothing could tell whether an answer had ever been given, which means no
screen could know to ask and no data path could treat silence as refusal.

Training becomes a tri-state Answer (unanswered/granted/refused) with the zero
value being unanswered, so a missing record, an unparseable blob, a wrong JSON
type and an unrecognized token all decode to silence. MayTrain admits exactly one
value — an explicit "granted" — and is the ONE predicate; pkg/model aliases the
type and re-exports the states so a consumer outside this module shares the same
definition rather than re-deriving what granted means.

The write path validates at the boundary, so an answer this version does not know
is refused rather than persisted for a later reader to interpret, and a change to
the answer writes an AuditLog row carrying the prior and new value — a grant and a
later revocation are both attributable, which overwriting a JSON field is not.

The preferences property is now defined once in schema, where the consent record
nested in the same blob reads it, so the two cannot drift apart.

scripts/mutants.py drives cloud's strict-scored mutation engine against these
properties: 8 rows, 8 KILLED.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 22:49:50 -07:00
zandGitHub bcd6468cf2 legal: merge legal/dual-mit-apache into main (HIP-0137) 2026-08-04 14:16:54 -07:00
hanzo-dev c92834f693 httpx: a refused request answers a status that agrees with it
image / build (push) Successful in 3m39s
image / test (push) Successful in 6m14s
The error envelope rode on HTTP 200. Every SDK that checks the transport before
the body — res.ok in fetch, raise_for_status() in requests, StatusCode/100 == 2
in Go — therefore read a REFUSED signup as a completed one, and the caller went
on to the next step of an onboarding that had not happened.

The envelope is not the thing that was wrong and it does not change: status, msg
and code are the contract the SDK and the portal branch on, and they stay byte
for byte. What changes is the number in front of it, which is the one part that
was never true. Fail is now the single writer and carries the status; Err and
ErrCode name 400 for it, the honest default on a front door whose refusals are
validation and credential failures.

Six existing tests asserted "want 200 error" — they encoded the defect, so they
now assert the corrected contract. Two more inferred "this route is public" from
a 200; that inference was always weak, and they now prove reachability the way
it is actually visible: the handler's own envelope came back rather than the
Guard's shape, which is what "past the Guard" means.

Several of these refusals are authentication failures where 401 is the honest
status. They are deliberately NOT spelled that way yet: these handlers sit on
the pre-Guard public group and the Guard's own refusal is a 401, so a handler
answering 401 becomes indistinguishable from a route that was never public —
which is exactly what internal/authz's public-route tests assert on. Telling
those apart needs a change to the authz surface, not to this envelope; until
then the machine-readable code carries the distinction, which is what it is for.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:38:14 -07:00
zeekayandhanzo-dev 236ec391b0 zip v1.24.2 — a declared operation id is not compositions to edit
occurrenceID qualified every operation id with its occurrences prefix, including
ids the author had written with WithOperationID. Being included under a host
prefix therefore renamed published ops as a silent side effect of one wiring line
— every cached MCP tool name, operationId, CLI command and generated SDK method.

Measured upstream on o11y: 217 of its 353 ops carry a declared id and all 217 were
being renamed by a host prefix.

Full suite green on this host.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 09:49:24 -07:00
zeekayandhanzo-dev 602c063493 zip v1.24.1: tests stop reaching through fiber
image / test (push) Successful in 4m42s
image / build (push) Successful in 1m7s
App.Test calls prepare, which installs the deferred projections — /mcp, the
OpenAPI document, the op-call plane, the plugin route. Reaching through Fiber()
skips that, so a test written against the escape hatch cannot see a surface
production exposes.

3 call sites, and the fiber import goes with them: nothing in this repo names the
underlying router now.

Full suite green before and after on this host.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 06:49:06 -07:00
zooqueenandhanzo-dev cbb4566f47 oidc: delivery is a bound sender, not an address
image / test (push) Successful in 5m5s
image / build (push) Successful in 1m9s
DeliveryConfigured keyed on IAM_NOTIFY_ADDR, and nothing else in this repo
read that variable. Setting it would have restored the code button and
silenced the endpoint's refusal while still sending precisely nothing —
re-arming the {status:"ok"} lie the gate was written to remove.

An address is a claim that delivery exists; a sender IS delivery. The seam
is now an interface bound at boot, and the endpoint reports what the send
actually did instead of assuming it worked.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 04:22:37 -07:00
zooqueenandhanzo-dev ca31722252 oidc: do not offer a code nobody can send
image / test (push) Successful in 5m18s
image / build (push) Successful in 1m8s
Every application advertised `code: true` for email/SMS sign-in while the delivery
seam was unbound, so a person could ask for a code, be told it was on its way, and
wait for a message that was never going to arrive. Measured against production: a
send to probe@example.invalid — an address that cannot exist — answered
{status:"ok"}.

Two independent facts were conflated into one. The application switch says the ORG
wants email/SMS codes. Whether the SERVER can send one is a different question, and
nothing asked it. DeliveryConfigured is now that question, in one place, read by
the send endpoint AND by both halves of the login descriptor — authMethods and
loginView, because the descriptor IS the screen's source of truth and a switch left
on there draws the button whatever authMethods says. The org's stored setting is
never modified; only what the browser is told.

The endpoint also stops reporting success it cannot deliver. Returning ok was
defensible as "the code exists" — it is generated and persisted, and that record
still is the source of truth for verification — but the caller asked us to SEND
one, so ok means sent. It now says plainly that no notify service is configured.

Keyed on IAM_NOTIFY_ADDR rather than a constant, so binding notify turns this on by
configuration with no code change and no second switch to remember. Unset today,
which is the honest answer.

Same rule as `offerable` for social buttons and WalletChains for wallet sign-in:
offer only what can complete. Codes were the last method still advertised on faith.

Two existing tests asserted the ok. They test persistence and verification, not
delivery, so they now configure an address the way a real deployment does; a third
pins the refusal. Verified the descriptor gate FAILS when removed, not merely that
it passes — after nearly losing this change to a `git checkout` of an uncommitted
file, which is why the negative proof used a file copy.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 02:21:52 -07:00
zeekayandhanzo-dev af49be487f An application is never registered unable to sign
issueTokens resolves app.Cert to sign, so an application created without one
authenticates the user, mints an authorization code, redeems it — and only then
discovers it has nothing to sign with, answering the token exchange
`500 server_error`. From a browser that is indistinguishable from an outage, and
the cause appears in no log, because this service has none.

`hanzo-tabs` shipped in exactly that state. Every sign-in reached hanzo.id,
authenticated correctly, returned to /auth/callback and died there.

Two changes, at the two places that can each end it:

resolveCert settles the signing cert an upsert CREATES with, the same shape and
for the same reason as resolveSecret: one place, testable without a store. An
explicit cert wins; otherwise it is the organization's own, which is the
convention every application here already follows (cert-hanzo, cert-lux,
cert-adnexus). Only the create path consults it — on an existing application a
blank request still means "not stated", never "clear it", which is what lets a
provision document add the field without rotating anything.

The cert ROW is deliberately not required to exist yet. An application that
records `cert-hanzo` signs correctly the moment that cert does, whereas
demanding it up front would order application creation behind cert seeding and
break a first-boot reconcile that has not reached the certs. The name is the
durable fact; resolving it is the token endpoint's job.

And for an application already in that state, the token endpoint now says so.
ErrNoSigningCert is the one internal failure it names out loud: it describes the
caller's own registration, reveals nothing about any credential, code or user,
and is otherwise undiagnosable from outside. Everything else keeps the bare
`server_error`, because describing it would build an oracle.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 01:48:29 -07:00
zooqueenandhanzo-dev e889e938d1 oidc: refuse a federation origin the browser cannot complete
image / test (push) Successful in 5m53s
image / build (push) Successful in 3m48s
IAM_FEDERATION_ORIGIN / _MAP exist to fold every host of an org onto ONE callback,
so a provider console holds one redirect_uri per org instead of one per brand host.
Setting them would have broken social sign-in on every folded host.

beginFederation sets the `hanzo_fed` anti-forgery cookie on whatever host served
it, with NO Domain attribute — host-only on purpose, because it is the login-CSRF
defence. The callback then requires it, with no exemption: an empty cookie is
refused as "the federation session could not be verified". Point iam.hanzo.ai's
callback at hanzo.id and the cookie is written on iam.hanzo.ai and never presented
to hanzo.id, so the check fails closed. Not at deploy — at the first human's first
login, with an error naming the symptom instead of the config.

The separation itself is right and is untouched: the issuer must be per-brand
because an RP pins `iss`, while the callback wants to be per-org because a provider
holds a fixed list. It is only the fold that cannot land yet, and completing it
means the begin leg redirecting to the federation origin so the cookie is written
THERE before the IdP hop. Until that exists, booting is refused with the host, both
origins, and the reason, so an operator can act on it.

Two tests asserted the unreachable fold and made it look supported. The feature's
own test used iam.hanzo.ai -> hanzo.id, which is exactly the broken case; it now
pins the refusal and its wording, and still asserts the issuer half, which holds. I
wrote the second one myself two commits ago while recommending this knob as the
better path — corrected to pin what a same-host map really does, which is nothing,
each brand keeping its own callback. A no-op map still boots, so the guard rejects
unreachable folds rather than the feature.

No effect on the running fleet: both variables are unset on the deployment, so the
guard does not execute. It arms the moment someone tries to use them.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 01:22:30 -07:00
hanzo-dev 92b2a2ba74 legal: dual MIT OR Apache-2.0, canonical licence text
This repo is public and its LICENSE called the source "confidential and
proprietary ... All rights reserved". Public visibility contradicts
confidentiality, and HIP-0130 puts `iam` in the OSS core tier. The declaration
now matches both.

LICENSE-APACHE and LICENSE-MIT carry the canonical texts, unedited —
LICENSE-APACHE is byte-identical to apache.org/licenses/LICENSE-2.0.txt
(sha256 cfc7749b…, 11358 bytes, blob d645695). LICENSE declares the pair.
LICENSE-MIT carries the copyright line the notice requires.

249 Go files led with `// Copyright 2026 Hanzo AI, Inc. All rights reserved.`
— the classic proprietary reservation, and a per-file contradiction of the new
grant. Each now carries `// SPDX-License-Identifier: MIT OR Apache-2.0`. The
word "confidential" survives untouched in the OIDC sense (confidential client),
which is protocol vocabulary, not a licence claim.

There is nothing else to declare: `go.mod` has no licence field and this repo
ships no Cargo/npm/PyPI manifest.

Relicensing is ours alone to do. The tree is original work, not a fork:
`fork: false` with no parent, its own root commit, and no Casdoor-lineage tag
is an ancestor of `main`. The retired Casdoor fork is `hanzoai/iam-v1`; the
provenance note in LICENSE names it, restoring the vendor name a clean-room
assertion needs in order to say what it is clean of.

Build, vet and all 29 test packages green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 01:09:51 -07:00
zeekayandhanzo-dev 4f1ed7734d provision: declare the org superuser as data, not a hand-made account
There is no built-in admin — the seeded superuser IS the admin — but nothing
in the provision document could express one, so every org's owner existed only
because someone created it by hand in a console. That is the one piece of IAM
state with no deterministic source.

Org gains an optional Owner (email, displayName, passwordRef). It sits beside
the org, not inside apps, because an owner belongs to the ORG and is not an
OAuth client; Owners() derives it separately from Derive() so an owner can
never be registered as a client that could then authenticate AS the superuser.

The password is never in the document. passwordRef is a kms:// locator and a
literal is REJECTED — this file is git-tracked by design, so a password
written here is leaked the moment it is committed. Validation runs at parse
time, not apply time, so a malformed owner fails the plan a reviewer reads
rather than halfway through mutating a live tenant.

Owner is a pointer and optional: the document is decoded with yaml.Strict(),
so an org that declares no owner parses unchanged while a typo'd key is still
a hard error.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 23:57:19 -07:00
hanzo-dev 13688b4429 chore(zip): v1.23.0 — Use is the one composition verb
A dependency bump with no source change, which is the interesting part.

zip v1.23 unexported Prepare() and replaced it with Build() error, widened
Router.Use to take a Component (Handler | *App) so composition and middleware
are one verb, and dropped Router.Fiber() and App.Add(). This repo's main already
composes that way: NewApp calls app.Build() and panics on the verdict, Route
takes the concrete *zip.App, and nothing here implements zip.Router or reaches
for Fiber() on one — c.Fiber() is on *zip.Ctx and is untouched.

The published v1.34.5 is what fails to build against v1.23 (server.go:65 called
app.Prepare()); the fix landed on main afterwards and was never tagged. This
bump is therefore the whole migration, and hanzoai/cloud is blocked on the TAG,
not on the code.

Measured: go build ./... clean; go vet ./... clean, which compiles the test
binaries too; go test ./... 29 ok, 0 failed.
2026-08-03 21:23:35 -07:00
hanzo-dev ca67fcf6c9 fix(seed): converge enableWebAuthn — 37 apps declared passkeys, 0 offered them
init_data.json declares enableWebAuthn TRUE on 37 of its 83 applications —
hanzo-app, hanzo-chat, hanzo-cloud, hanzo-console, hanzo-id, hanzo-world among
them — and /v1/iam/auth/methods answered "webauthn": false for every single one.
Measured across all 11 front doors: 11/11 false.

This is the exact defect reconcileApp was written to fix, one field over.
upsert is new-only, so a flag flipped in init_data.json never reaches an
already-seeded row; reconcileApp exists to converge declared POLICY on boot, and
enableWebAuthn was simply absent from appPolicyKeys. So two thirds of the estate
was configured to offer passkeys, no login screen ever did, and nothing logged
the disagreement — the only way to see it was to diff the ConfigMap against the
live endpoint.

It belongs on that list by the list's own test: the declared value should always
win. Whether an app offers passkeys is identity policy, not registration drift.
It names no external party, no redirect and no secret, so unlike redirectUris
there is no legitimate live value it can clobber.

The test asserts convergence in BOTH directions — a flag that only turns on is a
trapdoor, not a declaration.

Note for whoever reads this next: enableSignUp is already on appPolicyKeys and
is converging correctly. It is declared true on exactly two applications
(hanzo-console, hanzo-app) and true on exactly those two in production. Signup
being off elsewhere is deliberate and declared, not a bug.
2026-08-03 19:54:35 -07:00
zooqueenandhanzo-dev 3a5a68c9b3 ci: ask the forge for its tags ONCE, not once per tag
image / test (push) Successful in 4m39s
image / build (push) Successful in 1m12s
a219187d anchored the carry set on the forge's highest tag and was still not
enough: the run after it went from a 274-byte log at 36s to a 592-byte log at 68s
and failed again. The cap was no longer what killed it — the loop was.

Both the original and my version called `git ls-remote` PER TAG. This repo has
~170 of them, so the step made ~170 network round trips to the forge on every
10-minute run. Reproduced locally: the loop had not finished after two minutes. It
is also 170 independent chances for one transient failure to end the job, because
`set -e` turns any of them into an exit.

One `ls-remote` now fetches the whole tag list and the comparison happens locally.
The rewritten step runs instantly under `set -euo pipefail`, and against the live
forge (59 release tags, highest v1.34.10) produces an empty carry set — so it
prints "no unpushed release tags" and exits 0, which is the steady state this job
should have been sitting in all along.

One more `set -e` trap closed while here: `cmd && continue` is a bare AND-list, so
when cmd fails the whole statement fails and `set -e` exits the step. The
membership test is an `if`, not an `&&`.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 19:36:00 -07:00
zooqueenandhanzo-dev a219187da1 ci: carry tags NEWER than the forge, not every tag it lacks
image / test (push) Successful in 5m1s
image / build (push) Successful in 1m3s
This job has failed on every run for days, and the failure was its own safety cap
firing correctly against a precondition that was never true.

The tag step collected "every v* tag not on the forge". The forge repo was created
without history's tags, so ~160 of them — the whole v1.0.0 … v1.31.x line — are
permanently missing and always in that set. The cap ("refusing to dispatch that
many builds at once", >5) therefore tripped on EVERY run and exited 1, so the step
never reached a real release. That is why v1.34.5 and v1.34.8 exist as tags with no
image: starved behind 160 ancient tags nobody wanted rebuilt, in a queue that could
never drain. A guard that cannot be satisfied is not a guard, it is an outage.

The set is now anchored on the forge's OWN highest release tag, so it converges:
empty in the steady state, and exactly the new tags after a release. The cap stays
— it is still the right answer to a genuine tag storm — but it is now reachable.

Backfilling the ~160 historical tags is deliberately NOT done: pushing them fires
image.yml once per tag on `on: push: tags`, which is precisely the storm the cap
exists to stop. They are history; nothing needs them rebuilt.

Verified the filter against the real ladder — v1.0.0, v1.14.9, v1.31.37, v1.34.5,
v1.34.9 and v1.34.10 all skip against a forge high of v1.34.10; v1.34.11 and
v1.35.0 carry.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 19:27:00 -07:00
zooqueenandhanzo-dev c842b92d12 frontdoor: advertise wallet sign-in from the code that serves it
image / build (push) Successful in 1m6s
image / test (push) Successful in 5m9s
Native multi-chain wallet login has been LIVE and invisible. /v1/iam/web3/nonce
issues a CAIP-122 challenge on all seven families the verifier knows — measured
against production: evm, solana, bitcoin, ton, xrp, polkadot and cardano each
returned a challenge, dogecoin was refused. Every login screen reported
web3:false throughout.

The flag was read off the application's linked PROVIDER of category "web3". The
only such row is the seeded Web3Onboard one, whose clientId is the unexpanded
literal `${IAM_WEB3_CLIENT_ID}` and which names a third-party library this build
does not import and never calls — web3-onboard appears nowhere in the Go source
but one historical comment. So the flag tracked a row that governs nothing while
the endpoints it was meant to describe answered normally.

Wallet sign-in is a capability of the BINARY: Route mounts it unconditionally,
with no per-app switch to consult (there is no EnableWeb3 beside EnablePassword
and EnableWebAuthn). So the descriptor now asks the code that serves it.

ONE LIST, both halves. schema.WalletChains is what the endpoints GATE on and what
the descriptor ADVERTISES from, so a screen cannot offer a chain the nonce
endpoint then refuses — the same disagreement `offerable` closed for social
buttons, in the one place it could still occur. It lives in the leaf schema
package because internal/wallet imports internal/authz which imports internal/oidc:
a direct import would be a cycle, and inverting it with a registration hook would
be a second mechanism for one fact.

Names, not SDK types, keep schema dependency-free — so TestWalletChainsMatchSDK
pins them against the luxwallet constants in the one package that imports both,
and a rename upstream fails the build instead of silently narrowing what can sign
in. Verified it fails on drift, not merely that it passes.

`web3Chains` is additive; `web3` stays the boolean every client already reads.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 19:21:35 -07:00
zooqueenandhanzo-dev 184f002e0b test: assert the callback through the origin that now decides it
image / build (push) Successful in 1m24s
image / test (push) Successful in 5m18s
The guard added two commits ago pinned resolveIssuer + PathFederationCallback.
51b473ce then unbraided the two: the callback is resolveFederationOrigin, and the
issuer is only its FALLBACK when nothing is pinned. So the assertion still passes
today and stops describing the code the moment IAM_FEDERATION_ORIGIN is set —
which is the entire point of that commit. A guard that goes quiet exactly when
the thing it guards starts moving is worse than no guard, because it is read as
coverage.

Repointed at resolveFederationOrigin, with a note saying why the two spellings
are not interchangeable even though both are green right now.

Second test for the property the first one CANNOT see: with an origin pinned,
every host of one org folds onto ONE callback, while a different org keeps its
own. That is what makes the registered list per-ORG rather than per-brand-host —
the difference between a provider console holding one URI and holding one per
brand we ever add. Verified it fails on the braid it guards (unfold iam.hanzo.ai
and it reports "one org handed the IdP TWO callbacks"), not merely that it passes.

The issuer assertion rides along in the same test because the split only pays if
BOTH halves hold: an RP that discovered via iam.hanzo.ai must still pin that
issuer while its callback folds. Testing the fold alone would let the issuer be
dragged with it and still read green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 18:21:55 -07:00
hanzo-dev 51b473ce7a fix(oidc): unbraid the IdP callback origin from the token issuer
Social sign-in failed on every brand, and this is why. federationBaseURL was
resolveIssuer(c.Host()), so the origin handed to Google/GitHub was PER-BRAND:
hanzo.id, lux.id, zoolabs.id and pars.id each sent their own
https://<host>/v1/iam/oauth/callback. A social provider holds ONE OAuth client
per org with a FIXED list of authorized redirect URIs, so all but one of those
are strings it has never seen and it answers redirect_uri_mismatch.

Measured against the live Google client before the split: of
{hanzo.id,iam.hanzo.ai}x{/callback,/v1/iam/oauth/callback} exactly ONE was
accepted, and it was not the one iam sends. A bogus control URI produced the
identical rejection, so the probe discriminates.

The two values were braided because they are equal today, but they pull in
opposite directions: the issuer MUST vary per brand (an RP that discovered via
lux.id pins  and rejects a hanzo.id token), while the IdP callback MUST be
one org-constant string. Braided, one of them is always wrong — and it was the
callback, on every host.

So: same resolver type, second instance, own config
(IAM_FEDERATION_ORIGIN / IAM_FEDERATION_ORIGIN_MAP). One mechanism, two
instances — no second notion of a pinned origin, and the federation leg keeps
the header-immunity the issuer leg has: a request host can SELECT a configured
org's origin, never inject one. Registering every brand host with every provider
is the other way out and it is the wrong one: it makes each provider carry a
list of our apps and grows with every brand.

UNSET IS A NO-OP — federation falls back to the issuer, i.e. exactly today's
behaviour — so this deploys safely before the config lands. A non-https or
malformed pin fails the boot LOUD, because this value is handed to an external
IdP.
2026-08-03 18:07:40 -07:00
zooqueenandhanzo-dev 3f86f1f5ea frontdoor: offer only the sign-in methods that can finish
image / build (push) Successful in 1m23s
image / test (push) Successful in 5m59s
The login screen drew FIVE buttons for hanzo-app and exactly TWO of them could
complete a sign-in. GitLab answered "provider is not a supported federation
type"; Apple and Web3Onboard answered "unknown or unavailable provider". Three
of five ways into the product were traps.

The guard meant to prevent this only asked half the question. isConfigured
checked for a real (non-placeholder) CREDENTIAL, which is why Apple and Web3
were already hidden — but GitLab carries a real-looking client id, so it passed,
and then the authorize leg refused it for the OTHER reason: no dialect can drive
a GitLab that declares no OIDC issuer. A method can fail to complete in two
independent ways and only one was being checked.

So the predicate now asks both, and it asks the second through idpKind — the ONE
authority the authorize leg already consults, rather than a second opinion that
could disagree with it. Renamed offerable, because "holds a credential" is not
what the callers want to know.

It also has to be asked in the right PLACE. get-app-login answered with every
provider while /v1/iam/auth/methods answered with the filtered ones: two
endpoints, two answers to one question, and the browser reads the unfiltered one
— the SDK calls it "the canonical source of truth for which methods exist". That
is why the dead buttons were visible even though a filter existed. maskApp is now
loginView and does both halves of the browser's view: no secrets, no method that
cannot finish.

This is a capability test, not a deny-list of type names, so it stays true on its
own: give GitLab an issuerUrl and it becomes a real OIDC provider and its button
returns with no code change. Pinned by a test either way.

Google is deliberately still offered. It IS driveable from here; it is refused at
GOOGLE, by a redirect_uri that was never registered there (see the guard in
federation_contract_test.go). Hiding it would describe our own config as broken
when the missing half is external.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 17:27:06 -07:00
zooqueenandhanzo-dev cdc2920fdf test: the federation callback is a contract, so pin it
The redirect_uri iam hands an external IdP is held in two places at once: the
composition here, and each provider's own console. An IdP refuses any value it
was not told about in advance, and nothing in this package can see the other
half — so half of the contract can rot while the suite stays green.

It did. When federation moved off Casdoor's `<iam host>/callback` to the
canonical `<brand issuer>` + PathFederationCallback, the GitHub App's callback
list was updated and Google's OAuth client was not. Measured against the live
client: the only registered URI is still `https://iam.hanzo.ai/callback`, so
Google refused sign-in on ALL FIVE brands — hanzo.id, lux.id, zoolabs.id,
pars.id, id.bootno.de — with `Error 400: redirect_uri_mismatch`, while GitHub
kept working and every test here passed. The only report was a person who
could not log in.

The test pins the RULE, not a snapshot: one URI per distinct issuer in
IAM_ISSUER_MAP, aliases collapsing to the same URI. Moving the path or a
brand's issuer now fails with the registration that has to move with it.
Verified it fails on exactly that change, not merely that it passes today.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 17:12:29 -07:00
zooqueenandhanzo-dev 2e1a6788bf ci: carry release tags to the forge, and dispatch the build on the tag
This job synced `main` and nothing else, then dispatched image.yml with
ref: main. image.yml publishes ONLY for refs/tags/v* — its meta step sets
push=true there and push=false everywhere else, naming the result `unpublished`.
So both halves were broken: a `v*` tag cut on GitHub never reached the forge, and
the dispatch that did happen could never publish anything.

That is the recorded cause of "v1.34.5 was tagged in git and never built", which
iam.yaml already carried as a note, and of v1.33.32 through v1.33.37 having no
images at all. It is also the fourth distinct way this estate has shipped nothing
today while looking healthy — the others being an image published before its own
fix landed, a build job skipped by a stale generated-doc gate, and a module
change in no tag. A release that builds nothing is indistinguishable from one
that shipped, which is exactly what makes it expensive.

Tags are now fetched and pushed, and the build is dispatched on the TAG ref,
because the workflow token deliberately does not trigger other workflows (loop
prevention) — pushing the tag alone would leave it unbuilt for the same reason
the branch push already did.

Bounded at five: a tag storm has starved this CI before, and a silent truncation
would read as "everything built". Over the cap it fails loudly and names them.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 16:16:02 -07:00
zooqueenandhanzo-dev fbae3ac1e2 docs(routes): membership decides, so stop saying order does
image / test (push) Successful in 5m17s
image / build (push) Successful in 4m39s
The comments still explained public-vs-gated as a position: registered
BEFORE the Guard, or AFTER it. That was the old flat model's rule, and it
described the seam that just moved. A route is now public because it is on
a group holding no Guard and gated because it is on the group holding one
— the ordering is incidental, and prose that teaches otherwise is how the
next person reaches for app.Use again.

Comment-only; v1.34.7 is unaffected.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 15:55:15 -07:00
zooqueenandhanzo-dev 8ca8567680 auth: the seam belongs to the routes it guards, not to the binary
image / test (push) Successful in 4m51s
image / build (push) Successful in 1m3s
IAM's Guard was app.Use, which zip places at depth 0 — router middleware,
a barrier in front of every request the binary will ever serve. Alone that
reads as "guard my routes". Embedded in the cloud binary it meant "guard
all 59 subsystems": a sibling's route was authenticated against IAM's own
store, which has never seen a token minted by the external hanzo.id, so
every valid request 401'd wearing the sibling's URL. The same barrier
answered addresses nobody declared, so a mistyped path came back
"authentication required" instead of 404.

Both move onto a group that HOLDS the routes it guards. A group's
middleware is composed into that group's own route chains and reaches
nothing else, so the scope is now a property of where a route is
registered — which is how this package already decided public vs gated.

app.Authorize had the identical defect one seam over, and scoping only the
Guard would have hidden it: zip reads the op-invoke hook off the app an op
REGISTERED on, so on a shared app IAM's rules became the host's and a
sibling's TYPED op was refused 403 — a different status code for the same
overreach, which is why the raw-handler test could not see it. The hook
moves onto the group with the ops it authorizes.

Scoping the Guard takes the framework's own projections out of its reach:
zip installs /mcp, the OpenAPI document and /docs directly on the served
app's router with no middleware, so no group can cover them. authz.Control
mounts the SAME Guard for exactly those three addresses. It is not
optional — the MCP door dispatches tools/call straight into this admin
CRUD, and the op-invoke hook alone does not close it, because it admits a
read whose decoded target is empty on the assumption the Guard already ran.

cors.Allow keeps its app.Use and is NOT affected: it reads browserPaths
and returns c.Next() on any path it does not own, so it is already a no-op
on a sibling's route — and it MUST stay at depth 0, because a preflight to
a path with no OPTIONS route matches nothing, and depth 0 is the only
placement zip runs for unmatched requests.

Nothing IAM gated before is ungated now: its own paths, /mcp, the OpenAPI
document and /docs all still 401 without a bearer, proven by the existing
suite plus new cases here. The one seam that genuinely changed meaning is
feature.RouteAll, which registers on the app and used to inherit the
whole-app barrier by accident of coming after it; the registry is empty in
this repo, and server.Route now says so.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 15:47:25 -07:00
zooqueenandhanzo-dev a4bfc9087e generate: the zipdoc gate was red on main, so nothing could build
`make test` checks generated-doc freshness as its FIRST step, so a stale
zipdoc_gen.go does not fail a test — it ends the run before any test executes,
and the build job that depends on it is skipped. The repo then looks quiet while
nothing ships. That already cost a full day once: iam had no image for its own
auth fix, and the stale diff was that fix's own doc text.

This is the same shape again. Three route descriptions were edited without
running go generate, so the file drifted and the gate closed behind them. Five
commits have been sitting unreleased since v1.34.6 — three of them session and
SSO fixes — unable to build for a reason that has nothing to do with them.

Regenerated, not hand-edited. Doc strings only; no behaviour.

The gate now passes and one real failure is visible behind it:
TestGuard_DoesNotGateASiblingSubsystemsRoutes. It fails identically without this
change (verified by stashing), and it is not flaky — it is catching
app.Use(authz.Guard) gating every sibling subsystem's routes with 401 when IAM is
embedded in the cloud binary. That is a release-blocker doing its job, and it is
left standing rather than papered over.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 15:18:46 -07:00
zooqueenandhanzo-dev 8d4d287080 zip v1.19.2: the seam belongs to the subtree that owns it
Prepare is gone; Build replaced it and RETURNS THE VERDICT, which is the whole
reason for the rename — a program that does not compose used to be discovered
only by starting a server. NewApp panics on it, as Route already does for a
feature module that cannot register.

Asking for the verdict is what surfaced the real breakage. zip v1.19 anchors
middleware LEXICALLY: a node's environment is the stack at its inclusion site
plus the entries preceding it at its own level. Under that model a group holding
the Guard with the routes registered on the app has no routes beneath it, so the
Guard is inert — and zip refuses the program rather than serving it ungated.
Measured, not assumed: the same shape ran the middleware under v1.18.23 and is
refused at build under v1.19.2.

The prefix list existed because a flat Use was "in front of every route the app
will EVER serve", which gated ai's /v1/models when iam mounted earlier in the
same list. Lexical anchoring makes that impossible, so the list is no longer the
boundary and one Use says what it means. Verified in the shape cloud actually
mounts (host.Use(NewApp(db))): /v1/models 200, /v1/iam/get-users 401,
discovery 200 — and TestFrameworkSideDoorsAreGated still closes /mcp
and /openapi standalone.

zip.Graft is gone too — an App is a Component, so composing one is Use. Doc
references updated to the verb that exists.

OPEN, deliberately not decided here: TestGuard_DoesNotGateASiblingSubsystemsRoutes
co-mingles iam onto a host app via routes.Route and expects the host's later
routes ungated. Under lexical anchoring one Use cannot give that; only a
path-scoped guard or moving every entity route beneath a prefixed group can.
cloud does not use that shape (apps/iam/iam.go calls it "the wrong call" and
composes the App instead), so the test's premise is stale — but retiring a
security regression test is the owner's call, not this commit's.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 15:15:29 -07:00
hanzo-dev 3a7bf22904 oidc: a session that exists is an answer, so the second app stops asking
The authorize endpoint rendered a login page for every request, prompt=none
included. A relying party therefore had no way to ask "is anyone signed in?"
without putting a login screen in front of somebody who already was — which is
not a missing feature, it is the absence of single sign-on.

It now has three answers instead of one: the session answers the request with a
code straight back to the registered redirect_uri; or, when nobody is signed in
and the client said prompt=none, error=login_required goes back the same way;
or, failing both, the hosted login. prompt=login and prompt=select_account ask
for a screen and get one. Discovery advertises exactly those three, because an
ignored prompt=none is indistinguishable from an honoured one that found no
session, so a client cannot discover the difference by trying.

The session cookie becomes __Host- prefixed. The scope decision it encodes is
host-only, and the prefix moves that from our convention into the browser's
rules: a user agent refuses to store such a cookie with a Domain, so a sibling
host cannot plant one of the same name and have the victim's browser present it
to the issuer. Without it that fixation would now propagate silently to every
downstream app. It costs one re-login per human.

Silent SSO is a top-level redirect and nothing else — a framed or fetched
request is declined, so the flow never needs SameSite=None and a page that
merely embeds the endpoint cannot harvest a code. max_age is honoured against a
signed auth_time the session now carries, and id_token_hint binds the subject,
because both questions used to answer themselves when every grant was
interactive.

The mint path absorbs the reserved-org confinement that lived in login.go and
therefore held for a typed password and nothing else. One mint path, one set of
rules: the tenant rule, the exact redirect_uri match, S256-only PKCE and that
confinement are now the same checks in the same order for the credential post,
the wallet, and the silent grant alike.

The redirect_uri allow-list is untouched. Silent SSO runs entirely behind it.
2026-08-03 13:37:28 -07:00
hanzo-dev 4625012ee9 sso: the IdP remembers the human, whatever grant the app asked for
loginGrant established the session only for type != "code", so the one path
humans actually walk — every app sends them through the code flow — minted a
code and left no session behind. The silent-SSO branch above it was fully
built, tested and correct, and had nothing to read: hanzo.id asked for the
password again on every app on the fleet.

The session is the IDENTITY PROVIDER's memory of who signed in. The grant
shape the RELYING PARTY asked for is a separate question, and braiding the
two together is what cost the fleet its single sign-on. Establish it for
every interactive grant shape, and only when no live session already exists
so a silent hop reuses the one it arrived with.
2026-08-03 11:55:00 -07:00
zeekayandhanzo-dev 2673203d48 oidc: a public client can revoke its own token, so logout ends the session
`hanzo auth logout` was a LOCAL DELETE. hanzo-cli is a public PKCE client and
its refresh token now lives 30 days (provision refreshExpireInHours 720), so
dropping the local copy left a credential that stayed spendable at hanzo.id for
the rest of the month with nothing able to kill it. Measured 2026-08-01: the
revocation endpoint answered 401 invalid_client and the refresh token went on
minting access tokens.

The cause was authConfidentialClient, which required a stored secret for both
RFC 7009 revocation and RFC 7662 introspection. A public client has no secret to
present, so revocation — the one control a long-lived refresh token has — was
closed to exactly the clients that need it most.

Split the question in two. authTokenClient now authenticates the CLIENT and only
the client: client_id names it, a client that HOLDS a secret must still present
it (constant-time, unknown app fails closed), and a client that holds none is
public — the same bounded relaxation authorizationCodeGrant and refreshTokenGrant
already make for loopback PKCE clients, and what RFC 6749 §3.2.1 says such a
client does. It reads nothing about the token, so its status code cannot tell an
unauthenticated caller whether the token exists (RFC 7009 §2.2). WHAT a caller
may then do is each handler's own decision:

  revoke       PUBLIC allowed. Widening authentication does not widen authority:
               the caller must POSSESS the token and the row must belong to the
               client presenting it. Possession already permits USE, and
               revocation is the strict opposite of use — a public client_id
               buys only the power to destroy what its holder could spend.
  introspect   CONFIDENTIAL only, unchanged. It reports on tokens the caller did
               not issue, so it stays addressed to a protected resource
               (RFC 7662 §2.1) and a public client_id proves nothing.

Tests state all three, and the first fails without this change — reverting
authTokenClient to demand a stored secret reproduces the live 401 verbatim:

  TestRevoke_publicClient_revokesItsOwnRefreshFamily
  TestRevoke_confidentialClient_stillNeedsItsSecret   (no widening for a secret holder)
  introspection still refuses a public client_id

Verified: `go build ./internal/oidc` clean, `go test ./internal/oidc` ok.

Found uncommitted in the working tree; committing it rather than leaving a
security fix on one disk.

Rebased onto 28 upstream commits, which had moved things under it: `internal/
{schema,store}` became `pkg/{schema,store}` ("store: one store package, not
two"), and the test's `ComputeS256Challenge` is now `pkce.Challenge` in
pkg/pkce. The doc comment conflicted with an upstream prose rewrite — kept
THEIR plainer wording and appended only what this change adds, rather than
reverting their edit.

A superseded sibling commit was dropped rather than merged: it bumped zip to
v1.18.22 and upstream is already at v1.18.23.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 08:54:00 -07:00
zooqueenandhanzo-dev 31ae9463a5 image: one meaning per output, so a branch push stops failing on a ref it never pushed
image / test (push) Successful in 5m3s
image / build (push) Successful in 3m34s
The `tag` output carried two different kinds of string: a bare version
(`v1.34.6`) on a tag push, but a WHOLE image reference
(`ghcr.io/hanzoai/iam:unpublished`) on a branch push. Every consumer then had
to know which case it was in. The `tags:` input did, via a `startsWith`
guard. The verify step did not: it prefixed the repository a second time and
asked the registry to resolve

    ghcr.io/hanzoai/iam:ghcr.io/hanzoai/iam:unpublished

which is not a reference, so it spent its six retries and exited 1. Every
push to main was red on a builder that had just built the image correctly —
and this is the repo's only CI, so a real failure would have been invisible
in the noise.

Split it into three outputs that each mean one thing: `version` (what the
binary reports), `image` (the destination ref), `push` (whether this ref is
published at all). The double-prefix is then not a bug to patch but a shape
that cannot be written.

Also guard the verify step on `push`: a branch build publishes nothing, so
there is no manifest to resolve, and demanding one fails a run that did
exactly what it should. And VERSION is now `dev` rather than the unpublished
image ref on a branch build — an empty value would have overridden the
Dockerfile's `ARG VERSION=dev` with nothing and linked a blank version in.

Publishing is unchanged: only a `v*` tag pushes, and it pushes that tag.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 14:06:29 -07:00
zooqueenandhanzo-dev c4292f58ec image: this repo is not a mirror, so mirror-sync never fed it
The header credited "mirror-sync from GitHub" for delivering commits. That path
cannot work here and never did: POST /v1/repos/hanzoai/iam/mirror-sync returns
400 "Repository is not a mirror". Commits now arrive via sync-from-github.yml.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 13:36:11 -07:00
zooqueenandhanzo-dev a3b1a3ff8b keys: two shapes, and a retired prefix is simply not a key
image / test (push) Successful in 4m46s
image / build (push) Successful in 1m20s
UserByAccessKey resolved three prefixes. A durable full-access bearer
credential IS the confidential half, so the third family meant the same
thing as sk- and every consumer had to know all three.

Now: sk- resolves (same-tenant pinned), pk- is refused as key_wrong_door —
a real credential at the wrong door — and everything else answers
key_unknown. A retired prefix takes that generic path rather than a branch
of its own, which is what makes the shape gone rather than deprecated, and
key_unknown is what renders cloud's actionable "mint a new one at
cloud.hanzo.ai/keys". key_wrong_door would advise "use your secret key",
a lie to a holder whose credential no longer exists.

schema.User.AccessKey has no authenticating reader, so userByField goes
with it. Registry key tests move onto the schema.Key rows the resolver
actually reads; the fixtures that keep a retired prefix now assert refusal,
so re-adding a branch for it breaks a test instead of passing silently.

internal/oidc had no //go:generate zipdoc directive, so make generate and
the staleness gate both skipped it and its published API docs had drifted.
Added, and regenerated.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 13:32:26 -07:00
zooqueenandhanzo-dev 46ad4859a5 sync: pull from GitHub on the forge, instead of a push that cannot authenticate
image / test (push) Successful in 4m36s
image / build (push) Failing after 1m30s
.github/workflows/sync.yaml needed a forge-WRITE token (FORGE_TOKEN) inside
GitHub's secret store. It was never set on this repo, so the workflow hit its
own guard — "FORGE_TOKEN is not set" — on all 8 pushes today, and main sat 2
commits / ~3h behind GitHub (forge d49ee442, GitHub 90a6373f) while
.hanzo/workflows/image.yml never saw a commit to build.

The other two candidate paths cannot cover this repo at all: the org webhook
(-> /v1/sync) and cron.update_mirrors are both mirror-sync, and this repo is
mirror:false — POST /v1/repos/hanzoai/iam/mirror-sync returns 400 "Repository
is not a mirror". Those carry the ~2,300 mirror repos, never the canonical
ones. So iam had ZERO working sync paths.

Replace it with the pull that hanzoai/app already runs green (6/6 recent runs):
the forge fetches GitHub and fast-forwards itself. No new secret — GH_PAT is
already a git.hanzo.ai ORG secret for hanzoai — and no forge-write credential
has to live in GitHub at all; the only outbound key is READ-only. Fast-forward
only, so a divergence fails loudly instead of forcing either side.

One direction, one mechanism.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 13:29:55 -07:00
hanzo-dev 90a6373f2e pkce: move the S256 rule out of internal/ so clients can import it
image / build (push) Successful in 1m14s
image / test (push) Successful in 5m5s
iam is the authorization server: it decides what a code_challenge is. But
the derivation lived in internal/oidc, which no client can import, so
clients copied the two statements instead. hanzoai/cloud has two such
copies -- apps/deploy/login.go pkceChallenge (whose comment says outright
it is "byte-identical to IAM's own pkceChallenge") and apps/integrations
twitterChallenge, identical apart from the name.

A copy of a transform is not wrong today; it is wrong the first time the
rule changes and only one copy hears about it. The fix is not to keep them
in sync, it is to have one of them.

pkg/pkce now holds the derivation -- outside internal/, so a client can
import it -- and exports Method ("S256") alongside, so a client cannot send
a method this server refuses. internal/oidc's ComputeS256Challenge is gone
and its 33 references, including VerifyPKCE itself, call pkce.Challenge.
Verification policy (constant-time compare, plain permanently rejected, the
sentinel errors) stays in internal/oidc where it belongs: that is the
server's rule, not the primitive.

The RFC 7636 Appendix B vector moves to pkg/pkce with the derivation, and
picks up a test that the encoding is unpadded base64url -- padding or the
standard alphabet yields a challenge the server will not match.
internal/oidc's copy of the vector test is deleted (it pinned the function
that moved); its VerifyPKCE policy tests all stay.

Also: the prose in pkg/schema, internal/oidc and internal/users described a
storage key as "a GenerateID decimal string". hanzoai/orm just unexported
that function, because it sat one keystroke from a UUID generator, so the
comments now describe the value's shape and name no private symbol of
another module. zipdoc_gen.go regenerated; the diff is that sentence only.

0 failing packages before and after.
2026-08-02 12:56:03 -07:00
hanzo-dev 9af6f93907 docs(llm): repair the blind Casdoor debrand in the header
A sed replaced 'Casdoor' with 'the legacy surface' and left the sentence
meaningless ('no the legacy surface, Beego, or xorm'). Naming Casdoor here is
correct and required: it is provenance, and the go.mod retractions plus
TestCasdoorLineageRetracted only make sense to a reader who knows what lineage
is being retracted.
2026-08-02 11:01:01 -07:00
hanzo-dev d49ee44212 sync: reconcile the two iam mains
image / test (push) Successful in 4m40s
image / build (push) Failing after 4m5s
The forge and GitHub mains diverged one commit each from ecaad4514: the CORS
credentialed-origin fix landed on the forge, the hanzoai/sqlite v0.5.0 pin on
GitHub. Both are real work and neither supersedes the other, so this merges
them rather than choosing a side.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 09:53:17 -07:00
hanzo-dev 75389e8d34 cors: the app answers the five credentialed logins itself, so the edge rule can go
image / test (push) Canceled after 1m33s
image / build (push) Canceled after 0s
A proxy on the hanzo.ai and hanzo.id zones appends
Access-Control-Allow-Credentials: true plus the reflected Origin to every
response. Measured: the cluster ingress reached directly with Host:
iam.hanzo.ai answers `server: zip`, `Vary: Origin`, no ACAO; the same request
through the proxy answers with both. No Go change can undo an appended header.

What it costs us is the invariant POST /v1/iam/login was RELYING on. Its
single-sign-on branch mints a spendable authorization code from the SSO cookie
alone, and the comment above it said that was safe because "the IdP never allows
credentialed cross-origin reads". hanzo_session is host-only and SameSite=Lax,
so a CROSS-site page cannot spend it — but the proxy reflects *.hanzo.ai, which
is SAME-SITE with iam.hanzo.ai, and iam.hanzo.ai serves that login endpoint. Any
page on any hanzo.ai subdomain could mint a code for a signed-in user. That is
account takeover, not a disclosure, and the comment now says so.

Narrowing the edge rule is the fix. This is what has to land first, because
narrowing it against an app that answers nothing signs every console out.

The five paths, taken from the client rather than guessed. hanzoai/js-iam
src/browser.ts sends `credentials: "include"` to exactly POST /v1/iam/login, GET
/v1/iam/web3/nonce, POST /v1/iam/web3/verify, POST /v1/iam/oauth/revoke and POST
/v1/iam/oauth/logout — three of which were not browser paths here at all, so the
app answered nothing on them. A browser DISCARDS a credentialed response that
omits Access-Control-Allow-Credentials, whether or not the handler reads a
cookie, so the criterion is what the client sends: withholding the header on one
of them withholds no privilege, it breaks the call. Only login actually spends
the cookie; revoke, logout and both wallet legs never read or clear it, which
makes the SDK's credentials there inert — and makes logout not ending the portal
session a real defect, recorded in LLM.md, whose fix belongs in the handler.

One table, not two sets. Each browser path carries the proof its caller
presents, so the security fact sits on the same line as the path and there is no
way to add a path to one map and forget the other. `credential` is not a bool:
its zero value is `absent`, so a lookup that misses is CLOSED rather than the
safest-looking of two real states, and `if browserPaths[p]` no longer compiles.

Vary is appended AFTER the handler. Set before c.Next(), it is simply replaced
by a handler that sets its own — which every negotiated response does — and the
cache protection disappears while still looking right. Reproduced by reverting
the line: Vary comes back "Accept-Encoding", ours gone.

The console list is enforced where BOTH deployments pass. cors.Allow panics on a
malformed IAM_SESSION_ORIGINS, and routes.Route calls it, so the cloud binary
that embeds IAM (iamserver.Route) is gated identically and before its listener
opens. A gate in iam's own main() is a gate cloud does not have; that call is
gone. Exact origins only — never IAM_TRUSTED_ORIGIN_SUFFIXES, which would read
"hanzo.app" as a suffix and name every customer-published page a console.

Tests are mutation-checked, because a negative test that cannot fail is worse
than none. Removing the panic, unmounting the middleware from routes, and
restoring the Vary ordering each turn a specific test red. The corpus is 106
Origin strings: prefix, suffix, case, trailing dot, port, scheme, path, query,
userinfo, CRLF-folded header injection, comma-joined pairs, cyrillic and
zero-width confusables. Whitespace is asserted, not trimmed — the transport
strips OWS before we see it, so exact() stays the one total rule.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 00:23:41 -07:00
hanzo-dev f349a886a3 deps: sqlite v0.5.0, which this tree can take because it holds no crypto
hanzoai/sqlite v0.5.0 removes the key-derivation and DEK-wrapping API:
DeriveKey, DeriveChildKey, NewDEK, WrapDEK, UnwrapDEK, PrincipalType,
PrincipalAAD, PrincipalOrg/User/Global, WithPrincipalKey.

Nothing here calls any of it. This module reaches hanzoai/sqlite only
transitively -- pkg/store -> hanzoai/orm/db -> sqlite -- and never derives a
key, wraps a DEK or opens a keyed database itself. So v0.5.0 is not a
migration for this lineage, it is a pin, and pinning it says so out loud
instead of leaving the next bump to find out.

The code that DOES hold the deleted API is the Casdoor lineage this module
retracted at v1.31.37 and moved to github.com/hanzoai/iam-v1: 29 call sites
across object/orgdb.go, object/ormer.go, object/migration.go and
cmd/iam/cli/orgdb.go. The unmerged feat/sqlite-hanzo-driver branch carries
the same code and has no common ancestor with this history. Neither can
merge here; both need cek.Open -- one key derived from master+namespace,
never stored, so there is nothing to wrap and nothing to rewrap -- before
they can move off v0.1.5/v0.3.0.

The six indirect entries tidy adds with v0.5.0 (luxfi/mdns, miekg/dns,
zeroconf, luxfi/zap, cenkalti/backoff, x/mod) are module-graph only:
go list -deps shows none of them compiled into any package here.

Verified: build and vet clean, suite unchanged at 0 failures / 27 packages.
2026-08-01 22:23:53 -07:00
hanzo-dev ecaad4514f serve: --ops, so a standalone iam is probeable again
image / test (push) Successful in 4m52s
image / build (push) Successful in 1m8s
The graft moved /healthz, /readyz and /metrics off the public listener onto
zip's ops listener, which is right: a host owns liveness for what it composes,
and a child registering /healthz silently takes over the shared binary's. But
nothing brought that listener up. Standalone, iam answered 8000 with no /healthz
on it, both probes took 404, the pod never went Ready, its Service kept zero
endpoints, and every caller resolving identity through iam.hanzo.svc got
connection refused.

The ops listener is now stated the way the other two already were — an address,
in the same grammar, on the same line:

  iam serve --zap :9653 --http http://:8000 --ops http://:9090

Default on, because this binary's deployment is standalone. --ops "" is the
grafted case, where the host owns the ops port (HIP-0106 §1.3(f)).

Needs zip v1.18.23: OPS_PORT built a bare address, a bare address is ZAP, and a
kubelet cannot probe a ZAP socket.

Verified: --ops http://:9090 gives three listeners, ops on http, /healthz 200
"ok" /readyz 200 "ready" /metrics 200, and 8000 404s all three. --ops ""
gives two listeners and nothing on 9090.

gofmt also reordered a pre-existing import; main.go was unformatted at HEAD.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 21:21:56 -07:00
zooqueenandhanzo-dev 4444a09c8b LLM.md: the device endpoints exist, so list them
image / test (push) Canceled after 4m9s
image / build (push) Canceled after 0s
The surface list is what someone greps to find out whether a thing is served.
It omitted the RFC 8628 pair entirely, which is part of why the device flow was
investigated four times today against retired /api/ paths that answer with
misleading codes.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 21:17:33 -07:00
zooqueenandhanzo-dev dfadb80e2c device info: POST, because the argument is a secret
image / build (push) Failing after 1m52s
image / test (push) Successful in 4m27s
The lookup was a GET with the user_code as a path segment. A user_code is the
one secret in the device flow, and a request line is copied into ingress and
proxy access logs where a body is not — the approval page even ships scrubUrl()
to keep the code out of the address bar, so a GET undid that server-side.

POST for a read is the same call RFC 7662 introspection beside it makes, for the
same reason. The code now rides the body at a fixed path.

Caught by the id client while wiring it up; v1.34.2 shipped the GET form because
this change was left in the working tree when that tag was cut. It never reached
production behaviour — the portal that calls it has not shipped — but a tagged
release did not contain what its own commit claimed, so: v1.34.3.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 20:59:13 -07:00
zooqueenandhanzo-dev eb7a760378 logout: end the session, instead of reporting that we did
image / build (push) Failing after 1m59s
image / test (push) Successful in 4m54s
The handler destroyed nothing. Every line computed a redirect and it answered
{"status":"ok"} unconditionally — no session ended, no cookie cleared, no token
revoked. A redirect helper wearing a logout endpoint's name. Anyone who signed
out on a shared or borrowed machine was still signed in, and had been told
otherwise, which is worse than no logout at all.

sessions.Clear is the inverse of Set and does BOTH halves, because either alone
leaves a live session: the sid is dropped from the Session row so a cookie
captured before logout is dead server-side, and the cookie is expired so the
browser stops presenting it. Server-side revocation is the load-bearing half —
expiring the cookie is cosmetic against anyone holding a copy of its value, who
is precisely the threat.

The relying party's grant is revoked too, family-wide: a refresh chain rotates
into new rows, so deleting only the rows found by (user, app) can leave a
rotated descendant alive and mintable. Revocation state is the authority — a
JWT's exp still reads valid days out, so expiry is necessary and never
sufficient.

Revocation requires a live SESSION, not merely an id_token_hint. A hint is a
token, not proof of present possession; revoking on one alone would let anyone
replaying a captured id_token tear down that user's grant.

The open-redirect guard is untouched: a redirect still happens only when a
signature-verified hint identifies the application AND that application
registered the target. Content negotiation is layered on top — a browser
navigation lands on a signed-out page instead of a raw JSON blob on a blank
screen, while any caller that expresses no preference keeps the JSON envelope it
parses today.

Tests fail against the old handler with the exact sentence describing the
defect, including one that catches a logout reporting success on a still-live
session, and one asserting a bare hint revokes nothing.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 20:39:26 -07:00
zooqueenandhanzo-dev b466bd6302 device approval: name the client that minted the code
The approval page rendered the PORTAL's own application name — a per-brand
constant — for every device code. A code minted by hanzo-cli was approved on a
screen naming hanzo-console. The one job of a device-approval screen is to tell
you WHICH application you are authorizing; one that names the wrong app defeats
the control and manufactures the confidence it should be earning.

The client is a property of the CODE, so serve it from the code's row:
GET /v1/iam/oauth/device/:userCode answers the pending authorization's own
application. Gated on the session and sharing approveDevice's ONE opaque refusal
and its tenant boundary — the user_code is 40 bits and is the only secret in the
flow, so an unauthenticated or case-distinguishing lookup would be an oracle for
hunting live codes. It reveals strictly less than the approval the same caller
could already attempt.

CodeLoginRequired is the stable reason a caller routes on, replacing prose that
several causes shared. It also completes e9553147, which referenced the constant
before it existed and left main unbuildable.

This must land BEFORE the approval page's self-attestation checkbox is dropped
(id 5d411ff): the checkbox was an anti-phishing control, and naming the correct
client carries that protection far better than a tickbox users clear reflexively
— but only once the name is true.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 20:39:10 -07:00
zooqueenandhanzo-dev e955314715 fix: a device approval with no session says what to do about it
The approval page posts no credential — by design, the human is already signed in,
which is the whole point of approving on a phone. With no session cookie the
request fell through to the credential check and answered "organization, username
and password are required": three fields that page does not have and will never
show. The only reading available to the person reading it is that their credential
was rejected, when what was missing was a sign-in on that browser.

The session branch already knows this case — it says "please sign in first" one
line up when the session resolves to a dead user. Now the no-session case says the
same kind of thing, and names the next step rather than three fields that do not
exist here.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 20:33:00 -07:00
zooqueenandhanzo-dev d9c1e89e8e image: only a version publishes
image / build (push) Failing after 1m32s
image / test (push) Successful in 5m22s
A branch push published an immutable sha-<7> alongside the tag builds. It still
BUILDS on a branch — that is the check that main compiles and the image
assembles — but it now pushes nothing.

Traceable was never the bar. A registry that accumulates a tag per commit makes
'what is released' a question you answer by reading git instead of by reading the
registry, and it is how production came to run sha-ba43c54: a commit newer than
the last built release and older than two tagged ones, so the estate's IdP ran
code that no version named.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 20:22:14 -07:00
zooqueenandhanzo-dev e87e44bc97 device approval: a signed-in human approves without retyping a password
image / test (push) Successful in 4m31s
image / build (push) Successful in 1m10s
The RFC 8628 terminal leg could not complete. login.go's session branch was
gated to type=code — deliberately, to keep "a device approval a deliberate act"
— so the approval page's credential-less type=device post fell straight through
to the credential check and answered

    {"status":"error","msg":"organization, username and password are required"}

with HTTP 200, so nothing read as broken. `hanzo login` printed a QR and hung at
"Waiting for approval…" forever, on every brand portal at once.

The deliberate act was never the password. It is the human opening the
verification URI and transcribing the user_code their own device shows;
approveDevice binds the approver's proven identity onto exactly that pending
code, and a code nobody typed approves nothing. What the restriction actually
demanded was a full re-authentication from someone already authenticated — which
no device flow asks for, and which this page never sends.

No test caught it because every device test approved with organization +
username + password (approveAs), a shape the product never sends: the page
exists precisely because the human is already signed in. Both new tests drive
the real shape — session cookie, no credentials — and the first is
negative-controlled: reverting the gate reproduces the live message exactly.
The second pins that anonymous approval is still refused and binds nobody, so
dropping the type restriction cannot let a device be taken over by a caller with
no identity to bind.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 18:47:54 -07:00
zooqueenandhanzo-dev 0e12788c94 generate: the doc files that describe the code change
image / test (push) Successful in 8m15s
image / build (push) Successful in 1m7s
99e8bdfe added `code` to the response envelope and wrote its documentation;
fa8afb44 and 8fec8654 then moved store and schema. None of the three ran
`go generate`, so six zipdoc_gen.go files describe a surface that no longer
matches their source.

make test runs the staleness check as its FIRST step, before a single test.
Every push since has failed there and skipped the build job, so no image has
been published for any of the three commits — including the refusal-reason
fix itself, which is the one that cannot reach production without this.

Pure `go generate ./...` output, no hand edits: the new Response.code
description, the rewritten resolve-key prose, and gofmt realignment of the
map literals the added key widened.

go test ./... -race -count=1 -> 27 packages ok, 0 failures.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 16:14:25 -07:00
hanzo-dev 8fec86541e schema: the identity model is public, not internal
image / test (push) Failing after 1m36s
image / build (push) Skipped
pkg/store returns *schema.User and *schema.Key, and schema was internal. An
outside caller could invoke the resolvers and read their fields — Go's internal
rule restricts imports, not inference — but could not NAME the types, so it could
not declare a variable, write a helper, or seed a row in a test.

That last one is what made it a real boundary rather than a curiosity. Moving
cloud's API-key resolver off HTTP and onto this store means its tests should
exercise the store instead of a stubbed HTTP envelope — which is better coverage,
since it tests the actual query rather than a fake reply. Those tests must create
a user with an access key, and creating one requires the type.

So schema moves out with store. The model a caller receives is part of the API
that returns it: a package cannot hand back a type and also forbid describing it.

28 files, 149 importers, no name collisions, nothing renamed. Full suite green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 16:07:57 -07:00
hanzo-dev fa8afb4494 store: one store package, not two
image / test (push) Failing after 1m14s
image / build (push) Skipped
There were two. pkg/store held the project lifecycle; internal/store held
applications, users, tokens, api keys and memberships — 56 exported functions
against the same orm.DB, doing the same job, split only by which one a caller
was allowed to import.

That split is why cloud talks to IAM over HTTP. cloud embeds IAM in-process
(zip.Graft composes iamserver.NewApp), so its API-key resolver sits in the same
binary as the code that resolves an access key — and cannot call it, because
PublishableKeyByAccessKey and UserByAccessKey live under internal/. So it dials
http://iam.hanzo.svc/v1/iam/resolve-key: a network round trip from a process to
itself, forced by a package boundary rather than by a design.

Everything downstream of that hop is scaffolding for a call that should never
have left the process — the Cloudflare 403 on server-side POSTs to the public
issuer, the CLOUD_KMS_IAM_TOKEN_URL → IAM_URL → public-issuer fallback chain in
cloud's KMS broker, and the init() that panicked when iam.hanzo.svc was down and
took api.hanzo.ai with it.

One package now, at pkg/store. No exported or unexported name collided, so
nothing was renamed to fit; the two files that were both called store.go are now
named for what they hold — project.go for the project lifecycle, store.go for
the rest. All 79 importers move with it. Full suite green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 15:07:56 -07:00
zooqueenandhanzo-dev 99e8bdfeb8 iam: a refused key says WHICH refusal it was
"the entity does not exist" was one sentence for causes that call for
opposite actions. A holder whose key was revoked went looking for a
deleted org instead of minting a new key, and a tenant admin's forgery
attempt — the same-tenant pin firing — was indistinguishable from a typo.

The reason is now a value (store.KeyFailure) carried beside the error
rather than baked into its text. KeyError unwraps to orm.ErrNotFound, so
every existing errors.Is caller keeps working unchanged and unaware.

Both doors enumerate honestly: the secret door distinguishes unknown /
wrong-door / foreign-user / dangling-user, and the publishable door
distinguishes unknown / not-publishable / expired — the trio cloud's own
test annotated while having no way to tell them apart.

The human `msg` is byte-identical; the reason rides as `code`. Nothing
that reads the prose can tell the causes apart, and the caller that
reaches it has already passed CapKeyResolve — it can resolve any key to
a full principal, so the code discloses nothing it could not obtain.

A store fault yields no reason at all, so infrastructure trouble is never
reported to a holder as a bad credential.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 13:48:42 -07:00
hanzo-dev 7af0326e2d prose: the last two — JWKS, hoisted into a variable and lost on the way
`jwks := jwksHandler(db)` then Alias(…, jwks) puts a VARIABLE where the handler
goes, and a variable has no doc comment. Inlining the call restores the one place
the sentence lives. And the comment on the handler opened by restating its own
route — "serves GET /v1/iam/.well-known/jwks" — which is the raw-route fallback
written by hand, in the one place a fallback cannot be deleted from.

193 of 193 IAM operations now carry a description, every one lifted from a Go doc
comment in this repo. cloud's `make -C apps/iam describe` goes green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 13:01:26 -07:00
hanzo-dev e1629bd91e prose: the last 24, and the alias helper moves to zip so both halves speak
Nine legacy-verb pairs published silence at BOTH addresses, and the cause was a
two-line helper: internal/httpx.Alias registered canonical and legacy inside
itself, where a pass reading `router.Get(path, handler)` cannot see either. The
legacy half is the one a pinned consumer is likeliest to be calling, so it was
the worse of the two to lose.

zip v1.18.22 owns the primitive now — a matcher can only recognise a function
whose identity it knows — so internal/httpx/alias.go is deleted and the ten call
sites read zip.Alias. Both addresses carry the handler's sentence, which is
correct: they are one handler and mean one thing.

JWKS joins them. It was `jwks := jwksHandler(db)` registered twice, which is an
alias written the long way and lost its prose for the same reason.

And the four with no doc comment at all — the four highest-traffic operations in
the product, which is how they came to be the last ones:

  /v1/iam/login             what a person's password actually does
  /v1/iam/oauth/authorize   where every sign-in begins
  /v1/iam/oauth/token       and what your application exchanges at the end
  /.well-known/jwks         the one URL a service needs to verify a token itself

Each now says what it does AND what it costs to get wrong: a refresh token is
retired on use, and re-presenting a retired one revokes the whole chain, so a
stolen token buys one use and costs the session. That is worth a customer's
minute, and it was written nowhere.

193 operations carry a description, up from 167, 93, and 0 as published.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 12:58:58 -07:00
hanzo-devandzeekay 8a0ca1686f prose: the untyped half says what it does too — the sign-in surface included
The typed CRUD describes itself now. The other 74 operations did not, and they
are the ones a customer actually meets: sign-up, sign-in, whoami, the OAuth
token/authorize/userinfo/introspect/revoke endpoints, device login, wallet login,
SCIM provisioning, MFA enrolment, service accounts, memberships, the registry
token door, and the legacy read aliases. Every one published an address and
silence, and cloud's producer gate refuses a document like that — correctly, and
it has been refusing.

These routes stay untyped because the WIRE says so, not because nobody got to
them: an OIDC redirect, a JWKS document, a SCIM body RFC 7643 governs, a
multipart form, a Docker registry token. zip v1.18.21 gives them the same seam
the typed ops have — the doc comment on the handler — so this is prose written
where the handler lives, not a table of strings in a host that does not own them.

What is rewritten is the VOICE. These comments were written for us:

  "Serves GET /Users — owner-scoped, filterable by `userName|emails eq \"x\"`"
  "implements RFC 7662. Active iff the grant row still exists"
  "reports the resolved caller's identity in the casibase envelope"

A person paying for the Hanzo Cloud is not reading about our grant rows. They now
read what the operation does for them, and what it costs them to get it wrong:
revoking a refresh token kills every token minted from it; a SCIM replace leaves
multi-factor enrolment alone so a routine directory sync cannot strip somebody's
second factor; a service account's secret is shown once and never again;
publishable key resolution names an organization and never a person, on purpose.

Where a comment carried something for MAINTAINERS — the confused-deputy incident
behind authz.Scope, why a wrong factor burns the federation challenge — it moved
into the function body. A comment in the doc position is not a note; it is what a
customer reads.

Measured: 167 operations carry a description, up from 93 (typed only) and 0 as
published. Every one traces to a Go doc comment in this repo.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 12:53:54 -07:00
hanzo-dev ab6b90fc8a applications: the delete doc sat on the wrapper, so the operation shipped bare
zipdoc keys prose by the handler it is attached to. The paragraph describing the
delete — 'anyone mid-sign-in through it is turned away' — was written above
Delete, the thin legacy-envelope wrapper, while every route registers
deleteApplication. So the emitted document carried an empty Doc{} for the delete
and the only operation in the kind with nothing to say was the destructive one.

Moved onto the handler the routes actually name. 0 undescribed operations in the
emitted document, down from 2.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 12:46:41 -07:00
hanzo-dev 9b4886c791 applications: one kind, one spelling — the plural, like the other fourteen
`/v1/iam/application` and `/v1/iam/applications` both answered, and which one a
reader wanted depended on the operation: the plural listed, the singular got,
created, updated and deleted. Every other kind in this service — users, certs,
roles, invitations, keys, projects, workspaces, permissions, providers, tokens,
sessions, organizations, audit-logs, webauthn-credentials — is addressed in the
plural with `/get`, `/update` and `/delete` under it. Fourteen against one is
not a matter of taste.

So applications moved to the house shape. The singular address stays reachable
on the SAME typed handlers, tagged `compat`, which is what keeps it out of the
published document and therefore out of every SDK, docs page and CLI command —
the same seam the nineteen legacy entity verbs already use. It is deleted when
the last pinned consumer moves.

make test green (-race -count=1).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 12:44:51 -07:00
hanzo-devandzeekay 262f5f0c81 prose: IAM describes its own 93 operations, in the customer's words
IAM published 179 operations on api.hanzo.ai and not one of them carried a
description. 94 carried a WithSummary("…") string; 85 carried nothing at all.
Everything downstream inherited that: the OpenAPI document, every generated
client, the MCP tool list an agent reads to decide whether to call something, and
`hanzo iam …`.

The prose was not missing. It was written, on the handlers, and no generator ever
read it — this repo has never run zipdoc. So:

  * `//go:generate go run github.com/zap-proto/zip/cmd/zipdoc` in each of the 16
    packages that register typed ops, the committed zipdoc_gen.go beside it, and
    `make generate`. `make test` now runs `zipdoc -check` FIRST: a doc comment
    edited without regenerating is a red build, not a quietly stale artifact,
    because the failure mode here is silence and silence never rings.

  * 75 `zip.WithSummary("…")` calls deleted. Each sat beside a doc comment saying
    the same thing — two places to change and one to forget. The summary is now
    the first sentence of the comment, so there is ONE source and it is the one a
    reviewer already reads.

  * Every one of the 93 rewritten for the person paying for the Hanzo Cloud
    rather than for us. "Persists a new user, hashing the plaintext password"
    became "Adds a person to your organization and, if you send a password, sets
    the one they will sign in with. The password is hashed before it is stored
    and is never returned." No in.Owner, no orm, no v1-parity notes, no
    read-modify-writes. Where a sentence had to say something to MAINTAINERS —
    the confused-deputy incident behind authz.Scope, the compat grouping — it
    moved into the body or the Route doc, because a comment in the doc position
    is not a note, it is what a customer reads.

This needs zip v1.18.19: 34 of these are registered as `listProviders(db)` and 11
as multi-line inline closures, and zipdoc could read neither until that release.

Measured, on the generated files: 93 of 93 typed ops carry a description, up from
50 of 84 when zipdoc first ran here and 0 of 179 as published.

Still open and stated rather than papered over: IAM's 85 UNTYPED fiber routes —
the OIDC surface, SCIM, MFA, service accounts, memberships and the legacy read
aliases — have no prose channel at all. zipdoc lifts from typed registrations
only, so those need either typing or a doc-comment channel for raw handlers.
Nothing here invents a sentence for them.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 12:37:29 -07:00
hanzo-devandzeekay 4160c2e12e naming: a path names the thing, the method says the verb
Nine addresses spelled the verb twice: send-verification-code, set-preferred-mfa,
get-account, get-app-login, update-preferences, issue-user-token, mint-user-keys,
revoke-user-keys, delete-mfa. That spelling arrived with the entity store this
service replaced, and it is not an internal detail — it is what a customer reads
in `hanzo iam --help`, what every generated SDK turns into a method name, and
what every docs page prints.

Each now answers at a canonical noun as well: account, auth/application,
preferences, verification-codes, tokens/issue, keys/mint, keys/revoke,
mfa/disable, mfa/preferred. httpx.Alias registers ONE handler value at BOTH
addresses — no second implementation to keep in step, and no forward that could
answer differently from the thing it forwards to. The legacy spellings stay
reachable, so the console BFF, the gateway admin-api and the hanzo.id portal keep
working; they are simply what nothing teaches. When the last pinned consumer
moves, the Legacy* half of a pair is deleted and nothing else changes.

naming_test.go is the gate, and it reads the WHOLE router rather than one
package's subtree — server.NewApp's route table, every address the binary
actually answers at. It found set-preferred-mfa, which I had not enumerated. The
frozen list only ever shrinks: a new verb-noun fails at the commit that
introduces it instead of surfacing years later as a command name in somebody's
terminal.

make test green (-race -count=1).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 12:35:04 -07:00
hanzo-dev 1050448b08 image: a v* tag is a release and must publish an image named after it
image / test (push) Successful in 4m12s
image / build (push) Successful in 4m15s
The one builder for ghcr.io/hanzoai/iam answered only to `push: branches:
[main]` and unconditionally tagged its output `sha-<7>`. So it could not
publish a semver image at all, by construction, and `git tag` published
nothing.

Measured: v1.33.32, .33, .34, .35, .36 and .37 are all real tags on main and
NONE of them has an image (404). The last release that does is v1.33.31.
Production ran `ghcr.io/hanzoai/iam:sha-ba43c54` — newer than the last BUILT
release and older than two tagged ones — so the estate's identity provider
was running code that no version names, and the five commits between it and
v1.33.37 include "ROPC requires a confidential client, so going public cannot
open it" and the fix behind "why no terminal could sign in".

Two changes, both minimal:

  on.push.tags: ['v*']   a tag push now builds.
  meta               a tag push publishes the TAG; a branch push still
                     publishes an immutable sha-<7>.

Both outputs stay traceable and only the first is deployable — the estate's
guards require a semver tag (or a digest) in any manifest. A non-`v` tag
falls through to sha-, so an odd tag name cannot publish a bogus version.

This step also feeds `-X main.version`, so a release binary now reports the
same string as its image instead of every build calling itself a commit.

Owner directive: everything we publish carries real semver, no ad-hoc tags.
This is the repo that most needed it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 12:31:34 -07:00
zeekayandhanzo-dev cdd30428c9 compat: a legacy verb alias is named by its address, not by the op it delegates to
image / test (push) Successful in 3m53s
image / build (push) Successful in 3m40s
Five of the 19 legacy write aliases claimed the SAME operationId as the REST
twin they delegate to: addProvider, updateProvider, deleteProvider,
updateOrganization, deleteOrganization each named two distinct operations at
two distinct addresses. OpenAPI requires an operationId to be unique — one id,
one operation — so every generated client would bind whichever it read last,
and a document containing both is refused outright by any weave that checks.

It was invisible because the whole surface was published behind a wildcard.
Grafting iam into a host renders its ops into the host's document, and the
uniqueness check refused on the first of the five immediately.

The fix is one rule, applied to all 19 rather than patched on five: an alias
delegates to the canonical op, so the only thing that distinguishes the two IS
the address — let the address name it, via zip's path-derived default
(post_v1_iam_update_provider). Naming them by hand restated what the path
already said. The canonical REST op keeps its hand-picked SDK name.

No published SDK method changes: these 19 have never appeared in any generated
client, because the host published a wildcard where they were.

iam op names 89 -> 94 (five names that were one are now five).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 11:12:50 -07:00
zeekayandhanzo-dev 29bda800a5 graft: NewApp is what a host composes; Handler and the child /healthz die
server.Handler was adaptor.FiberApp(NewApp(db).Fiber()) — the one place in
cloud's whole module graph where a zip App was erased into an http.Handler.
A host hung it on app.All("/v1/iam/*", zip.AdaptNetHTTP(h)), and iam's 94
typed ops died at that closure: cloud published five wildcard path keys and
35 placeholder operations where 78 real paths and 94 typed operations were,
with no schema, no MCP tool, no CLI command and no SDK method for any of them.

zip v1.18.16 adds Graft, which composes the App itself: the host's router
learns iam's patterns AND its registry while iam's router keeps iam's
behaviour — the Use(authz.Guard) seam, the error handler, the config. NewApp
is the graft target and needs no adaptation, so Handler is deleted rather
than kept as a second way to embed.

Liveness is not iam's. zip/ops.go states the rule: /healthz, /readyz and
/metrics are a SECOND listener the DEPLOYMENT brings up when it names
OPS_PORT, never the public one — a liveness probe must not queue behind
public traffic. routes.Route registered /healthz on the public group, which
was iam hand-rolling a path the framework owns on the wrong listener, and it
is what made iam un-composable: a host registers /healthz as the HOST's
because it must answer while every subsystem is still cold. Two claimants on
one liveness address is what once served {"binary":"iam2"} out of a shared
binary. One address, one owner, and this is not iam's.

The two authz tests that named /healthz now name only routes that exist —
a public-route assertion against a path with no route passes vacuously.

zip v1.17.7 -> v1.18.16 (the version cloud already resolves to).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 10:59:18 -07:00
hanzo-dev 6b3c20eef1 LLM.md: why no terminal could sign in, and what going public then opened
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 10:40:42 -07:00
hanzo-dev b1584dfd98 oidc: ROPC requires a confidential client, so going public cannot open it
A CLI cannot hold a secret, so `hanzo-cli` has to register with none — that
absence is how IAM says "public", and it is what lets the device grant run at
all (a stored secret the client cannot present is `invalid_client` at
/v1/iam/oauth/device, which is why `hanzo login` was dead).

The same flip silently made ROPC reachable WITHOUT a credential. The password
grant let a public client through by design — a legacy-parity relaxation carried
so console/chat logins would not 401 during the clean-room cutover — and the
stored secret was the only thing that had ever gated it. Unlike the code and
device grants, ROPC has neither a PKCE challenge nor a human approval step to
authenticate the caller in the secret's place, so "public" there means anyone
who knows the client_id can post a username and password: a credential-stuffing
oracle against every user in the tenant, and a lockout lever against any named
one.

The relaxation was dormant — every live registration is confidential and takes
the secret path, measured against production — so requiring a confidential
client breaks nothing that works today and closes the surface for good. The rule
lives in the grant, not in a provision document, because registration shape must
not be able to open a credential surface.

Tests: a public client is refused with the CORRECT password (the refusal is the
client rule, not the credential); the lockout, org-boundary and forbidden-user
gates keep their own coverage, now reached by a fully authenticated caller.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 09:30:39 -07:00
zeekayandhanzo-dev b3c660f770 LLM.md: say which half bit whom, measured over all 286 live applications
image / test (push) Successful in 3m22s
image / build (push) Successful in 1m20s
The note claimed "every other app still carries refreshExpireInHours: 0". Wrong,
and wrong in the direction that hides the bigger win. Read off hanzo.id's store:
most first-party clients already carried the v1-era expireInHours 168 +
refreshExpireInHours 720, so for hanzo-cloud, hanzo-chat, hanzo-platform and
hanzo-world the lifetime was never the problem — they held a 30-day refresh token
they could not SPEND, because refresh demanded a secret their PKCE surface does
not have. One fix unblocks all of them, and each was driven after the change:
code->token 200 then refresh 200 with a new access token, no secret at either step.

hanzo-cli was the rare client with BOTH lifetimes at 0, which is why it was the
one that hurt. Still at 0: hanzo-git, hanzo-zrok, hanzo-admin, and every
auto-created per-signup app-<email> client. hanzo-mcp was too — same loopback
PKCE shape as the CLI, same dead refresh — and is now declared alongside it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-31 17:14:42 -07:00
zeekayandhanzo-dev ba43c54e76 oidc: a PKCE grant must be able to refresh, and a refresh lifetime must be sayable
image / test (push) Successful in 3m47s
image / build (push) Successful in 1m12s
`hanzo cli` reopened a browser login every hour. Two independent defects, either
of which alone is fatal to refresh_token, and both measured on hanzo-cli today.

CLIENT AUTH. authorizationCodeGrant carries a documented relaxation: a
registration that HOLDS a secret can still serve a public PKCE surface — hanzo-cli
and every @hanzo/iam SPA whose secret exists only for a backend path — so a code
exchange presenting no secret is authenticated by the PKCE binding instead.
refreshTokenGrant had no such relaxation and required the secret unconditionally.
The client completed the exchange without one, cannot acquire one, and was
refused the moment it tried to renew:

  POST /v1/iam/oauth/token grant_type=refresh_token client_id=hanzo-cli
  → 401 {"error":"invalid_client","error_description":"client authentication failed"}

The value that decides this is a property of the GRANT, not of the registration,
and it was being computed at establishment and thrown away. schema.Token.
PublicGrant records it and refresh honours it. It is copied onto the successor
row, because a rotation that drops it makes only the FIRST refresh work and 401s
the second — a session that dies an hour late instead of on time. It never
widens: a grant established WITH the secret still needs it, a presented secret is
always verified, and a code with no PKCE challenge is untouched.

LIFETIME. refreshTTL falls back to appTTL when RefreshExpireInHours is unset, so
the refresh token expires at the same instant as the token it exists to renew —
the advertised grant is dead on arrival. Nothing could say otherwise: the upsert
body (the ONLY supported admin write path, and what `iam provision` converges
through) carried no lifetime field at all, and the create path hardcoded
ExpireInHours=1. expireInHours/refreshExpireInHours now travel document →
provision.App → upsert → model under ONE name. POINTERS on the wire so an omitted
lifetime PRESERVES: a plain float sends 0 on every steady-state reconcile and
resets the lifetime it just set, the same accident IsShared is a pointer for.

provision.checkLifetimes REFUSES a refresh lifetime that does not outlive the
access lifetime, so the state hanzo-cli shipped in cannot be declared again. The
rule is total — an unstated access lifetime is schema.DefaultExpireInHours, which
is now the ONE home for that default (oidc.appTTL and the upsert's create path
both read it; three copies of "1" is how a refresh token comes to be born
already expired).

NOT changed: the refreshTTL fallback itself. Session lifetime is POLICY and this
mechanism ships none — every other app fixes it with one line in its own org's
provision document.

Tests are the failure, not the fix: reverting the refresh relaxation reproduces
the live 401 verbatim, and reverting the rotation copy fails the second refresh.
Also covered — a secret-established grant still needs its secret, a wrong secret
is still refused, lifetimes round-trip and an omitted one preserves, and four
shapes of unusable refresh lifetime are rejected at Derive. 28 packages green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-31 16:56:22 -07:00
hanzo-dev add9283033 oidc: say what a token carries, in discovery and in LLM.md
claims_supported advertised `preferred_username` for a year while no token emitted
it, and that gap is exactly why consumers went on reading `name` as a display
name. It now lists `displayName` too, so the document says what a token actually
carries rather than what it might.

LLM.md gets the two rules a reader has to know before touching either surface: a
principal is owner + USERNAME on every surface, and what may be a username is
decided in one place.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 20:33:22 -07:00
hanzo-dev d9381225bb users: one username rule, at the one write every create path reaches
Ten entry points reach a user row. Exactly ONE of them validated the name it
wrote — self-service signup, via a policy function with a single caller. SCIM
create, the legacy add-user verb, the operator's bootstrap upsert, the typed CRUD
create, the embedder seam and the wallet/service-account writes took whatever
bytes arrived. Anything a JSON string can hold could become a principal that way,
including a human's display name with a space in it — which is precisely the
account everyone had to go and check for after a token minted `name:
"Zach Kelling"`. The token was the defect; that it took an investigation to rule
out the other explanation is the defect underneath it.

So what may be a username is now a property of the entity, in schema.Username:
trim, lowercase, `^[a-z0-9][a-z0-9._-]{0,62}$`. Normalization settles case and
padding, which carry no meaning; everything else is REFUSED rather than rewritten,
because quietly turning what someone typed into a different principal is the
failure being avoided, not a convenience. Two deliberate differences from the
policy it replaces: a leading digit is allowed (nothing resolves a principal
numerically), and one character is allowed — the account this was written over is
named "z", which the old two-character minimum could not have created.

Every creation path calls it. users.Create is the choke point six of them already
share, so the rule lives at the write rather than at six doors; CreateInput's
AuthzTarget normalizes too, so the pair AUTHORIZED is the pair STORED and
authorization cannot sit one principal away from the write. The three paths that
write through orm directly — bootstrap's first-admin seed (which predates any
principal), the wallet identity, and the onboarding credential — state it
themselves. Service accounts stop re-enumerating the charset and keep only what is
actually theirs: `<org>-` binding, and segmentation (a handle that gets read back
apart must not name an empty segment).

Social signup derives from the ADDRESS, never the profile. schema.Handle takes the
email local part, and it refuses a string with no "@" and a local part with
whitespace — without both, "Zach Kelling" is just a local part whose space gets
dropped and the profile name silently becomes the username "zachkelling". The
display name reaches DisplayName and stops. Dedupe is a numeric suffix on the name
a person would have chosen (z, z2, z3), replacing a random 8-hex suffix on EVERY
name ("z-3f9ab21c") that made collisions impossible by making every username
unrecognisable.

Case stops making a second person. Lookups resolve exact, then folded, then over
the org for a legacy mixed-case row — and FAIL CLOSED when the fold is ambiguous,
the same rule GetUserById applies to a duplicated subject, so whoever registered
"ALICE" alongside "Alice" can never be resolved as the other. Stored names are NOT
rewritten: renaming moves real principals, so the resolution tolerates case
instead. users.lookup goes through store.GetUserByName rather than repeating the
query, which is how Create's uniqueness check had stayed case-sensitive while the
rule it guards is not — it would have admitted "Alice" next to "alice".

maxOrgSlug 60 → 55 so `<slug>-default`, the credential onboarding derives from a
slug, still fits the 63-character username bound by construction rather than by a
check that fails at the end of onboarding.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 20:32:34 -07:00
hanzo-dev 10c84382f5 oidc: name is the username — a login as z minted "Zach Kelling"
`hanzo auth login` files its credential under `owner`/`name` read straight off
the minted token, so those two claims ARE the principal every downstream surface
believes it holds. Reproduced today on iam.hanzo.ai: the authorize/code exchange
for client_id=hanzo-cli returned an access token carrying

    owner = hanzo    name = "Zach Kelling"    preferred_username = z

and the CLI filed `hanzo/Zach Kelling` — an account that does not exist, a human
label with a space in it. Every surface reading `name` then named the wrong
principal.

userClaims (token.go) computed `name = DisplayName, else Name`. That is OIDC's
display reading of `name`, inherited from the v1 Userinfo struct, and it has been
the behaviour since the in-tree server was written (73b7ef63e). It was not wrong
by accident — it was wrong by CONTRACT, because ours says owner=org, name=username
and nothing else addresses a principal. cloud's money path had already paid for
it: it addresses a wallet `<org>/<username>`, read `name`, addressed
`hanzo/Zach Kelling`, and 402'd every completion while the balance sat in
`hanzo/z`. 5c0ea823f answered that by ADDING preferred_username and deliberately
leaving `name` alone, which gave the username a home without evicting the display
name from the claim consumers actually read. The CLI then hit the same wall from
the other side.

So `name` is the username, always; preferred_username is the OIDC-standard
spelling of the SAME field (cloud reads it, discovery advertises it, and sourcing
both from one field is what stops them drifting); and the display name moves to
`displayName` — the spelling schema.User, SCIM and whoami already use — where
nothing resolves an account from it. UserInfo answers identically: it and the
token describe one principal, and a client holding either must not get two
different names for it.

Three mint paths — the code/refresh/password grant, the console's
issue-user-token, and the RFC 8693 exchange — had each SEPARATELY written the
`DisplayName, else Name` fallback, so fixing one would have left two. They now
share identityOf, the one user→claims resolution.

The values also stop travelling as six adjacent positional strings, two of them
human-readable and therefore swappable at the call site. They were swapped, on
all three paths, and it type-checked; the wallet harness had already lost a scope
into the username slot the same way. Identity gives each value a name, and
Signer.claims is the single place one becomes a claim set, so `name`,
`preferred_username` and `displayName` cannot be filled differently by one path
than another. A machine token's principal is the app, so its username is the app
name; a profile-less token omits the claims rather than emitting them empty.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 20:18:17 -07:00
hanzo-dev 96c95792d0 feature: SCIM is core, not a plugin — say so, and say what the seam owes
The seam doc advertised hanzoiam/scim as one of the modules that plug in here.
There is no such plugin any more: SCIM 2.0 is served by internal/scim at
/v1/iam/scim/v2, wired in routes.Route, behind the Guard and org-scoped by
authz.Scope. hanzoiam/scim was a second implementation of the same bounded
context that nothing ever imported, and it has been retired. Pointing at it left
two documented ways to get SCIM when only one exists.

Names the criterion instead of leaving it to be guessed. What belongs OUT of the
core is provenance and only provenance: this tree is clean-room, so a
Casdoor-derived implementation stays in a hanzoiam/* module with its Apache-2.0
attribution. SAML and LDAP qualify; anything written fresh does not.

Corrects the reason the earlier draft gave for LDAP. It claimed goldap is GPL-2.0
with no linking exception and that a separate module isolates that copyleft. The
pinned goldap (v0.0.0-20240304151906) is MIT, as are hanzoai/ldapserver and its
upstream — and a separate module would not isolate copyleft anyway, since Go
links statically.

Records the two things the seam does not give a module, because both are load
bearing and neither was written down: the Guard is mounted on
routes.guardedPrefixes rather than on the app, so a module that registers outside
them is unauthenticated (that is exactly how the retired SCIM plugin, mounted at
/scim/*, would have shipped an open user-CRUD surface); and Route is really
"activate" — hanzoiam/ldap takes the app as `_` and binds TCP listeners. Rename
it Start when a composing binary first exists.

Doc-only. go test ./... -race green, unchanged from the v1.33.32 baseline.
2026-07-28 23:37:21 -07:00
hanzo-dev 92c26c3781 module: retract the Casdoor lineage of iam/pkg/iam too
Retraction is per-module. The root go.mod withdrew the Casdoor versions of
github.com/hanzoai/iam at v1.32.0, but it cannot reach the submodule path
github.com/hanzoai/iam/pkg/iam — the in-process Embed + Mount entry points,
which only ever existed on the Casdoor side. That left one Casdoor-lineage
module still resolvable under the canonical prefix: `go get` it today and you
get Beego, xorm and iam v1.31.17 from a path that looks like v2.

Deleting the pkg/iam/* tags would not fix it. proxy.golang.org has all seven
cached (@v/list returns them, @latest resolves v1.18.6) and module versions are
immutable there, so deletion only splits the world — proxied resolvers keep
serving Casdoor code while direct-VCS resolvers 404. Retraction reaches both.

The range is self-inclusive, so every version of the path is withdrawn and the
tombstone carries no package: `go get .../pkg/iam` now fails loudly instead of
silently handing back the old lineage. The code is preserved byte-identically
at github.com/hanzoai/iam-v1/pkg/iam (same SHAs, all 7 tags).

The lineage guard becomes table-driven rather than growing a second copy — a
new published module path is a new row, not a new test function.

.gitignore listed `iam` unanchored, which matches every path component named
iam at any depth and was silently hiding pkg/iam/ from git.
2026-07-28 23:36:05 -07:00
hanzo-dev 22b1184af8 scim: one SCIM server, and the attributes an IdP actually needs
hanzoiam/scim was a SECOND SCIM 2.0 server over the same identity store —
Casdoor-derived, mounted at /scim/*, and by its own package doc carrying no
authorization ("the SCIM package itself carries no auth, so none is added
here"). Its GetAll read GetGlobalUsers unscoped and every item verb resolved by
id alone, so composing it would have added a cross-tenant user CRUD surface
beside this one. It had no consumers and no tags. This is the surviving way; the
other repo goes.

What it genuinely had that this did not, ported here with the scoping intact:

  externalId now round-trips. It is the provisioning client's OWN key for the
  record — schema.User declares the column and this surface accepted the
  attribute and dropped it, so an IdP could not correlate what it wrote and
  every sync looked like a new user.

  profileUrl and addresses map to Homepage / Location+Region+CountryCode.
  A single address is persisted, so a multi-valued write collapses to the
  primary — the rule emails and phones already follow.

  Discovery: /Schemas, /Schemas/{id}, /ResourceTypes, /ResourceTypes/{name}
  (RFC 7644 §4), beside the /ServiceProviderConfig already served. A connector
  reads these before it will configure.

userType is deliberately NOT writable, and that is the reason this port is not
a straight copy. schema.User.Type is the IDENTITY-CLASS discriminator, not a
profile label: Type == "service-account" is what serviceaccounts.is() tests
before handing out or rotating a pk-/sk- credential, and the class
oidc/provision.go mints a tenant's default credential as. Honouring a
client-supplied userType would let anyone who can provision a user reach an
identity class IAM otherwise hands out only through its own gated route. It is
advertised mutability:readOnly (RFC 7643 §7 — ignored on write) and projected on
read. TestRed_userType_cannotMintServiceAccount pins create, PUT and PATCH.

The tenant still comes from authz.Scope and nowhere else: the standard
enterprise extension's free-text `organization` (RFC 7643 §4.3) is not honoured
as an owner, which is what the deleted module did.

The schema document is not a hand-kept second copy of the wire shape —
TestSchema_matchesWireStruct reflects over scimUser and fails the gate if the
two diverge in either direction, top level and sub-attributes.

Gate: go test ./... -race -count=1 green, 0 failures, same as the baseline.
2026-07-28 22:53:04 -07:00
hanzo-dev 3c63ee2889 module: one lineage per path — retract the Casdoor versions of hanzoai/iam
github.com/hanzoai/iam published two implementations. Every tag below v1.32.0
carried the Casdoor-derived tree (Beego/xorm, controllers/); v1.32.0 and above
carry this one (zip/orm, internal/). Same import path, no signal, so
`go get github.com/hanzoai/iam@v1.31.28` swapped lineage and still compiled.

Deleting those tags does not fix it. proxy.golang.org caches module versions
immutably and already serves 506 of them — measured: v1.31.28.zip is 5.8 MB of
controllers/ and iamserver/beego.go under this exact path. Deletion would only
change resolution for GOPRIVATE/direct resolvers (this fleet), while breaking
rebuilds of tags that still pin those versions, e.g. deployed visor v1.108.12.

retract reaches every resolver, proxied or direct. Measured against a file
proxy: the Casdoor versions vanish from `go list -m -versions`, a pinned one
reports "(retracted)", an explicit get warns with the rationale, and
`go get @latest` upgrades v1.31.28 => the current release.

The versions themselves stay reachable, byte-identical, at
github.com/hanzoai/iam-v1 (all 178 tags verified same SHAs).
2026-07-28 22:52:46 -07:00
hanzo-dev c666bb1260 ci(image): gate the one builder on make test, and let the binary name itself
Two build systems were pushing ghcr.io/hanzoai/iam. The Casdoor-lineage GitHub
Actions builders are being deleted from all 133 branches that carried one; this
is what the surviving builder needs so that deletion is a strict improvement
rather than a trade.

GATE. docker-deploy.yml declared `docker: needs: [go-tests, go-build,
frontend-build]` — its image push was gated on tests. This file had no gate at
all, so removing the other lineage would have left ONE builder that ships
whatever compiles. That is worse than the duplication being fixed. `build` now
needs `test`, which runs `make test` — the repo's single declared gate
(`go test ./... -race -count=1`), named rather than inlined so a human and CI
run the identical command. Private-module fetches use the same GH_PAT the image
build already mounts as GIT_AUTH_TOKEN: one credential, two consumers.

VERSION. The Dockerfile has carried `-X main.version=${VERSION}` all along, but
nothing ever overrode its `ARG VERSION=dev`, so `/iam version` on the live pod
printed `iam dev` and the running binary could not name its own lineage — the
one question worth asking while two lineages shared an image name. The build-arg
now carries the same sha-<7> string used as the image tag, so `iam sha-d2aa268`
names the exact artifact with no second identifier free to drift.

Verified: `make test` green on eb83277c before the change (unchanged by it);
`-X main.version` proven end to end — `iam dev` without the arg, `iam sha-eb83277`
with it.
2026-07-28 22:49:25 -07:00
hanzo-dev eb83277c7d test: one gate, and a ceiling that was never chosen
This repo had no test target at all, so "run the tests" meant whatever each
person typed. `make test` is now the one command, and it is not a bare
`go test ./...`: that reuses cached PASS results, so a stale build reports green
for code you just changed, and it runs without the race detector, which is where
this repo's store and session defects surface. -count=1 defeats the cache.

Adding -race exposed the reason nobody had: 8 failures, all `i/o timeout`, all on
argon2id paths (signup, onboard, registry token, SCIM create), in a different
package each run. fiber's App.Test defaults to TestConfig{Timeout: time.Second}.
The detector costs roughly an order of magnitude, `go test ./...` runs packages
in parallel, and one second is simply not a valid budget for a deliberately
expensive KDF under that load. Each suite had inherited the default separately —
23 call sites, 19 files, none of them having chosen it.

That is the worst failure mode a gate can have. It is red without a defect, so it
teaches everyone to re-run until green, and a real regression hiding in the noise
gets re-run away with it.

internal/testhttp states the ceiling once — generous enough that only a genuine
hang trips it, bounded so a deadlock still fails rather than blocking forever —
and every suite drives the router through it. The KDF cost is untouched: it is
the security property, and lowering it for tests would be testing something else.

Verified: `make test` green end to end, and 0 remaining direct Fiber().Test calls
so no suite can drift back to its own timeout.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 18:50:06 -07:00
hanzo-dev 7bf4b04dd7 security(authz): an org-scoped request is honoured or refused, never silently reinterpreted
authz.Scope discarded the owner it was given. For any non-super it returned
p.Org, whatever had been asked for. Reproduced twice against production with the
hanzo-console credential (home org hanzo):

  GET /v1/iam/get-users?owner=hanzo             200 ok  262 rows  owner=hanzo
  GET /v1/iam/get-users?owner=lux               200 ok  262 rows  owner=hanzo
  GET /v1/iam/get-users?owner=zoo               200 ok  262 rows  owner=hanzo
  GET /v1/iam/get-users?owner=nonexistent-xyz   200 ok  262 rows  owner=hanzo

Nothing in the status code, the `status` field, the msg or the count said the
filter had been dropped, so a fabricated org was indistinguishable from a real
one and from your own. No rows escaped IAM — the pin held — so this was not a
confidentiality breach here. It was MISATTRIBUTION, which is worse in one
specific way: you believe you hold tenant B while holding tenant A. An operator
asked for lux, received 262 hanzo accounts with every surface signal reading
success, and was one filter-and-delete from purging the wrong tenant.

Downstream it WAS a leak. cloud/iam_edge.go validates ?owner= against the
calling tenant and then forwards it under ONE confidential client, so every
tenant's team page asked for its own org and was served the edge credential's
org. A pin that lies composes into a breach; a refusal cannot.

THE RULE, in the one place it lives (authz.Scope, 17 call sites resolve here):
a SuperAdmin is bound to the owner it names; everyone else is bound to its own
org and may say so — its own org, or none, is honoured; any other org is
refused. An empty p.Org is refused too: "" resolved to "no filter", i.e. every
tenant's rows, the branch TestListRoutesNeverLeakAnotherTenant exists to shut.

NOT AN EXISTENCE ORACLE, by construction rather than by care: the decision is
taken from the verified principal alone and never touches the store, so lux
(real), built-in (reserved) and nonexistent-org-xyz (invented) are the same
comparison and the same bytes; the message names the CREDENTIAL's org, never the
requested one. Same collapse cloud's per-org KMS store makes — every spelling you
may not have routes to ONE existence-independent answer. It differs only in
which answer: KMS reads the org from the token, so absence is its only
observable and it answers 404; here the org is a stated parameter, so there is
an authorization decision to report, and reporting it is the point.

get-users and get-organization now agree on the only question carrying a secret:
for every principal without a cross-tenant grant both refuse a foreign org
existence-independently. For a CapOrgAdmin holder — the brand consoles, which
create customer orgs and read Organization.Founder to resume a partial one — org
existence is not a secret, and a grant HONOURS the org it names, returning that
org's row correctly attributed. What can no longer happen anywhere: being handed
org A's rows in answer to a request that named org B.

Four surfaces were silently rewriting, three of them worse than the reported one:

  get-organization-projects/-workspaces  ?organization=lux -> hanzo's projects,
    reachable by any ordinary org admin, no client credential needed
  SCIM GET /Users/lux/alice -> 200 carrying HANZO/alice, a different human under
    the requested identity's URL
  SCIM POST /Users naming owner=lux -> 201 Created inside hanzo, a provisioning
    call that named one tenant landing the account in another

A REWRITE IS NOT A SAFE ANSWER, ONLY AN UNSAMPLED ONE. read_scope_test.go proved
foreign-exists and foreign-missing were both 404 and called the oracle closed. It
was — the re-pin turned /Users/orgb/bob into a lookup of hanzo/bob, absent. Seed
a hanzo/bob, a name every tenant has, and the same request returns 200 carrying
hanzo's bob under orgb's URL; PATCH active:false then deactivates a hanzo
employee. That case is now pinned, and the old 404 message ("User hanzo/bob not
found") disclosed the redirect in its own text.

Refusals leave through authz.Deny — the same refuse() shaping the Guard uses, so
one refusal looks the same whether raised before the handler or inside it.
httpx.Err would have sent HTTP 200 carrying {"status":"error"}, which is how a
refusal gets logged as a success.

BREAKING for a caller that passes a foreign owner today and silently receives its
own data. Audited across the fleet; each is wrong today and now fails loudly
rather than quietly: cloud's iam_edge (mounted only when IAM is not in-process),
the casdoor-derived SDKs that bind ?owner= to a config org rather than the
credential, and console's get-organization (already failing).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 18:48:14 -07:00
zooqueenandhanzo-dev 84b90f80da license: drop the third-party vendor name from the clean-room disclaimer
Hanzo IAM is original work. The disclaimer listed a vendor name among the
licenses this source does not contain — naming a codebase that was never
part of it. The retired fork was iam-v1, not this repo. The clean-room
statement itself is unchanged.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 17:10:43 -07:00
zooqueenandhanzo-dev 8e33b4b1e7 rip(casdoor): 153 of 154 gone — the last one is a legal assertion
Every remaining reference in this repo renamed. The vendor name was describing
OUR behaviour in 64 files — "Casdoor-derived", "the Casdoor shape", "Casdoor verb
spelling", "Casdoor error shape" — and that framing is what sends the next reader
to upstream documentation to answer a question about this codebase. It cost me
hours today debugging a 403 against the wrong authorization model.

The compat surface is still described accurately, just natively: "legacy verb
aliases", "the legacy envelope", "legacy v1 database". Those aliases are being
removed, and until they are, they need to be documented as what they are rather
than as who wrote them.

ONE reference is deliberately kept, in LICENSE:

  This is a clean-room implementation. It contains no Casdoor, Apache-2.0, or
  other third-party licensed source code.

That is a legal provenance assertion, and its whole function is to name what this
code does NOT contain. Removing the name would weaken the claim to "contains no
third-party code", which is both vaguer and less defensible. A clean-room
statement has to name the thing it is clean of.

Build green, 25 packages pass.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 15:32:18 -07:00
zooqueenandhanzo-dev d2aa268f94 fix(authz): one policy, not one per spelling — and bind the whole URL
zip v1.10.0 -> v1.17.7. v1.17.1 taught a typed op to read the whole URL
("a typed op reads the whole URL, not half of it"): until then a typed GET bound
NOTHING — no query, no path params — so every typed GET handler ran on a zero
input. That is the seam behind the tenant leaks fixed in the previous two
commits, and it is why seven legacy verbs had no working native replacement:
GET /v1/iam/applications?owner=x answered 400 "field Owner is required" because
the owner never arrived.

Bumping it exposed a second, older split. entityNoun pluralised a path segment
ONLY after stripping a legacy verb prefix, so:

  /v1/iam/get-application  -> "applications"  matches the app self-read clause
  /v1/iam/application      -> "application"   matches nothing -> reserved-owner -> 403

A relying party could read its own application row over the legacy verb and was
REFUSED over the native route — one policy giving two answers, decided by
spelling, which is precisely what kept cloud on the compat surface. Every clause
is written in the plural (applications, certs, projects, users, organizations,
keys), so the fold now applies to every segment; the only segments it newly
changes are the singular natives, and each folds onto the entity it already is.

Two test expectations updated because they encoded the bugs, not the contract:

- selfread_compat asserted 400 for the noun-surface LIST, with the comment
  "reaches the handler = authorized". It reads 403 now, and 403 is right:
  ApplicationQuery carries only Owner, so ?name= is ignored and the request asks
  to enumerate every application under the reserved admin org. The 400 was a
  shape complaint landing before authz could refuse. The case that actually
  mirrors the compat verb — the SINGLE application by natural key — is now
  asserted at 200, and fails at 403 if the fold is reverted.
- entityOf("/v1/iam/application") asserted "application". That singular WAS the
  split.

27 packages pass.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 14:34:55 -07:00
zooqueenandhanzo-dev daa08dba2a fix(authz): tokens and webauthn credentials leaked across tenants too
Same confused deputy as the previous commit, found by extending its test rather
than by reading more code — which is the point of writing the test first. Both
listers filtered on in.Owner, and a typed GET binds nothing, so both took the
"empty owner lists everything" branch on every REST call:

  GET /v1/iam/tokens?owner=hanzo               -> orgb's tokens
  GET /v1/iam/webauthn-credentials?owner=hanzo -> orgb's credentials

These are the most sensitive rows on the surface, so they are worth being precise
about: the leak is row METADATA (owner, name, timestamps, the credential
inventory) — not token secrets or credential material, which the schema does not
put on the list path. Still a full cross-tenant inventory of who holds what.

Both now resolve the owner through authz.Scope, matching certs and the six fixed
previously. The audit is complete for this class: every lister with the
"empty owner => no filter" shape is either principal-scoped or fails closed
("owner is required" — keys, permissions), and applications/users carry
validate:"required" so they refuse rather than widen.

organizations keeps the shape deliberately and is now covered by the test as a
REFUSAL case: it is the tenant registry, the one documented exception to the
reserved-owner gate, and the route is SuperAdmin-only. If a change ever opens it
to tenants, that case fails instead of quietly listing.

Falsified per route: revert either handler and the test names the leaked row.
25 packages pass.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 14:10:40 -07:00
zooqueenandhanzo-dev 1ecb6954f8 fix(authz): list routes returned every tenant's rows to any org admin
A confused deputy, invisible from either half alone. The Guard authorizes on the
query string — asking for a foreign org is correctly refused — and then the
handler filtered on `in.Owner` rather than on the principal. A zip typed GET
binds NOTHING from the request (a body is read only for non-GET), so `in.Owner`
arrived EMPTY on every REST call, took the "empty owner lists everything" branch,
and returned every tenant's rows.

The shape, as hanzo/boss (org admin of hanzo only):

  GET /v1/iam/roles?owner=orgb   -> 403   the guard works
  GET /v1/iam/roles?owner=hanzo  -> 200   contains orgb's roles

Name someone else's org and you are refused; name your OWN and the whole table
opens. Status codes look correct throughout, which is why this survived: only the
body shows it.

Six handlers: roles, projects, workspaces, invitations, audit-logs, providers.
certs was already correct — it resolved the owner through authz.Scope, and its
comment says exactly why ("a query parameter can never widen a listing beyond the
bearer's authority"). All six now do the same, so the owner comes from the
authenticated principal and a SuperAdmin still reads the owner it asks for.

Scope of exposure: cross-tenant METADATA — role membership, project and workspace
inventory, invitation rows, another tenant's audit trail. Not credentials;
masking held throughout (privateKey empty, masterPassword "***", clientSecret
empty on every path checked).

TestListRoutesNeverLeakAnotherTenant pins it through the real router with two
seeded tenants, asserting on the BODY because the status code never moved. Revert
any one handler and it fails naming the leaked row. A companion test asserts the
refusal did not simply migrate into a silently-empty listing.

Found while mapping the removal of the legacy compat surface: the native list
routes were proposed as its replacement, and they were the leaking ones.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 13:53:01 -07:00
zeekayandhanzo-dev 27e7d299f8 security(wallet): a reserved org is not a place a stranger's wallet can mint an account
Wallet login was the one public account-creation front door that never
consulted store.IsReservedOrg. signup, onboarding, federated provisioning
and token exchange all do; verify.go did not, so the predicate that is
documented as "never reached by a public signup or an external login" was
reachable by exactly that.

The org is not caller-chosen here (provision takes in.App.Organization),
so this is not a cross-tenant hole — it is an escalation one. authz
derives Super from owner == "admin", so an application row owned by a
reserved org with EnableSignUp set turned an unauthenticated,
wallet-signed POST into a SuperAdmin mint.

Folded into the existing EnableSignUp case rather than added as a new
one, so the refusal stays byte-identical to "sign up is disabled" and a
prober cannot tell which condition fired.

TestReservedOrgNeverProvisions covers all three reserved orgs and fails
without the guard; TestOrdinaryOrgStillProvisions pins that an ordinary
tenant still signs up, so the hole cannot be "closed" by breaking wallet
sign-up outright.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 12:16:58 -07:00
hanzo-dev faf2d2148f security(signup): org choice names your own org, it does not open every tenant
orgChoiceMode was read as "the caller may choose their organization", and the
tenant gate spells that as `app.OrgChoiceMode == ""` — so any non-empty mode
satisfied it. Past that gate the org lookup has two arms and only one of them was
guarded: a name NOBODY holds falls into the self-serve branch and is checked
(reserved names, '/', an email, the length and leading-digit rules), while a name
that ALREADY EXISTS falls straight through to the create-the-user tail with no
check at all. The whole of orgChoiceMode's meaning for existing orgs was therefore
"any org in the registry", and the registry is one multi-brand store.

Reproduced against production before writing this. An unauthenticated POST to
hanzo.id/v1/iam/signup naming application "hanzo-console" — a hanzo app, the one
app that is both orgChoiceMode=create and EnableSignUp — and organization "lux"
was answered {"status":"ok"} and created a user with owner "lux" and
registerSource "lux/conf". Nothing about hanzo-console is supposed to reach Lux's
tenant, and the same request shape reached every brand and every customer org.

The missing arm is the else: an org that already exists must be the app's own, or
the app must be isShared, which is the declaration that it is multi-tenant on
purpose. Creation is untouched, so a founder still mints their own org, and a
stranger still lands in the app's own tenant — the two destinations org choice was
actually for. The refusal reuses the sentence the wrong-tenant and reserved-org
refusals already use, so it adds no authority oracle to distinguish them.

What this does concede: a name nobody holds still succeeds, so signup can still be
asked whether an org exists by trying to mint it. That is inherent to self-serve
creation rather than introduced here, it leaves an org row behind every time it is
asked, and it is a far smaller thing than membership in a tenant that exists.

The test reproduces the production request and fails without the guard, with the
same owner "lux" the live instance returned; three sibling cases pin the paths
that must keep working, including a shared app still admitting an existing tenant.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 09:42:40 -07:00
hanzo-dev 4890a4301b ci: delete the GitHub puller — one direction, decided elsewhere
This workflow polled github.com every 10 minutes and fast-forwarded the forge
from it. It was written when which side was canonical was still open; it is not
open now, so a cron that reconciles two mains is a second answer to a settled
question. Removing it leaves exactly one way for code to move.

It was already inert by its own admission — the header notes it does nothing
while the GitHub repo is a pull mirror, because the forge overwrites main on its
own timer and rejects the push.
2026-07-28 08:57:28 -07:00
zeekayandhanzo-dev 822e0c6af9 oidc: billing_account must be "org:<slug>", not a bare slug
I shipped this claim minting the bare org name. account.Parse Cuts on ":" and
returns a ZERO Account when there is no kind prefix, and Payer then ignores the
claim and falls back to its shape rule — so the value did nothing at all, and
did it silently. The claim was minted, signed, stamped into
X-Billing-Account-Id by cloud's sanitizer, carried through principal.Payer into
the ai spend-gate, and dropped at the very last step. An admin kept paying from
their personal wallet while the org pool sat unused, and nothing anywhere
errored.

Every other layer was already correct: IAM signs it, cloud parses and stamps it
(auth_identity.idClaims.mintedBillingAccount), principal.BillingAccount reads
the header, ai's JWT path passes it as Credential.Account. Only the VALUE was
wrong, and it was wrong in the one place a wrong value cannot announce itself.

Built with account.Org(owner).String() rather than concatenating "org:" so it
stays the exact inverse of Parse if that encoding ever changes. Verified against
the real package: Org("hanzo").String() == "org:hanzo", Parse round-trips it to
subject "hanzo", and Parse("hanzo") is Zero.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 01:22:35 -07:00
zooqueenandhanzo-dev 57ae7b3092 LLM.md: the keys entity had no entry, and its two invariants are not guessable
The entity must be spelled plural on every route or authz.entityOf names two
entities for one thing and a capability keyed on it dies on half the surface;
and a publishable key resolves at a different door than a secret one, with a
narrower capability, because it discloses an org and never a principal.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 01:12:36 -07:00
zooqueenandhanzo-dev 4347dbb144 keys: nothing minted a publishable key, and a key list handed out secrets
A publishable key was a model with no producer. IAM owned the whole
apparatus — schema.KeyScopePublish, store.PublishableKeyByAccessKey,
compat resolve-key — and NO endpoint anywhere minted one, so every
surface configured its own ingest credential and errors stayed on a
separate DSN. The type of a key is a FIELD on the one mint, not a
second endpoint: `?type=publishable` yields the pk-, `?type=secret`
(the default) yields the sk-, and an unknown type is refused rather
than quietly handed a session-equivalent secret.

Two rows, one per scope, so a user holds both at once: rotating the key
in a browser bundle must not sign the holder out of their own API.
NameFor(scope) is the one mapping from access class to row.

The key LIST disclosed every sk- in the org verbatim — there was no
Key.Mask. That made read AUTHORIZATION stand in for redaction, which
is why the only key list in the system was reachable by SuperAdmin
alone: the surface a user calls to see their own keys had no truthful
read at all and reported "no key" immediately after a successful mint.
Mask blanks the confidential half and keeps the publishable one (the
holder needs it; it authenticates nobody), and with the read safe on
its own, capFor("keys") grants it to the authority that already mints,
rotates and revokes the same credential.

One noun for the entity. keys served its list at /v1/iam/keys and every
other op at /v1/iam/key, and authz.entityOf reads the first segment as
the entity — so the list authorized on "keys" and every write on "key",
and any capability keyed on it was dead on one of the two halves. Same
defect entityNoun was written to fix for the verb spellings, reached
from the other side. Plural everywhere now, matching users.Route.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 00:37:27 -07:00
hanzo-dev fffa95d01e authz: an org's platform-kms identity may read its own projects
Cloud's platform resolves a tenant's projects from a second, embedded copy of
this store — the split-brain where a project created at /v1/iam is invisible
to the PaaS and vice versa. Resolving them HERE instead needs a machine read,
and a confidential client's authority is its capability allowlist, which maps
no projects entity at all.

The grant mirrors the self-read blocks above it — narrow by construction,
four ways at once: only a READ, only projects, only the caller's OWN org, and
only the identity the "<org>-platform-kms" contract names (the same string
cloud's SanitizeIdentity recognises in order to DENY that principal
SuperAdmin). The contract is the grant, stated once; no env allowlist to
drift. Every wall carries its own negative in the test.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 00:02:17 -07:00
zeekayandhanzo-dev b227a026ae signup: an email is never an organization
image / build (push) Successful in 2m29s
hanzo-console signs up with orgChoiceMode=create and the form defaults the org
field to the address the person just typed, so every signup mints an org: 56 of
the 124 organizations on the live instance are email-shaped
(qa_probe_x@hanzo-qa.dev, …). Nearly half the tenant registry is people, and "is
this a company?" has no answer.

It is also the wrong money shape. account.Payer resolves a member of the SIGNUP
org to a PERSONAL wallet — Person(hanzo, name) — precisely so an individual has a
balance without their own tenant. Minting them an org sends them down the Org()
pool branch instead, which makes that special-case dead code and costs an org row
plus a membership row on every signup, at signup rate.

This refuses the SHAPE, not the intent: a real company name still creates a real
org, and someone who typed their address lands in the signup org with a personal
wallet, which is where they belonged. Three tiers stay clean — employees in
admin, individuals in the signup org on personal wallets, tenant members paying
their org's pool.

Existing email-shaped orgs are untouched: drift to reap separately, and the few
real ones need their users moved to hanzo/<name> first because that changes the
wallet key.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 23:29:47 -07:00
zeekayandhanzo-dev f32bc134a4 docs(LLM): signup makes a stranger a member of the tenant that owns the app
Found by standing up three real customer orgs through the real front door
(signup -> login -> onboard) and then attacking the boundary. Before the onboard
step — the state EVERY new account passes through — a brand-new anonymous
account listed Hanzo's 121 private git repos and its CRM contacts, and created
then deleted a project inside org `hanzo`.

Nothing here is forged: the `orgs` claim is signed and cloud reads it correctly.
The claim itself is wrong, because MemberOrgRefs synthesizes the HOME membership
from user.Owner, and signup sets Owner to the APPLICATION's org. Storage
placement is being read as tenancy.

Recorded rather than patched: this repo already refuses exactly this on the
onboard path, so the fix is to make signup obey the same rule — but every
version of it either stops customer signup or changes the meaning of the `orgs`
claim for existing users, and Seed being new-only means the config route does not
even take effect on a live app row. That is an owner's call, not a 6am guess.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 23:07:11 -07:00
zeekayandhanzo-dev eb06f140bc security(signup): enforce a password floor no organization can opt out of
An organization's PasswordOptions was the WHOLE password policy, and an empty
option set meant "any non-empty password". store.CreateOrganization mints a
self-serve org with no options, so that empty set was reachable by an
unauthenticated caller: POST /v1/iam/signup into a self-serve org was accepted
with the single byte "a", and the resulting account then logged in.

The floor (min length, and a refusal of one repeated rune, which is the length
rule's only trivial evasion) now applies in passwordPolicyError before any org
option is consulted. Options remain ADDITIVE strictness on top of an invariant
rather than being the invariant, so there is one source of truth and an org can
only ever make the policy stricter. The value is the same AtLeast8 every seeded
organization already carries, lifted out of configuration into code so it cannot
be configured away.

Length is now counted in runes once, for the floor and for AtLeast6/AtLeast8
alike — a byte count let a few multi-byte characters satisfy an "8 characters"
rule, and let the floor and AtLeast8 disagree about what "8 characters" means.

No effect on the funnel: every seeded org already declares AtLeast8, so a
legitimate signup is unchanged. Tests cover the exact production attack
(self-serve org + password "a"), the 8-char evasion "aaaaaaaa", and that a
strong password into an optionless org still succeeds.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 20:45:16 -07:00
zeekayandhanzo-dev 0648788524 go.mod: demote unimported hanzoai/sqlcipher, hanzoai/sqlite to indirect
Neither module is imported by any Go source in this module; they are only
reachable transitively. Declaring them direct overstated the dependency
surface. go mod tidy moves both to the indirect block with no go.sum churn
and no version changes.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 20:45:16 -07:00
zandhanzo-dev 0ef5b609a9 fix(docker): golang 1.26.4 -> 1.26.5, the version go.mod already requires
go.mod declares `go 1.26.5` while the builder pinned golang:1.26.4. The golang
images set GOTOOLCHAIN=local, so the base can never fetch a newer toolchain to
satisfy the directive and the build dies immediately:

    go: go.mod requires go >= 1.26.5 (running go 1.26.4; GOTOOLCHAIN=local)

A release build that fails in under a second did not fail to compile — it
failed to acquire a toolchain.

Found by sweeping every hanzoai repo with a go.mod against its Dockerfile base:
25 declare go >= 1.26.5, and 16 pin an explicit 1.26.4 that cannot satisfy it.
Repos on a floating `1.26` / `1.26-alpine` are already fine — both now resolve
to 1.26.5 (verified by digest: golang:1.26-alpine and golang:1.26.5-alpine are
the same sha256:0178a641).

Patch bump only, image variant preserved, and the target tag verified to exist
before the change.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 19:11:10 -07:00
zeekayandhanzo-dev 31f36d236b fix(oidc): accept any loopback port on a redirect_uri (RFC 8252 §7.3)
A native client binds an ephemeral port and cannot know its redirect_uri at
registration time, so the provisioner registers the portless
http://127.0.0.1/callback for a `cli` app. IsRedirectUriValid compared by exact
string equality, so the http://127.0.0.1:51234/callback the CLI actually sends
never matched:

  GET /v1/iam/oauth/authorize?client_id=hanzo-cli
      &redirect_uri=http%3A%2F%2F127.0.0.1%3A51234%2Fcallback&...
  400 authorization error: invalid redirect_uri

The browser therefore never came back and the CLI blocked on accept() forever —
the "loopback login hangs" symptom was this one comparison. The registration
half already assumed RFC 8252 semantics; the validation half never implemented
them.

Match now ignores the port for loopback IP LITERALS only (127.0.0.1, ::1) and
still compares scheme, host, path, query and fragment exactly. localhost is
deliberately excluded — it resolves through DNS, so it is not provably local
(RFC 8252 §8.3). Nothing off-loopback is widened.

Redemption is untouched: token.go still binds the code to the exact redirect_uri
it was minted with, so port-agnostic REGISTRATION never becomes port-agnostic
REDEMPTION. PKCE is still required for public clients.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 18:29:16 -07:00
hanzo-dev 0ea62feae5 chore(go): bump go directive to 1.26.5
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 16:18:09 -07:00
zeekayandhanzo-dev 6088330e42 docker: stop building a command that no longer exists
image / build (push) Successful in 1m46s
Every IAM image build has failed since 144db2add with

  stat /src/cmd/migrate-v1: directory not found

That commit ("iam: one IAM — drop v1 and the iam2 name") deleted
cmd/migrate-v1. The Dockerfile still built it and still COPY'd it into the
runtime stage, and it was the only consumer left referencing it — so the deletion
was complete everywhere except the one place that would notice.

The failure is silent from the outside: the run goes red, but nothing pins the
deployed tag to a green build, so the fleet simply kept serving the last image
that happened to succeed while every commit after it built nothing.

One binary now, and the comment says why the second is gone rather than
describing a thing that is not there.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 14:18:45 -07:00
zeekayandhanzo-dev 2f7f8237ea oidc: mint billing_account so an admin spends the org pool, not their own wallet
(Also carries the in-flight "test: give Sign its scope back" fix, which the
rebase folded in — that commit corrected a harness call where "openid" sat in the
username slot and silently became a username. Its comment is kept, updated for
the parameter this change adds.)

An org admin was billed against their own empty personal wallet while the
company's credit sat in the org pool, unreachable.

account.Payer honours a signed `billing_account` above every other signal, and
absent one falls back to a shape rule that makes the SIGNUP ORG special: a member
of any OTHER org spends the org pool, but a member of "hanzo" gets a PERSONAL
wallet. That asymmetry is deliberate and load-bearing — every self-signup lands
in hanzo, and keying them on the pool once let a brand-new $0 account read
Hanzo's balance and sail through the gate.

The cost was that a real admin is indistinguishable from a random signup. IAM
never minted the claim at all, so the branch that exists to resolve exactly this
never fired for anyone.

Now owners and admins carry billing_account = their org and spend the pool; a
plain member carries nothing and still falls through to a personal wallet, so the
free-rider hole stays shut. Only the caller's HOME org is considered — a token
minted for one tenant must never name another's ledger, however privileged its
holder is elsewhere. Eight cases pinned, including admin-of-another-org.

Empty is meaningful rather than missing: it means "no explicit entitlement", and
every consumer keeps the behaviour it already had.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 14:01:46 -07:00
hanzo-dev 5bdf104c38 fix(feature): GetUserByID resolves the subject Id, not the orm storage id
The seam handed out one id and looked up another, so the id AddUser
assigns never resolved: a feature module (hanzoiam/scim) reads a user's
Id back off the row, returns it as the SCIM resource id, and the client
sends it on the next request — where GetUserByID went to orm.Get, which
keys on the orm STORAGE id. Different value, every time:

    AddUser        -> users.Create mints u.Id = uuid.NewString()
    GetUser        -> row.Id  = "51d22ea6-dd9b-…"   (the OIDC `sub`)
    row storage id = "17851797297058435770001"      (a surrogate)
    GetUserByID(row.Id) -> orm: entity not found

So every SCIM GET/PATCH/DELETE by id 404s — and an unknown id came back
as a raw orm.ErrNotFound rather than (nil, nil), which a caller surfaces
as a 500 instead of a 404.

Route it through store.GetUserById, the ONE subject resolver, like the
other eight methods here already route through store/users. That is also
the only shape that can work: the storage id is "owner/name" for a
migrated row — mutable, and its slash cannot survive a /Users/{id} path
segment — and a surrogate decimal for a v2-native one.

The seam interface now states the contract so an implementor cannot
guess wrong, and internal/featurestore gets its first test: the exact
add -> re-read -> resolve sequence a module performs, the id surviving
an update (a body-supplied Id is ignored), and the (nil, nil) miss. All
three FAIL on the previous implementation.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 12:28:29 -07:00
hanzo-dev fbe36d71ae feat(store): export the read-only customer roster — GetMailableUsers
A product announcement needs to know WHO the customers are, and the answer
already exists: every customer is an IAM user in its org. Export that as an
in-process read beside the project store so an embedder (cloud marketing)
resolves an audience through IAM instead of keeping a contact list of its own.

Read-only by construction — a sender must never mutate an identity — and rows
come back through schema.User.Mask(), so no digest, seed, or bearer material
crosses the embed seam. An empty org is refused rather than treated as the
unscoped admin view: on a mailing path that is a cross-tenant send, not a
listing. The reachability predicates run in Go because the flags are omitempty
and a false value is absent from the stored document; only the owner filter,
the tenancy key, is pushed into the query.
2026-07-27 10:45:12 -07:00
hanzo-dev 0a31146909 ci: drop the Gitea mirror-sync nudge
Superseded: the Hanzo GitHub App pushes a webhook, so the forge tracks GitHub
without a per-repo workflow. This file called git.hanzo.ai/api/v1/.../mirror-sync
— a Gitea API for a system we no longer drive — and would sit inert in every repo.

One mechanism, in one place, instead of ~350 copies of a cron.
2026-07-27 10:34:35 -07:00
hanzo-dev 542d4e9795 Merge remote-tracking branch 'origin/main' into HEAD
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 10:29:33 -07:00
hanzo-dev ce366b3e1a deps: zap-proto/zip v1.10.0 (+ hanzoai/orm v0.6.16)
zip v1.10.0 carries zap-proto/http v0.3.0, where wire headers are length-prefixed
pairs instead of JSON. That breaks against v1.9.x, so every ZAP service moves
together. orm v0.6.14 called zaphttp.NewTransport, which v0.3.0 removed, so it
moves in the same commit.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 10:29:32 -07:00
hanzo-dev 144db2add8 iam: one IAM — drop v1 and the "iam2" name
There is one identity service. The clean-room rewrite already replaced the Casdoor
fork in this repo — the tree has no object/, controllers/ or web/, LICENSE is
Hanzo proprietary, and no source file carries a Casdoor copyright — but the name
never followed. 65 files still said "iam2", the binary was /iam2, and the store was
iam2.db, which reads as though a second service exists somewhere.

Renamed throughout: command, app name, DB path, prose. Zero "iam2" left.

cmd/migrate-v1 is deleted with the v1 service it read from. It existed to copy a
legacy Casdoor SQLite store into the clean-room one; with v1 dropped there is no
source to migrate. MIGRATION.md goes with it — it described the migration as
upcoming work, and it was the last file carrying Casdoor's copyright line.

internal/cred stays. It resolves the password algorithm from the stored row and
verifies argon2id as well as bcrypt; every row written by the old service is
argon2id, and dropping that would lock those accounts out. That is credential
support, not v1 code.

The server doc comment claimed iam runs "ALONGSIDE the live Casdoor /v1/iam/*"
under a shadow prefix. That was the cutover plan and is no longer true; it now
describes the prefix as the caller's choice, normally canonical.

Builds clean, 26 test packages pass, gofmt clean.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 10:26:44 -07:00
hanzo-dev 9e7cc0c585 deps(iam): zip v1.9.0, http v0.2.2, orm v0.6.15
zip v1.8.4 renamed zaphttp.NewTransport(addr) to zaphttp.Dial(network, addr) and
turned RegisterTransport's second argument into a Transport{Serve,Dial} struct.
iam registers no transport of its own, but orm v0.6.8 does: db/zap.go called
zaphttp.NewTransport, so bumping zip alone left the whole module unbuildable —
"undefined: zaphttp.NewTransport" out of github.com/hanzoai/orm/db.

orm v0.6.15 is the version that made that move (zaphttp.Dial("tcp", cfg.Addr),
requiring http v0.2.2), so the alignment is the pair, not the one. dbx follows
orm to v1.17.2.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 09:47:03 -07:00
hanzo-dev 5886025367 fix(keys,users): a credential is not a field a caller can write
Three write-sites let the CALLER choose a credential. hk- resolves a user by exact
match on AccessKey and sk- resolves its owning key by exact match on AccessSecret,
so a request body carrying either plants a secret the sender already knows and can
then present AS that principal. Rotation belongs to mint; these were profile edits
that happened to be able to forge.

users.Create / users.Update — AccessKey, AccessSecret and AccessSecretHash are now
cleared on create and carried from the stored row on update, exactly as the password
digest already was. Update is a full-row write, so without this a user-admin could
overwrite any reachable user's credential with a chosen one. It is also what makes
the legacy hk- population MONOTONE: while a body could re-introduce the prefix at
will, no census of it could ever be a proof, and the retirement had no finish line.

keys.apply() — AccessKey and AccessSecret are no longer copied from the caller.
Harmless-looking today because the secret is stored verbatim, and a forgery the
moment it is stored as a digest: a chosen digest is not a chosen password, it is
someone else's identity.

keys.apply() also stops carrying Scope, which is the key's ACCESS CLASS and belongs
to mint. An update could flip a secret key to publish scope, which blanks its secret
and makes its pk- half org-resolvable at the ingest door — a privilege change wearing
the clothes of a rename. Scope is now settable at create and fixed thereafter.

Each is covered by a test that plants a credential and asserts it did not stick,
while a legitimately mutable field on the same request still applies — so the guard
cannot be satisfied by simply refusing the write.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 09:00:57 -07:00
hanzo-dev 7cff6c2839 fix(keys): a minted key authenticated nobody, and locked its holder out
mint-user-keys stamped the new sk- onto schema.User.AccessKey. Nothing resolves
that: store.UserByAccessKey's sk- branch reads schema.Key.AccessSecret via
userOwningKey. The write and the read never met, so every key minted this way
authenticated nobody — and because it overwrote the SAME field that holds a
user's working legacy hk-, regenerating a key silently revoked the holder with
no way back through the UI. It is latent only because nobody has pressed the
button since newAccessKey() switched to sk-: 33 hk- and ZERO sk- exist
fleet-wide, and there are zero schema.Key rows.

Mint now writes the row the resolver actually reads, through ONE exported
minter so the shape of a key is decided in one place. Keyed deterministically at
(owner, "cloud-api") so a re-mint ROTATES in place rather than leaving a second
live secret behind — a user holds one key, and revoking it revokes them. Revoke
clears both homes, so a holder still carrying an hk- is fully revoked by one
call rather than "revoked" being a lie for exactly the unmigrated population.

Fixed greenfield on purpose: with zero Key rows and zero live sk-, this costs
nothing today and costs a re-mint per holder for every day it waits.

The three tests that covered this asserted the WRITE LOCATION — that the key
landed on the user row — which is why they stayed green while the feature was
broken. They now assert the ROUND TRIP: mint, then resolve, and get the same
user back. Verified the new test fails against the old behaviour with exactly
"no schema.Key row resolves the minted secret", so it can catch the regression
it was written for.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 08:45:22 -07:00
zooqueenandhanzo-dev c99023d9c6 feat(bootstrap): declare isShared through the upsert, the door that keeps the secret
A brand application — hanzo-id, hanzo-chat, a brand console — serves every customer
of that brand, and self-service onboarding moves each founder OUT of the brand org
into the org they founded. So `user.Owner != app.Organization` is the STEADY STATE
for those apps, and the honest description of them is isShared: they really do serve
every organization. Nothing could say so until now; the flag existed on the schema
and no write path could set it.

That matters because the tenant gate is about to start reading it. The gate's premise
— an application belongs to one tenant — is simply false for a brand app, and turning
it on before the flags are declared refuses every self-service customer. Flags first,
enforcement after.

The upsert is the door because it does not destroy the credential. update-application
is a full REPLACE over a read that MASKS the client secret, so the natural admin
round-trip posts ClientSecret:"" and silently turns a confidential client public
(fixed in the preceding commit, but not yet in the running build). This endpoint
merges field by field and resolveSecret already preserves what is stored.

isShared is a *bool so that OMISSION PRESERVES. This is the operator's steady-state
reconcile and almost no caller mentions sharing; a plain bool would read as false on
every one of them and silently un-share the fleet — the same shape of accident as the
de-secret, and it would surface as a recurring lockout of every self-service customer.
Nil leaves it alone; only an explicit true or false moves it. A newly created app is
single-tenant unless it says otherwise.

Tests: omission preserves (red before the pointer — "an omitted isShared UN-SHARED
the app"), an explicit false still un-shares, a new app defaults closed, and flipping
the flag leaves the client secret byte-identical. Full suite 27/27.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 08:02:12 -07:00
zooqueenandhanzo-dev 79a727617f fix(wallet): the test harness lost a Sign argument and stopped compiling
Signer.Sign grew a `username` parameter between the id_token's preferred_username
work and this caller; the wallet harness still passed the old eight, so "openid"
bound to `username` and the arity was one short. The package has not built since —
`go test ./...` fails on main today, which means the wallet suite has been silently
absent from every run rather than passing.

Pass the empty username explicitly and let "openid" land on `scope` where it was
always meant to go. Test-only; no production path changes. Full suite: 27/27.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 08:02:12 -07:00
1484797532 fix(applications): a write that omits the client secret must not destroy it
update-application is a full REPLACE, and every read of an application MASKS the
client secret. So the only shape an admin UI or an operator can have — read the
record, change one field, write it back — posted ClientSecret:"" and silently
de-secreted the app. The token endpoint reads a stored empty secret as "public
client, demand no client auth", so one console save on an application page could
turn a confidential client public and weaken every flow it serves.

Measured on live IAM, not inferred: the SuperAdmin read of hanzo-console,
hanzo-app, hanzo-id and hanzo-cloud all return clientSecret "" — while a
token-endpoint probe (nonexistent username, so client auth is decided before any
user lookup) answers invalid_client for all four, proving every one of them DOES
hold a secret. Read says empty, reality says otherwise; the round-trip is a trap.

I hit it trying to flip enableSignUp on two apps and stopped rather than take the
outage.

An omitted secret now preserves what is stored. Same rule the operator upsert
already settled in resolveSecret ("existing app -> preserve what it has"), stated
here because this is the other door onto the same row. Rotation stays possible,
it just has to be DELIBERATE: send the new secret. Clearing one on purpose
(confidential -> public) is no longer expressible as an accident — it goes
through the upsert's explicit `public: true`, the one place that decision is named.

Tests: the masked round-trip preserves the credential (red before this change —
"the admin round-trip DE-SECRETED the app"), a deliberate rotation still lands,
and a genuinely public client is never handed a secret it never had.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 08:02:12 -07:00
hanzo-dev c9b212b2f4 ci: nudge git.hanzo.ai to pull on push
git.hanzo.ai mirrors this repo by PULL on a ~10-minute interval, and arcd runs
CI/CD there — so every push waited out that interval before anything built.
This asks Gitea to pull HEAD immediately.

Latency only: the repo already mirrors via the App webhook, so a missing
HANZO_GIT_TOKEN or a failed curl is non-fatal and never fails the push.
Idempotent (mirror-sync just pulls HEAD) and concurrency-coalesced.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 08:01:47 -07:00
hanzo-dev e8933d3c87 Merge origin/main
# Conflicts:
#	internal/store/apikey.go

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 23:46:55 -07:00
hanzo-dev bd796359a4 docs(apikey): hk- is legacy accept-only — the minter has emitted sk- for a while
UserByAccessKey documented hk- as "minted by issue-user-token OR by a service
account", and revokeUserKeysHandler called the thing it clears an hk- key. Neither
is true: newAccessKey() returns keys.Mint("sk", ""), so every user key minted
since the key seam was unified is sk-, stamped on the same User.AccessKey field.

That matters for the retirement. Reading the code today suggests hk- is a live
shape still being issued, so removing it looks like a breaking change to an active
credential; in fact the population is FIXED and can only shrink, and what remains
is a re-key of stored values, not a code cutover. The branch stays until those are
re-keyed — dropping it earlier rejects every credential still carrying the prefix.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 23:45:47 -07:00
hanzo-dev 5579244598 Merge the forge into GitHub — converge the two mains
Same drift as cloud: git.hanzo.ai held 156 commits GitHub did not, so the
GitHub-to-forge sync could never fast-forward. Merging the forge in from this side
needs no forge credentials and makes that push a fast-forward again.

The merge changes no files at all — every one of those commits is content GitHub
already carries under different SHAs, from the two sides being reconciled by hand.
This records the shared history so the sync stops being rejected.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 22:59:34 -07:00
zeekayandhanzo-dev 5c0ea823f4 oidc: emit preferred_username — the token never carried a username
Discovery has advertised preferred_username in claims_supported since it was
written, and no token ever emitted it. The only user-ish claims on the wire were
sub (a UUID) and `name`, and userClaims fills `name` from User.DisplayName — so a
real token read

  sub = e7d7fda0-…   name = "Zach Kelling"   email = z@hanzo.ai

A resource server that needs the IAM USERNAME (the `<name>` half of
`<owner>/<name>`) had nothing to read and fell back to `name`. cloud's money path
addresses a wallet exactly that way, so it addressed `hanzo/Zach Kelling` — a
wallet no funding path can name, a human label with a space in it — while the
balance sat in `hanzo/z`. Every signed-in completion 402'd against a funded
account, which is what took hanzo.chat dark.

userClaims already had the value and discarded it: it computes DisplayName for
`name` and drops u.Name. It now returns both, and Sign/SignUserToken/SignID take
the username and emit it as preferred_username. omitempty keeps a machine token
(no user, no username) omitting the claim rather than emitting it empty, so one
struct still serves both token shapes.

DisplayName is unchanged and still carried in `name` — this adds the missing
claim, it does not repurpose an existing one.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 22:43:17 -07:00
zooqueenandhanzo-dev 23b97f5911 fix(routes): the Guard covers IAM's prefixes, not the whole app
Root cause of the .224/.225 401s. app.Use(authz.Guard) is not "guard my routes",
it is "guard every route this *zip.App will ever serve". Coherent while IAM owns
the app; false the moment it is one of 59 subsystems. Embedded, IAM mounts at
position 9 and `ai` registers /v1/models at 106, so IAM's Guard gated ai's routes
97 positions later — then resolved the bearer against the EMBEDDED iam2.db, which
has never seen a token minted by the external hanzo.id, and failed closed on every
valid request.

The tell was the body. {"status":401,"error":"authentication required"} is
internal/authz/authz.go verbatim, not ai's OpenAI-shaped error — same image, same
route, different handler, selected purely by whether iam was mounted.

Fixed by mounting on the prefixes this subsystem declares: /v1/iam, /login/oauth,
and the framework's own side doors onto IAM's typed ops (/mcp and
/.well-known/openapi.json — named, not assumed, because a host binary owns those
paths when IAM is embedded). NOT by reordering mounts, which would work today and
break on the next reorder: a position in a slice is not a security boundary, a
path prefix is.

A SCOPING change, not a relaxation. The public group is still registered first and
still terminates the walk; every path IAM serves is gated exactly as before, and
the existing side-door suite (POST /mcp and the OpenAPI doc, both 401 without a
bearer) is unchanged and still green.

The test mounts a SIBLING subsystem on the same app, which is what no previous
test did — with only IAM mounted there is no neighbour to swallow, so the suite
could not see this. Red against app.Use with the production body verbatim.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 20:13:36 -07:00
zeekayandhanzo-dev 749da88afe fix(authz): admit the cert id shape the BINARY sends; commit the probe as the gate
Fourth instance of one pattern in a day: verified with the bare id, the client
sends the org-qualified one. ai/internal/iam/cert.go:35 builds
"<IAM_ORG>/<name>" -> hanzo/cert-hanzo, while GetApplication hardcodes admin/ —
same clause, opposite directions, and only the spelling I happened to test worked.

SETTLED THE TRAP BEFORE GRANTING. admin/cert-hanzo and hanzo/cert-hanzo are two
DISTINCT rows, so granting blind risked cloud validating every bearer against the
wrong public key: green boot, total silent auth failure, no stack trace — strictly
worse than the crashloop. Measured instead: a real hanzo-cloud token carries
kid=cert-hanzo, and both rows carry the IDENTICAL 4096-bit modulus
(zE8fZcoJ_u4Uq…), which is the single key /v1/iam/.well-known/jwks publishes for
that kid. So the owner half selects between duplicates of ONE keypair, not between
keys, and the trap cannot fire.

They were seeded 3ms apart (…19.416479 and …19.419119) by the same run — seed
drift, a duplicate signing identity, reported separately. Nothing here depends on
which row wins, which is the point: name == p.AppCert remains the whole gate, so
an app reaches the one cert its own application row names and no other, whichever
owner it spells. Read-only, and Cert.Mask blanks PrivateKey, so this discloses the
PUBLIC key already published at the JWKS endpoint.

The probe manifest is now a committed artifact (test/probe/) with the rule written
down: derive the test id from what the client actually sends, by reading the
client. A green suite has now failed to predict production three times running;
the probe caught all three in seconds.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 19:40:58 -07:00
hanzo-dev a83949d644 fix(seed): reconcile POLICY only — a full merge would delete live registration
The reconcile added in the previous commit merged the WHOLE declared application
object onto the live row. Measured against production before enabling it, that
would have been destructive: live applications legitimately carry redirect URIs
and grants init_data.json does not list — hanzo-console alone had 4 extra
redirects and 2 extra grants, hanzo-id and lux-kms extra grants, zoo-cloud an
extra redirect. Declared-wins over the whole object would have DELETED them and
broken the very logins the flag change exists to fix.

The file's authority is narrowed to identity POLICY — who may sign up, whether
password/code sign-in is on, how an org is chosen, which org owns the app.
Registration (redirects, grants, client credentials) drifts legitimately and is
owned by the provision document; keeping the two apart is what lets a bootstrap
file converge a flag without being able to take a surface offline.

appPolicyKeys is deliberately short: every key on it is one this file can silently
revert on the next boot, so a field belongs there only if declared should always
win over live.

Test pins it: a row carrying undeclared redirects, grants and a generated
clientSecret keeps all three across a reconcile that flips enableSignUp.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 19:37:29 -07:00
hanzo-dev b4212cee6b Merge feat/pk-publishable-key: a publishable pk- resolves an org, never a principal
A pk- is the publishable half of a key — safe to ship in client JS. It resolved
through UserByAccessKey to a full principal (owner, name, email, isAdmin), so a
value meant to be public was also a read grant.

pk- is now write-only: UserByAccessKey recognizes hk- and sk- only, and a pk-
falls through to not-exist alongside every unknown value. Resolving a pk- to its
tenant is a separate door, GET /v1/iam/resolve-key, behind its own
CapPublishableResolve capability, and it answers the org and scope and nothing
else. store.PublishableKeyByAccessKey is fail-closed on prefix, scope and expiry,
and an unresolvable key returns the same envelope as a missing one.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 19:30:22 -07:00
hanzo-dev 9082a8e272 Merge remote-tracking branch 'origin/main' into feat/pk-publishable-key
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 19:29:48 -07:00
hanzo-dev d04aed0e6d Merge remote-tracking branch 'origin/main' into feat/pk-publishable-key
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 19:28:28 -07:00
zeekay b6465a32a3 Merge remote-tracking branch 'origin/main' into fix/selfread-compat-verb
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 19:26:31 -07:00
zeekayandhanzo-dev b425ae5e98 fix(compat): a self-read must query its OWN owner, not the tenant it serves
The probe caught this one line past the fix. get-application as hanzo-cloud went
403 -> 200, and the body said "the entity does not exist": authorized, and still
unable to read itself. A 200 that is functionally the 403 it replaced.

Scope pins a non-SuperAdmin to p.Org, which for an APP principal is the tenant it
SERVES (hanzo), not the org that OWNS its row (admin). So the Guard admitted
admin/hanzo-cloud and the handler then queried hanzo/hanzo-cloud.

Scope itself is not loosened — its pinning IS the tenant gate on every
handler-authorized path (SCIM, service-accounts, memberships), and widening it for
app principals would let one list admin-owned rows there. Instead ScopeFor asks the
ONE self-read clause again, through the same authorize() that defines it: if
authorize would admit this exact read, the owner it admitted is the owner queried;
otherwise the pin stands. No second copy of the rule.

The test now asserts the BODY, not the status. Asserting 200 was exactly what let
the first version look correct while returning nothing.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 19:26:22 -07:00
hanzo-dev fd146050be Merge tag 'v1.33.18' into converge
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 19:22:30 -07:00
hanzo-dev e42dd82ce3 feat(signup): self-serve organizations, and declared app policy converges
Two defects, one symptom: nobody could create an account.

1. "the application does not allow to sign up new account"

   enableSignUp was false on the console app, and init_data.json COULD NOT change
   it. Seeding is new-only — upsert calls orm.GetOrCreate and skips an existing
   row — which is right for identity DATA (a bootstrap file must never stomp a
   live user) but wrong for application POLICY. init_data.json is how the platform
   declares whether an app allows sign-up; with the flag unreachable, production
   read false while the declared state said otherwise, and the only way to move it
   was an out-of-band admin call.

   Declared application policy now converges on boot. The reconcile merges the RAW
   declared object onto the loaded row rather than saving a decoded struct, which
   is the crux: json.Unmarshal sets only the keys PRESENT in the JSON, so a field
   the file does not mention keeps its stored value. That is what protects
   clientSecret — generated at first seed, never written back to the file — from
   being blanked, the same de-secret hazard update-application had to fix. It also
   needs the raw object because a decoded struct cannot distinguish "declared
   false" from "absent".

2. "the organization: X does not exist"

   Signup required the org to already exist, so "a new account with a new
   organization" was impossible without an operator creating the org by hand. The
   old comment said this needed an org-create helper that iam2 did not have; that
   helper is now store.CreateOrganization (idempotent, so two founders racing a
   name join rather than collide).

   Self-serve creation is OPT-IN per application via orgChoiceMode == "create", so
   an app that merely lets users CHOOSE among orgs still cannot mint one, and an
   app naming a single tenant is unaffected.

Safe by construction, resting on checks that already ran rather than new ones:
IsReservedOrg refuses admin/built-in/app BEFORE this point, and the founder is
created under their OWN org — authority is a property of the user row, and authz
derives Super from user.Owner == "admin", so self-serve signup can never mint a
SuperAdmin. The org name is validated because it becomes the OWNER half of every
(owner, name) key; an unvalidated name would be key injection, not cosmetics. The
"does not exist" refusal is unchanged when the opt-in is off, so signup does not
become an org-existence oracle.

Tests: org is created and owned by admin while the founder is owned by the new
org; refused without the opt-in (both "" and a non-create mode); reserved orgs
still refused WITH the opt-in set; name policy. Plus seed convergence and the
undeclared-field guard proving clientSecret survives a reconcile. Full suite green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 19:20:19 -07:00
zeekayandhanzo-dev 7cd132a223 fix(authz): the compat verb IS the entity — self-read now fires, and covers its cert
v1.33.17's self-read grant was inert in production. It matched entity
"applications"; entityOf resolved the alias cloud actually calls,
/v1/iam/get-application, to the literal string "get-application". No clause
matched, the reserved-owner gate denied, 403. The native noun route matched and
the verb route did not — the same two-spellings-of-one-concept bug as
OrgChoiceMode "None" vs "", in a different place.

I proved that grant with a unit test against authorize() and shipped it without
exercising the path the caller uses. That is the third time today a fix was
"unit-tested only" and did not fire. The tests here go through the real router
over the compat verb with client_secret_basic — the shape hanzo-cloud sends.

entityOf now folds the verb spelling onto the entity noun in the ONE place a path
becomes an entity. This is wider than the grant: EVERY capability keyed on an
entity was dead on the compat surface, because capFor("add-organization") is not
capFor("organizations") — the allowlists that exist precisely so the brand
consoles can manage orgs during onboarding were consulted with a key that could
never match. Both surfaces now resolve the same policy.

CERTS, the second entity on the same critical path. InitAuthConfig reads the
application, then reads application.Cert, then InitConfig(cert.Certificate) —
so granting only applications fixes one line and panics identically on the next.
An app may read the ONE cert its own row names: Principal carries AppCert from
that row, and the clause requires name == p.AppCert, so an app cannot walk to
another brand's signing cert. Read-only, and Cert.Mask already blanks PrivateKey
and AccessSecret, so what crosses is the PUBLIC certificate this client must
already trust to verify our tokens.

ReadTarget also resolves a BARE `?id=cert-hanzo` to its name half. It previously
yielded NO target — owner "" and name "" — so the authorizer was handed nothing
and fail-closed denied even the caller reading its own. Knowing the name cannot
widen anything: an empty owner still fails the tenant rule and IsReservedOrg(""),
so only the self-read clause, which pins that name to the principal, can act on it.

Refusals pinned over the same verb surface: a sibling app, the same name under a
tenant owner, a cert it does not reference (both id spellings), the application
list, the cert list, user rows, and a wrong client secret.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 19:18:15 -07:00
zeekayandhanzo-dev 60a112cbd0 feat(provision): a document may state the URIs and grants derivation cannot know
The upsert REPLACES redirectUris and grantTypes — bootstrap.go writes
`if len(req.RedirectUris) > 0 { existing.RedirectUris = req.RedirectUris }`.
So a document that cannot express a live URI does not merely fail to add it:
converging DELETES it, reports success, and takes the login down.

Read off the production store, not inferred. hanzo-app holds 24 redirect URIs
— the desktop deep link hanzo://oauth/hanzo, the Tauri loopback ports,
https://cowork.hanzo.ai/auth/callback — where `desktop` derives exactly one,
hanzo://oauth/app, a value nothing uses. hanzo-cloud holds grants
[authorization_code refresh_token device_code client_credentials]: one
client_id serving a browser PKCE surface AND a backend machine identity, where
`spa` derives two, so a converge silently revokes the machine half. Hanzo's
provision document has been BLOCKED on precisely this since it was written; it
could not be applied without breaking working logins.

Redirects and Grants are additive, never substitutive: hosts+type stay the
default so a line stays one line, and the field carries only the exceptions.
Both go through one union() — order-stable, duplicate-free, blanks dropped — so
two runs over one document still produce byte-identical bodies, which is what
makes --dry-run reviewable and a re-run a no-op. A literal redirect that is a
path rather than an absolute URI is a Derive error, because that registration
converges silently and fails later as redirect_uri_mismatch.

Merging in the server was the alternative and is worse: it makes the document
permanently non-authoritative, drift becomes unremovable, and every stale URI
lives forever. Replace stays; the document gets a vocabulary big enough to be
true.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 18:58:15 -07:00
zeekayandhanzo-dev 8bfefe9c70 fix(authz): an app may read its OWN record; the Guard speaks the caller's envelope
Two refusals a client could not act on, both at the same seam.

SELF-READ. hanzo-cloud calling get-application on itself got 403. Reading its own
registration is the ordinary bootstrap of an OIDC relying party — how a client
discovers its cert, redirect URIs and enabled methods — and it discloses nothing
the holder of that client's credential does not already have. The owner-pin that
closed "every client credential is a global admin" is right and stays; it was
missing this one case, and applications are not in capFor(), so a confidential
client could not read even itself.

Granted narrowly, four ways at once: only an app principal, only a READ, only the
applications entity, and only the exact (AppOwner, App) pair the request
authenticated as. Both halves of the key must match, which is what makes it
self-read rather than "apps may read applications" — a sibling under the same
owner differs in name, and admin/<app> vs <tenant>/<app> differs in owner, so
NEITHER direction of that name collision is admitted. Read-only is load-bearing:
a self-write would let a client widen its own redirect URIs or grant types.

ENVELOPE. The Casdoor verbs are a contract — every client branches on a STRING
status of "ok"/"error" and reads msg — and the handlers honour it. But the Guard
short-circuits before any handler runs, and zip's error shape is
{"status":401,"error":"…"}: status an int where the client expects a string, the
text under error where the client reads msg. So one endpoint spoke two languages
depending on how far the request got, and a client written against the documented
shape saw neither an ok nor a recognizable error. Fixed at the source rather than
teaching every client to tolerate both.

Scoped to the compat surface only. Those paths are verb-shaped (get-/add-/update-/
delete-) while the native surface is noun-shaped, so the prefix distinguishes the
two contracts with no second list to keep in sync; REST/OIDC/SCIM keep their own
error shapes. HTTP status codes are unchanged.

Tests: the self-read truth table pins both collision directions, both write verbs,
the wrong entity, an unpinned app and an empty owner as refusals, and that a human
is unaffected; the envelope test pins verb-vs-noun selection. Red before.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 18:49:15 -07:00
zeekay f9813f6e0b Merge remote-tracking branch 'origin/main' into reconcile-iam
image / build (push) Successful in 3m47s
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 17:47:32 -07:00
zeekayandhanzo-dev 06709cd844 feat(login): single sign-on off a live session — one minting tail, two proofs
Every app sent a SIGNED-IN person to a credential form. The portal's launcher
tiles are the visible symptom: click one, the app starts an OIDC hop, and the
browser lands back on hanzo.id looking at a password box — which reads as "the
link is dead", because from the outside nothing happened except a bounce.

The client half of silent SSO was already written and correct (@hanzo/id-auth
silentLogin posts a credential-less type=code login with the cookie attached, and
its comment describes exactly the branch it expects). IAM v2 never had that
branch: a post with no username and no password fell straight through to
"organization, username and password are required", so the fallback to the
interactive form was the ONLY outcome — always.

A code request carrying no credential but a live session THIS IdP issued is a
user who is already signed in asking for a grant to the next app. Re-typing the
password proves nothing new: the cookie is HMAC'd over the platform signing key,
carries its own expiry, and its sid is checked against the Session row on every
resolve, so it is revocable — and it only ever exists downstream of the full
gate, second factor included, because loginGrant is what sets it.

It grants nothing extra. The mint runs through loginGrant, the ONE minting tail,
so the reserved-org gate, the app-org tenant gate, the exact redirect_uri match
and the public-client PKCE requirement are the same checks in the same order as a
password post; only the proof of identity differs. Restricted to type=code, and
the user row is re-read so an account forbidden or deleted since sign-in is
refused rather than riding its old session. Not a CSRF mint either:
/v1/iam/login is not a CORS browser path and this IdP never allows credentialed
cross-origin reads, so only a first-party page can both send the cookie and read
the code.

Tests: a live session mints a redeemable code bound to the right user and
redirect; no session still demands a credential; an unregistered redirect_uri is
refused even with a session; a forbidden user cannot ride an old session.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 17:20:52 -07:00
zeekayandhanzo-dev e3815934fd feat(onboard): a founder's own org is first-class — roster row + session carry-over
Self-service org creation already had ONE door — POST /v1/iam/onboard, which
resolves the caller from its own session or bearer and provisions org + admin +
metered key under the Founder stamp. Two things kept the org it produced from
being a real tenant.

The org was born with an empty ROSTER. provision() moved the caller in as the
org's admin, and MemberOrgRefs derives a HOME entry from the user row, so the
`orgs` claim looked right — but nothing ever wrote the (User x Org) relation an
org's membership is actually read from. MembershipsByOrg returned nothing, so as
far as invitations, team management and every roster read were concerned the org
had no owner. It now ensures the founder's row at RoleOwner: EnsureMembership
never downgrades, so a later home-org backfill (which writes RoleAdmin) can no
longer quietly demote the person who founded it.

And the move SIGNED THE FOUNDER OUT. An IAM identity is (owner, name), so moving
a user re-keys it — which strands the session cookie, whose (Owner, Name,
Application) triple keys the Session row. The next request read as anonymous: a
person was logged out by their own signup. sessions.Rekey carries the live
session across to the new key and revokes the old sid, so the browser holds
exactly one session throughout and the superseded cookie cannot be replayed. It
is a no-op on the bearer path, whose subject is a stable UUID the re-key does not
touch.

The same re-key stranded the caller's PREVIOUS home membership on an id that no
longer exists, leaving a ghost on the old org's roster forever; the converge that
re-keys the user now drops it.

No gate is widened. Authority is FOUNDERSHIP — the caller is resolved from its
own credential and may only found an org for itself — so IAM_ORG_ADMIN_APPS,
which would let one app administer every tenant's orgs, stays exactly as it is.

Tests (red before, green after): a founder's org carries Founder + an owner
roster row + the orgs claim and keeps them signed in; a second identity can
neither complete nor join that org; anonymous and forged-bearer callers are
refused.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 17:07:46 -07:00
zeekayandhanzo-dev 4daead4b61 ci: add the ONE GitHub Action this repo may run — sync to git.hanzo.ai
git.hanzo.ai is CANONICAL; GitHub is a mirror. Everything that BUILDS,
PUBLISHES or DEPLOYS is native — .hanzo/workflows/image.yml on the forge for
images, hanzo-cd for rollout. GitHub Actions must never build, never publish an
image, and never touch a cluster. Its single job is to get commits onto the
forge so the native pipeline can see them.

Direction is push (GitHub -> forge). The forge also runs a pull job
(.hanzo/workflows/sync-from-github.yml); the two compose rather than fight
because whichever arrives second sees LOCAL == REMOTE and exits a no-op.

fetch-depth: 0 — a shallow push silently drops commits.
The token goes in a credential helper, never a remote URL: a token baked into
.git/config leaks on any `git remote -v`, which is a live finding in this very
working copy.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 16:54:41 -07:00
zeekayandhanzo-dev ccc4825046 build(deps): pin hanzoai/orm to the released v0.6.8, not a pseudo-version
The image could not be built at all. On-cluster BuildKit, from the v1.33.12 tag:

  go: github.com/hanzoai/orm@v0.6.8-0.20260726065619-7b3c62da906d:
      invalid pseudo-version: revision 7b3c62da906d is not a descendent of
      preceding tag (v0.6.7)

7b3c62da906d is on no branch and in no tag that github.com/hanzoai/orm serves,
so only a machine whose module cache already held it could resolve that pin —
which is why this built here and nowhere else. v0.6.8 is the released tag
carrying the same work (the tenant registry), so the dependency is stated as a
version instead of a commit that happens to be lying around.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 16:42:41 -07:00
Hanzo AIandzeekay c655fa94eb fix(oidc): a PKCE-bound code needs no client secret — SPA logins were 401ing
Every @hanzo/iam browser login died at the token exchange:

  Token exchange failed (401): {"error":"invalid_client",
                                "error_description":"client authentication failed"}

A visitor signed in at hanzo.id, came back to /auth/callback, and got no session —
on hanzo.chat and cloud.hanzo.ai both. Cause: authorizationCodeGrant required the
registered secret whenever the app HAS one, and registration here is not per-flow.
`hanzo-chat` (secret len 48) and `hanzo-cloud` (len 64) keep a secret for their
BACKEND paths — chat's passport OpenID strategy, cloud's `IAM_CLIENT_ID=hanzo-cloud`
client_credentials machine auth — while their SPA is a public PKCE client that
cannot hold one. Deleting the secrets is NOT the fix: clientCredentialsGrant
requires a registered secret, so that would break cloud.

So authenticate the browser the way OAuth 2.1 does — by the code's PKCE binding:
require the secret only when one is PRESENTED, or when the code carries no
challenge. Same bounded relaxation this file already documents for passwordGrant
(Casdoor allowed exactly this; the clean-room rewrite tightened it and broke login).

Untouched: a presented-but-wrong secret is still 401; a code with no PKCE still
needs the secret (client auth cannot be skipped by omitting the challenge);
RedeemCode still verifies verifier↔challenge, single-use and redirect binding;
client_credentials still demands a registered secret.

Tests (4) cover all four corners and the first one reproduces the live 401 with
this change reverted.
2026-07-26 16:37:37 -07:00
Hanzo AIandzeekay 1b39c5f973 feat(iam): /v1/iam/consent — account-canonical data-sharing consent
GET/PUT /v1/iam/consent, self-scoped to the caller (callerOf). Stores the two
switches (anonymous insights default-on; opt-in training-contribution default-off)
inside the SAME preferences blob as update-preferences, so there is one source of
truth and no parallel table to drift. The hanzo.id signup asks it, the browser
extension (v1.9.36) reads/writes it, and hanzo.ai edits it — one value, one way.
2026-07-26 16:37:31 -07:00
zeekayandhanzo-dev cd1e65e06c fix(login): read the whole authorize request from the query, not only the body
219fc64e took the PKCE challenge off the query string. The same request carries
`scope`, `nonce`, `redirectUri` and `clientId` in exactly the same place, and
those were still being read from the body alone — so they were dropped, and the
damage surfaced two hops away at the relying party.

insights.hanzo.ai signed in and landed on "Something went wrong". Captured from
the pod:

  Internal Server Error: /complete/oidc/
    File ".../social_core/backends/open_id_connect.py", line 357
      response["id_token"], response["access_token"]
  KeyError: 'id_token'

Nothing was wrong with that client. The code it redeemed had `Scope=""`, so
issueTokens' `hasScope(row.Scope, "openid")` was false and /token answered 200
with an access_token and no id_token at all. Behind that sat two more failures
the first one hid: `Nonce=""` fails the id_token claim check of every strict
OIDC consumer, and `RedirectUri=""` meant the token endpoint skipped the RFC
6749 §4.1.3 redirect binding entirely — a code minted for one client's callback
could be redeemed against another.

The login form is posted to the URL the authorize step handed the page and the
OAuth request rides that query, written in two spellings: this server's own
authorize redirect emits RFC snake_case (authorizeForwardQuery), the @hanzo/iam
SDK emits camelCase. adoptQueryPKCE becomes adoptQuery and fills the whole
passthrough from either spelling, so no parameter can be forgotten on its own
again. Body still wins when both are present; an adopted value runs every check
the body path runs, so an unregistered redirect_uri is refused as before.

Every login test in this package posted these parameters in the BODY — a
contract no real client uses, which is why CI stayed green through the outage.
login_query_test.go posts what the wire actually carries: credential in the
body, request on the query. Before this change four of its five cases fail,
the first with the production symptom (`no id_token in the token response`).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 16:37:27 -07:00
hanzo-dev 486857acc2 test(schema): TestOrgRef_wireContract -> TestOrgRef_JSONContract
The mount/wire sweep matched on word boundaries, so the one occurrence
where "wire" was glued to the next word (wireContract) survived. Same
law, same substitution the file's own doc comment already uses ("These
tests pin the HTTP contract"). go test ./internal/schema/ ok.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 16:04:44 -07:00
zeekayandhanzo-dev f8cec60bc8 cors: open the two native writes a first-party console makes
Create-org and invite-member are performed by the console on the user's OWN
behalf. Listed as the native REST paths (/v1/iam/organizations,
/v1/iam/invitations), not the Casdoor add-* verbs — those exist so existing
backends keep working, and a new browser client should not learn them.

Guard-authorized as before: opening the ORIGIN does not open the data, so the
browser can only do what that principal could already do.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 15:54:22 -07:00
zeekay 39363e8a11 fix: point forge API calls at /v1 — /api/v1 is gone
The fork moved its API off /api to /v1, so every call built against
${{ github.server_url }}/api/v1/... now 404s. Verified live with a control:
/v1/version 200, /api/v1/version 404, a nonsense path 404.

This is the build-dispatch in sync-from-github, so a fast-forward from GitHub
was landing commits and then silently failing to trigger the build.
2026-07-26 15:51:43 -07:00
zeekay 3dd50632ae fix(authz): never authenticate a CORS preflight
A preflight carries no credentials — the browser strips them — so the Guard
could only ever answer 401, and a 401 preflight is indistinguishable to the page
from "your origin is not allowed". A registered console asking its own IdP
"which orgs am I in?" got a failed preflight and rendered an empty org switcher,
with nothing in the network log but an OPTIONS 401. That is the whole reason
consumers grew server-side IAM proxies: the browser path looked impossible.

Whether a path is open to a browser is CORS's question, and it is already
answered upstream in internal/cors: if it opened the path it terminated the walk
with 204 and the Guard never runs; if it did not, falling through emits no
allow-origin header and the browser blocks the real request anyway. Either way
a preflight is not a request to authorize.

Also opens the org surface a console reads about ITSELF (get-organizations,
get-organization, get-users, get-account). Same shape as userinfo, which was
already open: a Bearer-protected read where CORS decides which ORIGIN may see
the answer and the Guard still decides WHO. Opening the path does not open the
data — the Guard authorizes the exact (owner, name) addressed, so a caller sees
only what its principal could already see.

Tests: TestGuard_NeverAuthenticatesAPreflight drives the real router and pairs
each assertion — OPTIONS must not 401, and the same GET without a bearer must
still 401, so opening the preflight can never quietly open the read. Verified by
reverting the fix: 3 failures, one per path. Plus browserPaths coverage both
ways (the console surface is open; certs/providers/writes/typed-CRUD stay shut).

Note: TestSuperAdminWritesAdminCertAndCrossOrg and TestOrgAdminManagesOwnOrgOnly
are FLAKY on main independently of this change — 0, 1, and 2 failures across
three identical baseline runs. Not touched here.
2026-07-26 15:34:21 -07:00
zeekayandhanzo-dev a84a293452 Route, not Mount: drop the banned mount/wire vocabulary
"Mount" and "wire" are banned. Route registration in a zip app is Group +
the Route seam this repo already standardized on (routes.Route,
oidc.Route, mfa.Route, scim.Route, registry.Route) — server.Mount and
feature.Mount were the last two holdouts, so the repo had TWO names for
one concept. One and only one way:

  server.Mount            -> server.Route
  feature.Feature.Mount   -> feature.Feature.Route
  feature.MountAll        -> feature.RouteAll
  registry.mount (unexp.) -> registry.route

Prose follows the same law: "mounts/mounted/mounting" -> registers/
registered/registering, "wire contract" -> HTTP contract, "wire
request" -> HTTP request, "wires/wired/wiring" -> binds/bound/binding.
These read as values (what the thing IS) rather than places (where it
got stuck).

No external repo imports hanzoai/iam/{feature,server} — verified by
grepping every go.mod under ~/work/{hanzo,lux,zoo} for hanzoai/iam and
then grepping those trees for the import paths; the only cross-repo
import is hanzoai/iam/pkg/model (hanzo/cloud), untouched here. So this
breaks no consumer.

Mechanical only: no behaviour change, no route path change.
GOWORK=off go build ./... clean; go test ./... 26/26 ok, same set as
the pre-change baseline.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 15:12:19 -07:00
hanzo-dev 4b0f4e9308 feat(provision): apps can name a signing cert and their own callback
Two gaps that made the sanctioned convergence path unable to express a real
client, so the client got hand-made instead — which is how it drifted.

cert. issueTokens resolves app.Cert to build the signer, and the id_token is
minted from it. A client registered with no cert therefore cannot produce one,
and an OIDC consumer that identifies the user FROM the id_token fails after a
perfectly successful code exchange — goth reports "cannot get user information
without id_token", which reads like a scope or secret problem and is neither.
Every client this package registered until now sent no cert at all.

Omitted from the JSON when empty, not sent as "". The upsert assigns a cert only
when non-empty, so omission is what actually means "leave it alone" — and
nothing here rotates on a re-run, same contract as clientSecret.

callback. Redirects stay derived per host — hand-written URI lists are how
registrations drift from the app calling them. But the path is not always ours:
Hanzo Git serves /user/oauth2/<source>/callback. One optional path override per
app keeps the host set as the single source of the URI list while letting a
foreign server's shape be stated. Validated at Derive (must be a path, never
/api/) because a malformed redirect converges silently and only shows up as
redirect_uri_mismatch on the next login.

Four tests, each proven to fail without its half: the override lands
(and an app that does not ask still gets /auth/callback), the cert is carried,
an absent cert is absent from the body, and a bad callback is rejected.
provision + bootstrap suites pass.
2026-07-26 14:52:48 -07:00
hanzo-dev 960b147eee chore(git): ignore build output, frontend deps, and signing key material
Restores the guard dropped by a force-push. Without these rules the JWT
signing keypair (object/token_jwt_key.key/.pem), the iamd binary, web/build,
and web/node_modules are untracked-but-stageable — a plain 'git add -A' commits
the keypair. Keys live in KMS; they never belong in the tree.
2026-07-26 14:46:44 -07:00
hanzo-dev 41b708836d chore(git): ignore the agent scratch directory
.claude/ holds per-machine agent working state and worktrees; it has no
business in the repo.
2026-07-26 14:02:37 -07:00
Hanzo AI 4bec5ef52f Merge remote-tracking branch 'origin/main' 2026-07-26 13:02:50 -07:00
Hanzo AI f641d06736 fix(oidc): a PKCE-bound code needs no client secret — SPA logins were 401ing
Every @hanzo/iam browser login died at the token exchange:

  Token exchange failed (401): {"error":"invalid_client",
                                "error_description":"client authentication failed"}

A visitor signed in at hanzo.id, came back to /auth/callback, and got no session —
on hanzo.chat and cloud.hanzo.ai both. Cause: authorizationCodeGrant required the
registered secret whenever the app HAS one, and registration here is not per-flow.
`hanzo-chat` (secret len 48) and `hanzo-cloud` (len 64) keep a secret for their
BACKEND paths — chat's passport OpenID strategy, cloud's `IAM_CLIENT_ID=hanzo-cloud`
client_credentials machine auth — while their SPA is a public PKCE client that
cannot hold one. Deleting the secrets is NOT the fix: clientCredentialsGrant
requires a registered secret, so that would break cloud.

So authenticate the browser the way OAuth 2.1 does — by the code's PKCE binding:
require the secret only when one is PRESENTED, or when the code carries no
challenge. Same bounded relaxation this file already documents for passwordGrant
(Casdoor allowed exactly this; the clean-room rewrite tightened it and broke login).

Untouched: a presented-but-wrong secret is still 401; a code with no PKCE still
needs the secret (client auth cannot be skipped by omitting the challenge);
RedeemCode still verifies verifier↔challenge, single-use and redirect binding;
client_credentials still demands a registered secret.

Tests (4) cover all four corners and the first one reproduces the live 401 with
this change reverted.
2026-07-26 13:02:32 -07:00
hanzo-dev f83d81d7ab chore(git): ignore build output, frontend deps, and signing key material
The tree had no rule for the iamd binary, web/build, web/node_modules, or
object/token_jwt_key.*, so a routine 'commit local changes' swept 57k files,
a 240MB binary, and the JWT signing keypair into a commit. Keys live in KMS;
they never belong in the tree.
2026-07-26 12:40:22 -07:00
Hanzo AI 1f9a80df2c docs(store): name our fork Hanzo Datastore in the backend list 2026-07-26 11:43:11 -07:00
Hanzo AI a0f47b7596 feat(iam): /v1/iam/consent — account-canonical data-sharing consent
GET/PUT /v1/iam/consent, self-scoped to the caller (callerOf). Stores the two
switches (anonymous insights default-on; opt-in training-contribution default-off)
inside the SAME preferences blob as update-preferences, so there is one source of
truth and no parallel table to drift. The hanzo.id signup asks it, the browser
extension (v1.9.36) reads/writes it, and hanzo.ai edits it — one value, one way.
2026-07-26 11:15:40 -07:00
zeekay 266e7a3cbc fix(login): read the PKCE challenge from the query, not only the body
A public client's login was rejected with "PKCE is required for public clients"
while the browser was sending a challenge the whole time — we only looked in
one place. Captured live:

  POST /v1/iam/login?clientId=lux-cloud&…&code_challenge=JCCAo2ey…&code_challenge_method=S256
  body: {"type","username","password","application","signinMethod","autoSignin","organization"}
  -> {"status":"error","msg":"PKCE is required for public clients"}

The login form is posted to the URL the authorize step handed the page, so the
OAuth parameters ride the QUERY. The body binds `codeChallenge` (camelCase);
the query spells it `code_challenge` (RFC 7636). Take the query value when the
body has none — body still wins when both are present, so this can only ever
supply a challenge, never replace one.
2026-07-26 10:53:08 -07:00
zeekay 219fc64ee8 fix(login): read the PKCE challenge from the query, not only the body
image / build (push) Successful in 1m21s
A public client's login was rejected with "PKCE is required for public clients"
while the browser was sending a challenge the whole time — we only looked in
one place. Captured live:

  POST /v1/iam/login?clientId=lux-cloud&…&code_challenge=JCCAo2ey…&code_challenge_method=S256
  body: {"type","username","password","application","signinMethod","autoSignin","organization"}
  -> {"status":"error","msg":"PKCE is required for public clients"}

The login form is posted to the URL the authorize step handed the page, so the
OAuth parameters ride the QUERY. The body binds `codeChallenge` (camelCase);
the query spells it `code_challenge` (RFC 7636). Take the query value when the
body has none — body still wins when both are present, so this can only ever
supply a challenge, never replace one.
2026-07-26 10:53:08 -07:00
zeekay 747473641f fix(bootstrap): a public client must STAY public across reconciles
Found by probing the live store: after the provisioner correctly registered
lux-cloud as public, ANY later upsert that merely omitted `public` — an
operator reconcile, a read-back, any caller that only sets a name — minted a
fresh secret and silently turned it confidential again. Every browser login
then failed `invalid_client` with nothing in the provision document changed to
explain it. Reproduced end to end:

  upsert {"name":"lux-cloud"}                 -> clientSecret dc49409a…
  upsert {"name":"lux-cloud","public":true}   -> clientSecret ""      (correct)
  upsert {"name":"lux-cloud"}                 -> clientSecret Ug5KTDx… (!!)

The old branch asked "is the stored secret empty?" and treated empty as
"nothing to preserve". But for a public client empty IS the value, and it is
load-bearing: the token endpoint reads a stored secret as "demand client auth".

Settle it in one testable place, resolveSecret: public -> none; explicit ->
honour; existing -> preserve WHATEVER it has, empty included; new -> mint. The
table test pins all four, so the confidential no-rotation path stays covered
too.
2026-07-26 10:46:16 -07:00
zeekay 7badd9dc74 fix(bootstrap): a public client must STAY public across reconciles
image / build (push) Successful in 4m6s
Found by probing the live store: after the provisioner correctly registered
lux-cloud as public, ANY later upsert that merely omitted `public` — an
operator reconcile, a read-back, any caller that only sets a name — minted a
fresh secret and silently turned it confidential again. Every browser login
then failed `invalid_client` with nothing in the provision document changed to
explain it. Reproduced end to end:

  upsert {"name":"lux-cloud"}                 -> clientSecret dc49409a…
  upsert {"name":"lux-cloud","public":true}   -> clientSecret ""      (correct)
  upsert {"name":"lux-cloud"}                 -> clientSecret Ug5KTDx… (!!)

The old branch asked "is the stored secret empty?" and treated empty as
"nothing to preserve". But for a public client empty IS the value, and it is
load-bearing: the token endpoint reads a stored secret as "demand client auth".

Settle it in one testable place, resolveSecret: public -> none; explicit ->
honour; existing -> preserve WHATEVER it has, empty included; new -> mint. The
table test pins all four, so the confidential no-rotation path stays covered
too.
2026-07-26 10:46:16 -07:00
zeekay 483aadb750 feat(bootstrap): let a public (PKCE) client be registered at all
upsertApplication ALWAYS minted a clientSecret when the request omitted one.
The token endpoint treats a stored secret as "this client must authenticate"
(token.go: the secret is verified only when one exists), so every client the
operator or provisioner registered was implicitly confidential — and a browser
SPA, which cannot hold a secret, had no way to comply. Its code->token exchange
died on `invalid_client` at the callback, holding a perfectly valid code.

Captured on lux.cloud, one layer at a time:
  POST /v1/iam/login                    200        password fine
  POST /v1/iam/oauth/token  (CORS fix)  reachable  no longer net::ERR_FAILED
  POST /v1/iam/oauth/token              401        invalid_client  <- this

Add `public` to the upsert. A public client stores NO secret — that absence is
exactly what the token endpoint reads as "PKCE, do not demand client auth" — and
setting it CLEARS a secret left by an earlier confidential registration, so a
mis-typed app can be corrected by re-converging instead of by hand.

provision derives it from the app's type, which is what `type` was always for:
spa/cli/desktop ship to the user and are public; confidential and service can
be trusted with a credential. The steady-state no-rotation path is untouched:
a confidential client that omits the secret still keeps the one it has.
2026-07-26 10:30:43 -07:00
zeekay bb7eff5431 feat(bootstrap): let a public (PKCE) client be registered at all
image / build (push) Successful in 1m15s
upsertApplication ALWAYS minted a clientSecret when the request omitted one.
The token endpoint treats a stored secret as "this client must authenticate"
(token.go: the secret is verified only when one exists), so every client the
operator or provisioner registered was implicitly confidential — and a browser
SPA, which cannot hold a secret, had no way to comply. Its code->token exchange
died on `invalid_client` at the callback, holding a perfectly valid code.

Captured on lux.cloud, one layer at a time:
  POST /v1/iam/login                    200        password fine
  POST /v1/iam/oauth/token  (CORS fix)  reachable  no longer net::ERR_FAILED
  POST /v1/iam/oauth/token              401        invalid_client  <- this

Add `public` to the upsert. A public client stores NO secret — that absence is
exactly what the token endpoint reads as "PKCE, do not demand client auth" — and
setting it CLEARS a secret left by an earlier confidential registration, so a
mis-typed app can be corrected by re-converging instead of by hand.

provision derives it from the app's type, which is what `type` was always for:
spa/cli/desktop ship to the user and are public; confidential and service can
be trusted with a credential. The steady-state no-rotation path is untouched:
a confidential client that omits the secret still keeps the one it has.
2026-07-26 10:30:43 -07:00
zeekay d42e669b64 feat(cors): let a registered browser client finish its OIDC exchange
A public PKCE client runs code->token in the BROWSER: the page at
https://lux.cloud fetches https://lux.id/v1/iam/oauth/token directly. IAM sent
no Access-Control-Allow-Origin, so the browser blocked it and every login
dead-ended on the callback with "Failed to fetch" — user authenticated, code
issued and valid, impossible to spend. Captured live:

  POST https://lux.id/v1/iam/login        -> 200   (password fine)
  GET  https://lux.id/.well-known/openid-configuration -> net::ERR_FAILED
  POST https://lux.id/v1/iam/oauth/token  -> net::ERR_FAILED
  "blocked by CORS policy: No 'Access-Control-Allow-Origin' header"

THE ALLOWLIST IS DERIVED, NOT CONFIGURED. An origin is permitted iff some
registered application already declares a redirect_uri on it — the same set
OAuth trusts to receive a code. So CORS can never be looser than the redirect
allowlist, and there is no second list to drift: provision a host and login
works from it, which composes exactly with `iam2 provision`.

Scoped deliberately. Only the endpoints a browser-side client actually calls
are opened (discovery, JWKS, token, userinfo, revoke, logout); admin/bootstrap
upsert, credential login and /oauth/authorize (a top-level redirect, not a
fetch) stay closed, and a test asserts both halves of that set. Credentials are
NOT allowed — a PKCE exchange proves itself in the body, never via a cookie —
so echoing an origin cannot authorize a cookie-bearing request.

The origin set is cached for 60s; a storage error keeps the last good set
rather than failing open to every origin or closed to all of them.
2026-07-26 10:22:16 -07:00
zeekay 438357267e feat(cors): let a registered browser client finish its OIDC exchange
image / build (push) Successful in 1m15s
A public PKCE client runs code->token in the BROWSER: the page at
https://lux.cloud fetches https://lux.id/v1/iam/oauth/token directly. IAM sent
no Access-Control-Allow-Origin, so the browser blocked it and every login
dead-ended on the callback with "Failed to fetch" — user authenticated, code
issued and valid, impossible to spend. Captured live:

  POST https://lux.id/v1/iam/login        -> 200   (password fine)
  GET  https://lux.id/.well-known/openid-configuration -> net::ERR_FAILED
  POST https://lux.id/v1/iam/oauth/token  -> net::ERR_FAILED
  "blocked by CORS policy: No 'Access-Control-Allow-Origin' header"

THE ALLOWLIST IS DERIVED, NOT CONFIGURED. An origin is permitted iff some
registered application already declares a redirect_uri on it — the same set
OAuth trusts to receive a code. So CORS can never be looser than the redirect
allowlist, and there is no second list to drift: provision a host and login
works from it, which composes exactly with `iam2 provision`.

Scoped deliberately. Only the endpoints a browser-side client actually calls
are opened (discovery, JWKS, token, userinfo, revoke, logout); admin/bootstrap
upsert, credential login and /oauth/authorize (a top-level redirect, not a
fetch) stay closed, and a test asserts both halves of that set. Credentials are
NOT allowed — a PKCE exchange proves itself in the body, never via a cookie —
so echoing an origin cannot authorize a cookie-bearing request.

The origin set is cached for 60s; a storage error keeps the last good set
rather than failing open to every origin or closed to all of them.
2026-07-26 10:22:16 -07:00
zeekay 0cc264f218 feat(provision): converge orgs + OAuth apps from a declarative document
The provision documents in each org's universe repo described a mechanism
that did not exist: IAM_PROVISION_CONFIG / IAM_PROVISION_ON_BOOT are set on
the deployment and a ConfigMap is present, but nothing in this repo has ever
read either, the ConfigMap is not mounted, and it declares only one org. So
no org's app graph was ever reconciled, and registrations drifted from the
apps that call them — which presents as invalid_client / redirect_uri_mismatch
long after the change that caused it.

Build the missing driver. The convergence primitive already existed —
POST /v1/iam/admin/applications/upsert is idempotent by natural key and
PRESERVES an existing clientSecret when the request omits one. This package
is the half that was missing: read the document, derive every client, apply.

Mechanism here, policy in each org's repo. This ships ZERO brands.

Everything is DERIVED from an app's name and type, so a document line stays
one line and cannot drift:
  clientId  ALWAYS <org>-<app>            (HIP-0111)
  redirect  https://<host>/auth/callback  per host, for browser types

Both derivations are verified against production, not assumed: lux.cloud,
zoo.cloud and platform.hanzo.ai all drive client_id=<org>-<app> with
redirect_uri=https://<host>/auth/callback. Note it is NOT /api/... — the
/v1-only rule holds and the browser callback is unversioned.

Re-running is a no-op by construction, and the test suite pins the contract
that makes that true: the request must never carry clientSecret, or every
converge would silently rotate a live credential.

  iam2 provision --config <doc> --url https://lux.id [--dry-run]
2026-07-26 10:04:35 -07:00
zeekay 96bcfe41b5 feat(provision): converge orgs + OAuth apps from a declarative document
image / build (push) Successful in 1m16s
The provision documents in each org's universe repo described a mechanism
that did not exist: IAM_PROVISION_CONFIG / IAM_PROVISION_ON_BOOT are set on
the deployment and a ConfigMap is present, but nothing in this repo has ever
read either, the ConfigMap is not mounted, and it declares only one org. So
no org's app graph was ever reconciled, and registrations drifted from the
apps that call them — which presents as invalid_client / redirect_uri_mismatch
long after the change that caused it.

Build the missing driver. The convergence primitive already existed —
POST /v1/iam/admin/applications/upsert is idempotent by natural key and
PRESERVES an existing clientSecret when the request omits one. This package
is the half that was missing: read the document, derive every client, apply.

Mechanism here, policy in each org's repo. This ships ZERO brands.

Everything is DERIVED from an app's name and type, so a document line stays
one line and cannot drift:
  clientId  ALWAYS <org>-<app>            (HIP-0111)
  redirect  https://<host>/auth/callback  per host, for browser types

Both derivations are verified against production, not assumed: lux.cloud,
zoo.cloud and platform.hanzo.ai all drive client_id=<org>-<app> with
redirect_uri=https://<host>/auth/callback. Note it is NOT /api/... — the
/v1-only rule holds and the browser callback is unversioned.

Re-running is a no-op by construction, and the test suite pins the contract
that makes that true: the request must never carry clientSecret, or every
converge would silently rotate a live credential.

  iam2 provision --config <doc> --url https://lux.id [--dry-run]
2026-07-26 10:04:35 -07:00
zeekayandhanzo-dev ad714f36dd ci: pull from GitHub fast-forward-only — the other half of the loop
The push-mirror carries native -> GitHub. This is the return leg, so the two
forges converge from either side.

They compose rather than fight: a native commit reaches GitHub via the mirror,
so this job then sees LOCAL == REMOTE and exits "in sync"; a GitHub commit
fast-forwards native here and the resulting mirror push is a no-op. No echo,
no loop.

Fast-forward ONLY -- a divergence fails loudly instead of force-pushing either
side and destroying whichever history lost the race. Same shape hanzoai/app has
run green 299 times.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 09:15:46 -07:00
zeekayandClaude Opus 5 7f8f13a40b ci: pull from GitHub fast-forward-only — the other half of the loop
image / build (push) Successful in 1m28s
The push-mirror carries native -> GitHub. This is the return leg, so the two
forges converge from either side.

They compose rather than fight: a native commit reaches GitHub via the mirror,
so this job then sees LOCAL == REMOTE and exits "in sync"; a GitHub commit
fast-forwards native here and the resulting mirror push is a no-op. No echo,
no loop.

Fast-forward ONLY -- a divergence fails loudly instead of force-pushing either
side and destroying whichever history lost the race. Same shape hanzoai/app has
run green 299 times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:15:46 -07:00
zeekay 021179c27e chore(deps): hanzokv/go v9.21.1 -> v9.22.0
v9.22.0 renames the package identifier from redis to kv. Nothing here imports
it — it is an indirect dependency — so this is a graph bump only, keeping the
version aligned with the repos that do import it.
2026-07-26 08:54:02 -07:00
zeekay 2f5ed94a59 chore(deps): hanzokv/go v9.21.1 -> v9.22.0
v9.22.0 renames the package identifier from redis to kv. Nothing here imports
it — it is an indirect dependency — so this is a graph bump only, keeping the
version aligned with the repos that do import it.
2026-07-26 08:54:02 -07:00
zeekay e736e78017 refactor(deps): follow the KV client to github.com/hanzokv/go
The KV client moved orgs (hanzoai/kv-go -> hanzokv/go) and this module reached
it transitively via hanzoai/orm, which has already migrated. Bumping orm lets
tidy drop the old path — no source here imported either client directly, so
there was nothing to edit, only a dependency to advance.
2026-07-26 00:55:20 -07:00
zeekay 75fa62e60e refactor(deps): follow the KV client to github.com/hanzokv/go
image / build (push) Successful in 3m46s
The KV client moved orgs (hanzoai/kv-go -> hanzokv/go) and this module reached
it transitively via hanzoai/orm, which has already migrated. Bumping orm lets
tidy drop the old path — no source here imported either client directly, so
there was nothing to edit, only a dependency to advance.
2026-07-26 00:55:20 -07:00
Hanzo AI 53ab0f2bd0 ci: drop the shadowed .gitea/workflows leftover
.hanzo/workflows is already canonical here; the forge takes the FIRST existing
dir in WORKFLOW_DIRS (.hanzo before .gitea) and ignores the rest, so this file
never ran. Dead cruft — delete it. One native-CI dir, .hanzo.
2026-07-25 18:39:22 -07:00
Hanzo AI 37633091dd ci: drop the shadowed .gitea/workflows leftover
.hanzo/workflows is already canonical here; the forge takes the FIRST existing
dir in WORKFLOW_DIRS (.hanzo before .gitea) and ignores the rest, so this file
never ran. Dead cruft — delete it. One native-CI dir, .hanzo.
2026-07-25 18:39:22 -07:00
z 09f455b103 keys: pk- and sk-, never a third prefix
Every credential IAM mints is one of exactly two things, and the schema already
said so: AccessKey (pk-*) is the publishable half — frontend-safe, the hot lookup
index — and AccessSecret (sk-*) is the confidential half. `hk-` was a third
prefix meaning whichever of those two you happened to be holding, so every
consumer had to know all three: the spelling 'hk-/pk-/sk-' appears verbatim in
the gateway auth filter, the cloud audit redactor, the registry resolver, and
CapKeyResolve's own doc comment. Three names, two concepts.

Two mint sites carried it, and the second was worse than the first:

  oidc.newAccessKey       hand-rolled 'hk-' + newOpaqueToken, duplicating the
                          keys.Mint it should have called -> keys.Mint("sk").
                          A durable full-access bearer credential IS the
                          confidential half. Its (string, error) signature also
                          lied — it could not fail — so the dead branch is gone.

  provision.mintCredential
  serviceaccounts.mint    minted BOTH halves as Mint("hk") — the public lookup
                          handle and the argon2id-digested secret, identical
                          prefixes — while the comment two lines down says 'the
                          access key is a public lookup handle, not a secret'.
                          An exposed secret was indistinguishable from a harmless
                          handle at a glance, in a log or a config file. Now
                          pk- for the handle, sk- for the secret.

Forward-only and non-breaking: resolution is an exact-value lookup
(store.UserByAccessKey), never a prefix match, and the gateway filter already
accepts pk-/sk-. Keys already issued keep authenticating; every NEW key is
minted correctly. The prefix carries no authority — it is a readable label on an
opaque random token.

Tests: 24 packages ok, 0 fail. The three suites that asserted 'hk-' now assert
the half they actually receive (sk- for the user key, pk- for the service-account
handle) — they were encoding the bug.
2026-07-25 17:37:14 -07:00
z 72dd23f0c2 keys: pk- and sk-, never a third prefix
Every credential IAM mints is one of exactly two things, and the schema already
said so: AccessKey (pk-*) is the publishable half — frontend-safe, the hot lookup
index — and AccessSecret (sk-*) is the confidential half. `hk-` was a third
prefix meaning whichever of those two you happened to be holding, so every
consumer had to know all three: the spelling 'hk-/pk-/sk-' appears verbatim in
the gateway auth filter, the cloud audit redactor, the registry resolver, and
CapKeyResolve's own doc comment. Three names, two concepts.

Two mint sites carried it, and the second was worse than the first:

  oidc.newAccessKey       hand-rolled 'hk-' + newOpaqueToken, duplicating the
                          keys.Mint it should have called -> keys.Mint("sk").
                          A durable full-access bearer credential IS the
                          confidential half. Its (string, error) signature also
                          lied — it could not fail — so the dead branch is gone.

  provision.mintCredential
  serviceaccounts.mint    minted BOTH halves as Mint("hk") — the public lookup
                          handle and the argon2id-digested secret, identical
                          prefixes — while the comment two lines down says 'the
                          access key is a public lookup handle, not a secret'.
                          An exposed secret was indistinguishable from a harmless
                          handle at a glance, in a log or a config file. Now
                          pk- for the handle, sk- for the secret.

Forward-only and non-breaking: resolution is an exact-value lookup
(store.UserByAccessKey), never a prefix match, and the gateway filter already
accepts pk-/sk-. Keys already issued keep authenticating; every NEW key is
minted correctly. The prefix carries no authority — it is a readable label on an
opaque random token.

Tests: 24 packages ok, 0 fail. The three suites that asserted 'hk-' now assert
the half they actually receive (sk- for the user key, pk- for the service-account
handle) — they were encoding the bug.
2026-07-25 17:37:14 -07:00
hanzo-dev bb449eb92a ci: rename the native builder to image.yml so the legacy tag builder can be blocked
Enabling Actions on the mirror armed more than main. Mirror-synced TAGS fire
a push event (services/mirror/mirror_pull.go:357 — a new ref calls
SyncPushCommits with the tag's RefFullName), and the v1.33.x release line is
diverged from main with no .hanzo/workflows to shadow it, so a v* tag there
makes Gitea collect .github/workflows/build.yml. That file logs in as
hanzo-dev with GH_PAT, which EXISTS as a git.hanzo.ai org secret, so it would
succeed — racing GitHub Actions to push the same immutable
ghcr.io/hanzoai/iam:v<X.Y.Z> from the same commit. Two digests behind one
name, on the identity control plane.

Gitea's disable list is keyed on the workflow filename
(services/actions/notifier_helper.go: cfg.IsWorkflowDisabled(wf.EntryName)),
so `build.yml` is now disabled on this repo. This file takes a distinct name
so that block cannot silence it too — and `image` is the honest name anyway:
it builds an image, it does not deploy.

Reversible: delete the release line's builder or unify the two lines, then
re-enable build.yml.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-25 16:19:37 -07:00
Claude Opus 5 (1M context) 097fe2eaeb ci: rename the native builder to image.yml so the legacy tag builder can be blocked
Enabling Actions on the mirror armed more than main. Mirror-synced TAGS fire
a push event (services/mirror/mirror_pull.go:357 — a new ref calls
SyncPushCommits with the tag's RefFullName), and the v1.33.x release line is
diverged from main with no .hanzo/workflows to shadow it, so a v* tag there
makes Gitea collect .github/workflows/build.yml. That file logs in as
hanzo-dev with GH_PAT, which EXISTS as a git.hanzo.ai org secret, so it would
succeed — racing GitHub Actions to push the same immutable
ghcr.io/hanzoai/iam:v<X.Y.Z> from the same commit. Two digests behind one
name, on the identity control plane.

Gitea's disable list is keyed on the workflow filename
(services/actions/notifier_helper.go: cfg.IsWorkflowDisabled(wf.EntryName)),
so `build.yml` is now disabled on this repo. This file takes a distinct name
so that block cannot silence it too — and `image` is the honest name anyway:
it builds an image, it does not deploy.

Reversible: delete the release line's builder or unify the two lines, then
re-enable build.yml.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 16:19:37 -07:00
hanzo-dev 3bca5af830 ci: retire the GitHub build stub — the native builder is proven
Deleted only after the replacement was verified, not before. Evidence:
git.hanzo.ai run 394, .hanzo/workflows/build.yml, event push (mirror-sync
of 6a91a53), job `build` on git-runner-2, conclusion success — which
published ghcr.io/hanzoai/iam:sha-6a91a53, digest sha256:4d0f1ff087bc, at
2026-07-25T23:09:34Z, confirmed pullable by an independent registry
manifest fetch (HTTP 200, OCI manifest, 4 layers). First image ever built
from main since the 07-24 neutralization, and this mirror's first run ever.

The file being removed built nothing: it was `on: workflow_dispatch` with a
single echo. It also pointed callers at .hanzo/workflows/deploy.yml, a path
that no longer exists, so leaving it in place would misdirect. GitHub now
holds zero CI for this repo; the mirror pulls on its 10m interval and the
sync push fires the native build.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-25 16:10:39 -07:00
Claude Opus 5 (1M context) 83f349d9ca ci: retire the GitHub build stub — the native builder is proven
Deleted only after the replacement was verified, not before. Evidence:
git.hanzo.ai run 394, .hanzo/workflows/build.yml, event push (mirror-sync
of e71ce0f), job `build` on git-runner-2, conclusion success — which
published ghcr.io/hanzoai/iam:sha-e71ce0f, digest sha256:4d0f1ff087bc, at
2026-07-25T23:09:34Z, confirmed pullable by an independent registry
manifest fetch (HTTP 200, OCI manifest, 4 layers). First image ever built
from main since the 07-24 neutralization, and this mirror's first run ever.

The file being removed built nothing: it was `on: workflow_dispatch` with a
single echo. It also pointed callers at .hanzo/workflows/deploy.yml, a path
that no longer exists, so leaving it in place would misdirect. GitHub now
holds zero CI for this repo; the mirror pulls on its 10m interval and the
sync push fires the native build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 16:10:39 -07:00
hanzo-dev 6a91a53d2f ci: make the native iam build actually run (main built nothing since 07-24)
.github/workflows/build.yml was neutralized to a dispatch-only echo on
2026-07-24 (3ab19a5b4), handing the build to .hanzo/workflows/deploy.yml.
That file could never run, so main has shipped no image since. Measured:
main is diverged from the v1.33.x line that ships (136 ahead / 142 behind
v1.33.8); v1.33.8 came from a tag push on that other line; git.hanzo.ai had
Actions disabled on this mirror, so the native file had zero runs, ever.

Replace it with a builder that matches the forge as measured:

  runs-on hanzo-build-linux-amd64  the only label the four online act_runners
    advertise; the old hanzo-linux-amd64 matches nothing and queues forever
  buildx + build-push-action       buildctl-daemonless.sh is absent from
    catthehacker/ubuntu:act-24.04, the image this pool serves
  GHCR_USER/GHCR_TOKEN, GH_PAT     org-level secrets that exist; the old
    GIT_CLONE_TOKEN is on neither repo nor org, and the Dockerfile needs a
    token to fetch the private hanzoai modules
  no kubectl patch                 the App CR is ArgoCD-managed with selfHeal;
    rollout stays a reviewed tag pin in hanzoai/universe

Immutable sha- tag only: a re-pushed semver leaves two digests behind one
name, which is how platform's v4.4.5 came to mean two builds on 2026-07-25.

Renamed deploy.yml -> build.yml because it builds and does not deploy.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-25 16:07:13 -07:00
Claude Opus 5 (1M context) e71ce0f883 ci: make the native iam build actually run (main built nothing since 07-24)
.github/workflows/build.yml was neutralized to a dispatch-only echo on
2026-07-24 (f267b4ae8), handing the build to .hanzo/workflows/deploy.yml.
That file could never run, so main has shipped no image since. Measured:
main is diverged from the v1.33.x line that ships (136 ahead / 142 behind
v1.33.8); v1.33.8 came from a tag push on that other line; git.hanzo.ai had
Actions disabled on this mirror, so the native file had zero runs, ever.

Replace it with a builder that matches the forge as measured:

  runs-on hanzo-build-linux-amd64  the only label the four online act_runners
    advertise; the old hanzo-linux-amd64 matches nothing and queues forever
  buildx + build-push-action       buildctl-daemonless.sh is absent from
    catthehacker/ubuntu:act-24.04, the image this pool serves
  GHCR_USER/GHCR_TOKEN, GH_PAT     org-level secrets that exist; the old
    GIT_CLONE_TOKEN is on neither repo nor org, and the Dockerfile needs a
    token to fetch the private hanzoai modules
  no kubectl patch                 the App CR is ArgoCD-managed with selfHeal;
    rollout stays a reviewed tag pin in hanzoai/universe

Immutable sha- tag only: a re-pushed semver leaves two digests behind one
name, which is how platform's v4.4.5 came to mean two builds on 2026-07-25.

Renamed deploy.yml -> build.yml because it builds and does not deploy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 16:07:13 -07:00
hanzo-dev 965fb32a33 fix(ci): correct native deploy to iam image + app CR (was cross-repo clobbered) 2026-07-24 15:16:44 -07:00
hanzo-dev 1ceccc581b fix(ci): correct native deploy to iam image + app CR (was cross-repo clobbered) 2026-07-24 15:16:44 -07:00
hanzo-dev 3ab19a5b4c ci: neutralize GitHub build to sync-notice — native pipeline is .hanzo/workflows/deploy.yml 2026-07-24 15:16:00 -07:00
hanzo-dev f267b4ae89 ci: neutralize GitHub build to sync-notice — native pipeline is .hanzo/workflows/deploy.yml 2026-07-24 15:16:00 -07:00
hanzo-dev e47933458c ci: native Hanzo deploy pipeline (BuildKit → ghcr → operator reconcile) 2026-07-24 15:15:51 -07:00
hanzo-dev ac1fff02cb ci: native Hanzo deploy pipeline (BuildKit → ghcr → operator reconcile) 2026-07-24 15:15:51 -07:00
z 5efd5f9ddc docs: modernize + LLM.md + cross-links (one-way SDK model) 2026-07-24 12:39:20 -07:00
z 3f61b7a269 docs: modernize + LLM.md + cross-links (one-way SDK model) 2026-07-24 12:39:20 -07:00
z 53c8787cd3 docs: modernize + LLM.md + cross-links (one-way SDK model) 2026-07-24 12:38:53 -07:00
z b14b8636a3 docs: modernize + LLM.md + cross-links (one-way SDK model) 2026-07-24 12:38:53 -07:00
zeekay 643f9464d5 merge: pin iam-v1 security invariants as regression guards (impersonation + SCIM oracle)
The clean rewrite already enforces all 3 iam-v1 hardening invariants by construction
(no ?userId override; app principals never admin/super; SCIM re-pins to caller org
before read). These mutation-proven tests PIN them so a future refactor can't
silently reopen the escalation / cross-org existence-oracle classes. Test-only.
2026-07-23 19:52:40 -07:00
zeekay 726b4f2e80 merge: pin iam-v1 security invariants as regression guards (impersonation + SCIM oracle)
The clean rewrite already enforces all 3 iam-v1 hardening invariants by construction
(no ?userId override; app principals never admin/super; SCIM re-pins to caller org
before read). These mutation-proven tests PIN them so a future refactor can't
silently reopen the escalation / cross-org existence-oracle classes. Test-only.
2026-07-23 19:52:40 -07:00
zeekayandhanzo-dev b2c2f84928 security(iam2): port iam-v1 app-impersonation + SCIM read-scope hardening as regression guards
Ports the security INVARIANTS of three iam-v1 (Casdoor fork) fixes into the clean
iam2 rewrite. The rewrite's architecture already ENFORCES all three by construction
(no ?userId override exists; an app principal is never Admin/Super; SCIM re-pins the
owner to the caller's org before any store read) — so this adds the missing
regression guards that pin each invariant to the surface that now enforces it, not
new enforcement code.

- impersonation (iam-v1 c904dc0a + 0e5485a5): the ?userId override is GONE
  (userinfo/whoami/get-account take the subject from the verified JWT sub). The
  analogue "act as an arbitrary named user" surface is the confidential-client mint
  (issue-user-token / mint-user-keys, ?id=<owner>/<name>); its escalation block is
  mintTarget's reserved-org gate. TestImpersonation_* proves a general minter cannot
  reach an admin-org (SuperAdmin) target, and that the admin-mint capability is the
  sole boundary that can. Mutation-verified: deleting the gate mints a token with
  sub=admin/z (the exact iam-v1 super spoof) and the test fails.

- SCIM read-scope + existence oracle (iam-v1 da0732a1): scopedTarget re-pins a
  non-super's owner to its own org on every verb, so list/count are org-scoped and a
  foreign row is never addressed. TestRed_scim* proves a foreign-existing id, a
  foreign-missing id, and an own-missing id are the identical 404 (no 404-vs-403
  cross-org existence oracle) and that cross-org DELETE/PATCH never reach the row.
  Mutation-verified: skipping the re-scope makes a foreign row distinguishable and
  the test fails.

Test-only; no production code changed. Build + full suite green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-23 19:51:39 -07:00
zeekayandClaude Opus 4.8 1fee458553 security(iam2): port iam-v1 app-impersonation + SCIM read-scope hardening as regression guards
Ports the security INVARIANTS of three iam-v1 (Casdoor fork) fixes into the clean
iam2 rewrite. The rewrite's architecture already ENFORCES all three by construction
(no ?userId override exists; an app principal is never Admin/Super; SCIM re-pins the
owner to the caller's org before any store read) — so this adds the missing
regression guards that pin each invariant to the surface that now enforces it, not
new enforcement code.

- impersonation (iam-v1 c904dc0a + 0e5485a5): the ?userId override is GONE
  (userinfo/whoami/get-account take the subject from the verified JWT sub). The
  analogue "act as an arbitrary named user" surface is the confidential-client mint
  (issue-user-token / mint-user-keys, ?id=<owner>/<name>); its escalation block is
  mintTarget's reserved-org gate. TestImpersonation_* proves a general minter cannot
  reach an admin-org (SuperAdmin) target, and that the admin-mint capability is the
  sole boundary that can. Mutation-verified: deleting the gate mints a token with
  sub=admin/z (the exact iam-v1 super spoof) and the test fails.

- SCIM read-scope + existence oracle (iam-v1 da0732a1): scopedTarget re-pins a
  non-super's owner to its own org on every verb, so list/count are org-scoped and a
  foreign row is never addressed. TestRed_scim* proves a foreign-existing id, a
  foreign-missing id, and an own-missing id are the identical 404 (no 404-vs-403
  cross-org existence oracle) and that cross-org DELETE/PATCH never reach the row.
  Mutation-verified: skipping the re-scope makes a foreign row distinguishable and
  the test fails.

Test-only; no production code changed. Build + full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:51:39 -07:00
hanzo-dev 4964e03b3f feat(keys): write-only publishable pk-; sever pk- from principal resolution
A publishable pk- is public (it ships in client JS) and must authenticate no
read. Today store.UserByAccessKey resolves a pk- to its owning user, so
get-user?accessKey and the registry token path turn a public key into a read
principal. Remove the pk- branch: only the secret hk-/sk- shapes resolve to a
user; a pk- falls through to ErrNotFound on every principal path (get-user AND
registry), so a public key authenticates no read anywhere.

Add the write-only publishable key as a Scope on schema.Key. KeyScopePublish
mints a pk- half only (no sk-, forced empty on create and update), and its ONLY
resolution is org-only: store.PublishableKeyByAccessKey backs a new
GET /v1/iam/resolve-key, gated by CapPublishableResolve (its own least-privilege
capability, narrower than CapKeyResolve which discloses principals). It is the
dual of get-user?accessKey, co-located in compat.

Resolve contract cloud consumes:
  GET /v1/iam/resolve-key?accessKey=<pk->  (client_secret_basic, CapPublishableResolve)
  -> 200 {status:ok, data:{org:<owner>, scope:"publish"}}   live publishable key
  -> 200 {status:error, msg:"the entity does not exist"}    anything else

Tests prove a pk- never becomes a principal via UserByAccessKey,
get-user?accessKey (even for a CapKeyResolve holder), the registry, or as a
bearer; resolve-key returns org-only and refuses a secret key's pk- half, an
sk-, an expired/unknown key, a non-cap app, and a human; a publishable key mints
a pk- only.
2026-07-23 10:56:36 -07:00
hanzo-dev 01ab0897d8 feat(onboard): pre-pay self-service provisioning (idempotent, atomic, no trial)
Self-service onboarding provisions a tenant atomically through the ONE
provision() primitive: personal org (== billing account) + founding user as
its own admin + ONE org-scoped, HASHED, metered service-account credential.
Backend-portable resume (Founder stamp) converges an interrupted signup on
any backend; provision-not-promote (store.IsReservedOrg guard) blocks the
reserved admin org; first-run gate + caller re-resolution.

Exposed as the self-service POST /v1/iam/onboard and the service-token
POST /v1/iam/admin/provision (the ONE atomic op the cloud orchestrator calls
instead of a create-org + move-user pair). The tenant credential is a
service account (hk- key + argon2id secret digest, shown once) — no plaintext
at rest. The unified service-token validator lives in httpx.ServiceTokenAuth
(shared with bootstrap).

Pre-pay model: NO trial credit. A provisioned org starts at a ZERO balance;
the metering gate refuses metered requests until it pre-pays, then debits.

Integrated onto origin/main; adopts main's store.IsReservedOrg (ONE reserved
set) and schema.User.Id (surrogate via user.Model.Id() for the Founder stamp).
Squash of the reviewed pre-pay series f35d3d889..be36295a6 (red verdict SHIP),
replayed clean off a local junk base (a 239MB iamd binary + node_modules that
must never reach origin).

Tests: provision happy-path/idempotent/atomic-resume/founder-fence/tenant-
isolation/reserved-refusal + the service-token endpoint, all green.
2026-07-23 03:03:17 -07:00
hanzo-dev 1a79e574d6 feat(onboard): pre-pay self-service provisioning (idempotent, atomic, no trial)
Self-service onboarding provisions a tenant atomically through the ONE
provision() primitive: personal org (== billing account) + founding user as
its own admin + ONE org-scoped, HASHED, metered service-account credential.
Backend-portable resume (Founder stamp) converges an interrupted signup on
any backend; provision-not-promote (store.IsReservedOrg guard) blocks the
reserved admin org; first-run gate + caller re-resolution.

Exposed as the self-service POST /v1/iam/onboard and the service-token
POST /v1/iam/admin/provision (the ONE atomic op the cloud orchestrator calls
instead of a create-org + move-user pair). The tenant credential is a
service account (hk- key + argon2id secret digest, shown once) — no plaintext
at rest. The unified service-token validator lives in httpx.ServiceTokenAuth
(shared with bootstrap).

Pre-pay model: NO trial credit. A provisioned org starts at a ZERO balance;
the metering gate refuses metered requests until it pre-pays, then debits.

Integrated onto origin/main; adopts main's store.IsReservedOrg (ONE reserved
set) and schema.User.Id (surrogate via user.Model.Id() for the Founder stamp).
Squash of the reviewed pre-pay series f35d3d889..be36295a6 (red verdict SHIP),
replayed clean off a local junk base (a 239MB iamd binary + node_modules that
must never reach origin).

Tests: provision happy-path/idempotent/atomic-resume/founder-fence/tenant-
isolation/reserved-refusal + the service-token endpoint, all green.
2026-07-23 03:03:17 -07:00
Hanzo AI 2d2d05ad07 fix(oidc): burn the federation state atomically (I1 — TakeChallenge twin)
SaveFederationState burned the single-use federation transaction via a non-atomic
Get -> set Used -> Update (federationCallbackHandler): two concurrent callbacks on one
state could both observe Used=false and both complete the same federated login — the
same lost-update/TOCTOU class fixed for TakeChallenge in ITEM 4. Exploitability is low
(browser-cookie-bound, same-user, short TTL; a double-burn just re-completes the same
login, no identity crossing), but it is the exact twin and must not be left non-atomic.

Replace SaveFederationState with store.BurnFederationState: resolve the row's real key
via the (owner,name) query path, then find-and-burn inside a GetForUpdate transaction
(mirrors the wallet challenge burn / TakeChallenge). Refusals (gone/used/expired/race)
roll back with the opaque ErrFederationConsumed; a store fault returns the raw error so
the caller keeps its distinct 'internal error' message. The bind-cookie (CSRF) check
still runs on the prior read BEFORE the burn, so a CSRF-failed replay never burns a
victim's pending state. SaveFederationState deleted (no other callers).

Test TestBurnFederationState_concurrentBurn_exactlyOneWinner: 16 parallel burns on one
state yield EXACTLY one winner. FAILS before (3-7 winners), PASSES after (-race).
2026-07-23 02:07:36 -07:00
Hanzo AI 9a0dde3b55 fix(oidc): burn the federation state atomically (I1 — TakeChallenge twin)
build / docker (push) Successful in 1m14s
SaveFederationState burned the single-use federation transaction via a non-atomic
Get -> set Used -> Update (federationCallbackHandler): two concurrent callbacks on one
state could both observe Used=false and both complete the same federated login — the
same lost-update/TOCTOU class fixed for TakeChallenge in ITEM 4. Exploitability is low
(browser-cookie-bound, same-user, short TTL; a double-burn just re-completes the same
login, no identity crossing), but it is the exact twin and must not be left non-atomic.

Replace SaveFederationState with store.BurnFederationState: resolve the row's real key
via the (owner,name) query path, then find-and-burn inside a GetForUpdate transaction
(mirrors the wallet challenge burn / TakeChallenge). Refusals (gone/used/expired/race)
roll back with the opaque ErrFederationConsumed; a store fault returns the raw error so
the caller keeps its distinct 'internal error' message. The bind-cookie (CSRF) check
still runs on the prior read BEFORE the burn, so a CSRF-failed replay never burns a
victim's pending state. SaveFederationState deleted (no other callers).

Test TestBurnFederationState_concurrentBurn_exactlyOneWinner: 16 parallel burns on one
state yield EXACTLY one winner. FAILS before (3-7 winners), PASSES after (-race).
2026-07-23 02:07:36 -07:00
Hanzo AI 3a0a768ce3 fix(iam): update-user must not zero the lockout counter (F-6)
users.API.Update (POST /v1/iam/update-user, the canonical admin profile edit) is a
full-row write that preserved Id/CreatedTime/password from the stored row but NOT
SigninWrongTimes/LastSigninWrongTime. A client body omitting signinWrongTimes (or
sending 0) therefore overwrote a LOCKED account's counter to 0 — an org-admin's routine
profile edit silently unlocked a user mid-attack. Authz is org-admin/SuperAdmin (not
anonymous), so LOW, but it is a real hole in the ITEM-2 invariant: recordAttempt must be
the ONLY writer of the lockout counters.

Fix: carry both lockout fields from the stored row and ignore the body value, symmetric
with the server-owned Id/CreatedTime block two lines up.

Parity-suite check: no test asserts a client-settable signinWrongTimes — every
SigninWrongTimes assertion verifies the lockout MECHANISM (increment/reset/threshold),
all preserved. Nothing to remove.

Test TestUpdate_preservesLockoutCounter: lock the account, then update-user with a
zero-value counter body; counter must stay >= threshold. FAILS before (0), PASSES after.
2026-07-23 02:04:21 -07:00
Hanzo AI f3f66ac02a fix(iam): update-user must not zero the lockout counter (F-6)
users.API.Update (POST /v1/iam/update-user, the canonical admin profile edit) is a
full-row write that preserved Id/CreatedTime/password from the stored row but NOT
SigninWrongTimes/LastSigninWrongTime. A client body omitting signinWrongTimes (or
sending 0) therefore overwrote a LOCKED account's counter to 0 — an org-admin's routine
profile edit silently unlocked a user mid-attack. Authz is org-admin/SuperAdmin (not
anonymous), so LOW, but it is a real hole in the ITEM-2 invariant: recordAttempt must be
the ONLY writer of the lockout counters.

Fix: carry both lockout fields from the stored row and ignore the body value, symmetric
with the server-owned Id/CreatedTime block two lines up.

Parity-suite check: no test asserts a client-settable signinWrongTimes — every
SigninWrongTimes assertion verifies the lockout MECHANISM (increment/reset/threshold),
all preserved. Nothing to remove.

Test TestUpdate_preservesLockoutCounter: lock the account, then update-user with a
zero-value counter body; counter must stay >= threshold. FAILS before (0), PASSES after.
2026-07-23 02:04:21 -07:00
Hanzo AI c053004a89 fix(oidc): burn the login challenge atomically + correct the 2FA throttle note (ITEM 4)
TakeChallenge did Get -> set Used=true -> UpdateCtx, a non-atomic read-modify-write:
two concurrent finishMfa calls on ONE captured passcode could both observe Used=false
and both win, double-spending the challenge (the F-D1 lost-update/TOCTOU class). Move
the find-and-burn inside a GetForUpdate transaction — the row lock is held from the
read through the Used=true write, so exactly one caller wins — mirroring the wallet
challenge burn. Every refusal still collapses to the one opaque ErrChallenge.

Correct the mfa_gate deferral note: it credited 'the atomic password lockout' for
throttling the second factor, but the MFA threat model assumes the password is KNOWN
and a CORRECT password RESETS the lockout (never trips it), so the password lockout
provides NO throttle on minting fresh challenges. The real friction is the argon2id
cost per fresh challenge + the 30s TOTP window (a moving 6-digit target). The note now
states that truthfully and records that the burn is now atomic.

Test: TestTakeChallenge_concurrentBurn_exactlyOneWinner — 16 parallel TakeChallenge on
one id yield EXACTLY one winner. FAILS before (8-11 winners), PASSES after (-race).
2026-07-23 01:40:14 -07:00
Hanzo AI 5150fae578 fix(oidc): burn the login challenge atomically + correct the 2FA throttle note (ITEM 4)
TakeChallenge did Get -> set Used=true -> UpdateCtx, a non-atomic read-modify-write:
two concurrent finishMfa calls on ONE captured passcode could both observe Used=false
and both win, double-spending the challenge (the F-D1 lost-update/TOCTOU class). Move
the find-and-burn inside a GetForUpdate transaction — the row lock is held from the
read through the Used=true write, so exactly one caller wins — mirroring the wallet
challenge burn. Every refusal still collapses to the one opaque ErrChallenge.

Correct the mfa_gate deferral note: it credited 'the atomic password lockout' for
throttling the second factor, but the MFA threat model assumes the password is KNOWN
and a CORRECT password RESETS the lockout (never trips it), so the password lockout
provides NO throttle on minting fresh challenges. The real friction is the argon2id
cost per fresh challenge + the 30s TOTP window (a moving 6-digit target). The note now
states that truthfully and records that the burn is now atomic.

Test: TestTakeChallenge_concurrentBurn_exactlyOneWinner — 16 parallel TakeChallenge on
one id yield EXACTLY one winner. FAILS before (8-11 winners), PASSES after (-race).
2026-07-23 01:40:14 -07:00
Hanzo AI 31de2d481c docs(iam): correct user storage-key drift — surrogate id, not (Owner,Name)/int64 (ITEM 3)
Two comments mis-stated the key shape, so a reviewer reasoning about a locked write
would assume the wrong key:
 - internal/users/lockout.go said the users.Create'd key is 'an auto-int64';
 - internal/schema/user.go said the orm storage id IS '(Owner,Name)' / the OIDC sub.

The actual Create-shape storage key is a store-assigned surrogate id — a GenerateID
DECIMAL STRING (e.g. "17847909129933610000001") — because Create allocates rather than
pinning a key; only MIGRATED casdoor rows are keyed 'owner/name' (SetId). (Owner,Name)
is the natural/QUERY key, not necessarily the storage key. Both comments now state the
real shapes and point at the one correct resolution (Key().Encode(), see updateUser).
No behavior change.
2026-07-23 01:38:46 -07:00
Hanzo AI bc0eef00a7 docs(iam): correct user storage-key drift — surrogate id, not (Owner,Name)/int64 (ITEM 3)
Two comments mis-stated the key shape, so a reviewer reasoning about a locked write
would assume the wrong key:
 - internal/users/lockout.go said the users.Create'd key is 'an auto-int64';
 - internal/schema/user.go said the orm storage id IS '(Owner,Name)' / the OIDC sub.

The actual Create-shape storage key is a store-assigned surrogate id — a GenerateID
DECIMAL STRING (e.g. "17847909129933610000001") — because Create allocates rather than
pinning a key; only MIGRATED casdoor rows are keyed 'owner/name' (SetId). (Owner,Name)
is the natural/QUERY key, not necessarily the storage key. Both comments now state the
real shapes and point at the one correct resolution (Key().Encode(), see updateUser).
No behavior change.
2026-07-23 01:38:46 -07:00
Hanzo AI f9390d0d30 fix(oidc): route onboard + preferences through updateUser — no lockout clobber (ITEM 2)
onboard and update-preferences wrote the whole user row via user.UpdateCtx on the
snapshot loaded at handler entry. If a wrong-password lockout increment
(users.recordAttempt) landed between that load and the write, the stale full-row write
erased it — Red PoC4 dropped SigninWrongTimes 5->0, unlocking the account. All such
writers are authenticated (own-row self-service, or a privileged minter), so this is
LOW, but the durable fix is to keep the counter off the full-row-write path.

Route both through updateUser (introduced for ITEM 1): the row is read FRESH under a
GetForUpdate row lock and written back, so the lockout counter is carried from the
current committed value, never a stale snapshot — the same row lock recordAttempt
takes, so the two serialize. preferences additionally now merges against the FRESH
stored blob under the lock, so a concurrent product/device setting a DIFFERENT key is
preserved too (the shallow-merge promise is now actually delivered under concurrency).

Narrow recordAttempt's 'collateral clobber removed' note to the truth: its own write is
lost-update-free within its lock window; cross-writer safety comes from routing every
other user-row writer through the SAME lock (updateUser).

Tests (internal/oidc/user_test.go):
 - TestUpdateUser_preservesConcurrentLockoutCount: deterministic differential — a CONTROL
   branch reproduces the pre-fix stale full-row write RESETTING the counter to 0, the FIX
   branch shows updateUser PRESERVING it (fail-before/pass-after in one permanent test).
 - TestUpdateUser_concurrentWithLockIncrement_exactCount: -race, N updateUser writes
   racing N atomic increments leave the counter at EXACTLY N (no lost update).
2026-07-23 01:37:51 -07:00
Hanzo AI 8ec528591d fix(oidc): route onboard + preferences through updateUser — no lockout clobber (ITEM 2)
onboard and update-preferences wrote the whole user row via user.UpdateCtx on the
snapshot loaded at handler entry. If a wrong-password lockout increment
(users.recordAttempt) landed between that load and the write, the stale full-row write
erased it — Red PoC4 dropped SigninWrongTimes 5->0, unlocking the account. All such
writers are authenticated (own-row self-service, or a privileged minter), so this is
LOW, but the durable fix is to keep the counter off the full-row-write path.

Route both through updateUser (introduced for ITEM 1): the row is read FRESH under a
GetForUpdate row lock and written back, so the lockout counter is carried from the
current committed value, never a stale snapshot — the same row lock recordAttempt
takes, so the two serialize. preferences additionally now merges against the FRESH
stored blob under the lock, so a concurrent product/device setting a DIFFERENT key is
preserved too (the shallow-merge promise is now actually delivered under concurrency).

Narrow recordAttempt's 'collateral clobber removed' note to the truth: its own write is
lost-update-free within its lock window; cross-writer safety comes from routing every
other user-row writer through the SAME lock (updateUser).

Tests (internal/oidc/user_test.go):
 - TestUpdateUser_preservesConcurrentLockoutCount: deterministic differential — a CONTROL
   branch reproduces the pre-fix stale full-row write RESETTING the counter to 0, the FIX
   branch shows updateUser PRESERVING it (fail-before/pass-after in one permanent test).
 - TestUpdateUser_concurrentWithLockIncrement_exactCount: -race, N updateUser writes
   racing N atomic increments leave the counter at EXACTLY N (no lost update).
2026-07-23 01:37:51 -07:00
Hanzo AI 9fadbde0ab fix(oidc): mint/revoke user keys resolve the real storage key, both row shapes (ITEM 1)
saveUser resolved a user row by orm.Get(owner+"/"+name), which only matches a
MIGRATED casdoor row (owner/name storage key). A v2-native users.Create'd user gets
a store-assigned surrogate key (GenerateID decimal string) + a UUID sub, so the
owner/name lookup MISSED it — every hk- key mint/revoke for an account created after
the cutover errored (orm.ErrNotFound -> 500). Migrated users worked; new signups did not.

Introduce updateUser: resolve the row's REAL storage key via the (owner,name) query
path (store.GetUserByName(...).Key().Encode(), both shapes), then read-modify-write it
inside a GetForUpdate transaction (mirrors recordAttempt / the wallet challenge burn).
mutate edits the FRESH locked row in place; the whole fresh row is written back, so
fields the caller does not touch — notably the lockout counters — are carried from the
current committed value, not a stale snapshot (this also removes the counter clobber for
these paths, closed fully for onboard/preferences in the ITEM 2 commit).

Route all four saveUser callers through it (mint, revoke, federated link, federated
unlink) and delete saveUser — one and only one way to write back a user row.

Test: TestMintRevokeUserKeys_createPathUser_persists drives mint AND revoke against a
canonical users.Create'd user; FAILS before (mint 500), PASSES after.
2026-07-23 01:25:35 -07:00
Hanzo AI 616165d09a fix(oidc): mint/revoke user keys resolve the real storage key, both row shapes (ITEM 1)
saveUser resolved a user row by orm.Get(owner+"/"+name), which only matches a
MIGRATED casdoor row (owner/name storage key). A v2-native users.Create'd user gets
a store-assigned surrogate key (GenerateID decimal string) + a UUID sub, so the
owner/name lookup MISSED it — every hk- key mint/revoke for an account created after
the cutover errored (orm.ErrNotFound -> 500). Migrated users worked; new signups did not.

Introduce updateUser: resolve the row's REAL storage key via the (owner,name) query
path (store.GetUserByName(...).Key().Encode(), both shapes), then read-modify-write it
inside a GetForUpdate transaction (mirrors recordAttempt / the wallet challenge burn).
mutate edits the FRESH locked row in place; the whole fresh row is written back, so
fields the caller does not touch — notably the lockout counters — are carried from the
current committed value, not a stale snapshot (this also removes the counter clobber for
these paths, closed fully for onboard/preferences in the ITEM 2 commit).

Route all four saveUser callers through it (mint, revoke, federated link, federated
unlink) and delete saveUser — one and only one way to write back a user row.

Test: TestMintRevokeUserKeys_createPathUser_persists drives mint AND revoke against a
canonical users.Create'd user; FAILS before (mint 500), PASSES after.
2026-07-23 01:25:35 -07:00
Hanzo AI f8c6e974ac docs(oidc): note the deferred second-factor lockout + its structural throttle (F-D1 INFO)
The passcode/recovery verify in finishMfa has no dedicated per-account counter,
but it is not an unthrottled oracle: the MFA challenge is single-use (TakeChallenge
burns it before the verify), so each guess needs a fresh challenge, which needs a
fresh first-factor password auth — now rate-limited by the atomic lockout (F-D1).
A dedicated second-factor counter is a separate, tested change, deliberately not
folded into this rework to avoid destabilizing the MFA path.
2026-07-23 00:03:10 -07:00
Hanzo AI 1383f7a4cd docs(oidc): note the deferred second-factor lockout + its structural throttle (F-D1 INFO)
build / docker (push) Successful in 1m11s
The passcode/recovery verify in finishMfa has no dedicated per-account counter,
but it is not an unthrottled oracle: the MFA challenge is single-use (TakeChallenge
burns it before the verify), so each guess needs a fresh challenge, which needs a
fresh first-factor password auth — now rate-limited by the atomic lockout (F-D1).
A dedicated second-factor counter is a separate, tested change, deliberately not
folded into this rework to avoid destabilizing the MFA path.
2026-07-23 00:03:10 -07:00
Hanzo AI 19edcefa82 fix(registry): stop the reserved-org password walk coupling orgs + unauth SuperAdmin lockout DoS (F-2 MEDIUM) [SECURITY]
userByPassword looped {admin, hanzo} calling users.Authenticate per org, so a
single wrong docker-login on a name present in BOTH orgs (z@hanzo.ai collides
across admin and hanzo) incremented BOTH rows — locking in ~3 requests not 5 —
and a correct hanzo/<name> login (wrong for admin/<name>) bumped admin/<name>
every use. On a PUBLIC unauthenticated endpoint this let an anonymous caller lock
the platform SuperAdmin out of every password door (login/ROPC/registry share the
one row counter) in five wrong tries, and offered a low-throttle brute-force
oracle for the super's password.

Skip the reserved candidate org in the password walk: a reserved-org
(SuperAdmin/built-in/service) principal is no longer authenticated by a guessable
WEB PASSWORD on the public registry realm — it pushes with its HIGH-ENTROPY
machine credential (API key via userByKey, or service account) which are
unchanged and are the documented CI/SuperAdmin push identity. That both (a)
removes the reserved-org password from the lockout path entirely — no unauth
account-lock DoS and no brute-force oracle on the super — and (b) collapses the
walk to the single non-reserved candidate, so a wrong attempt drives at most ONE
row's counter (login-parity, no double-speed) and a correct hanzo/<name> password
can never touch admin/<name>'s counter (no cross-org coupling).

PARITY: casdoor's registry path resolved {admin, hanzo} passwords with NO lockout
at all, so the per-account lock on this endpoint is NEW surface (added by F-D1);
narrowing the PASSWORD path to non-reserved orgs is a deliberate, tested
hardening of that new surface, aligned with ROPC which already refuses reserved-
org password grants (token.go). API-key and service-account paths are untouched.

Tests (internal/registry/registry_test.go), fail-before / pass-after:
- SuperAdminPassword_Denied: correct admin/z password -> 401 (pre-fix: 200+token).
- AdminPassword_NotDosableOnPublicRegistry: flood of 3x wrongs leaves admin/root
  counter at 0 (pre-fix: 5 -> locked, DoS).
- RegistryPassword_NoCrossOrgCoupling: one wrong 'z' attempt bumps ONLY hanzo/z
  (pre-fix: admin/z also 1); correct hanzo pw auths hanzo/z, admin/z untouched.
- SuperAdminKey_CanPush: SuperAdmin still pushes via API key (privileged).
go build ./... && go vet ./... && go test ./... green.
2026-07-23 00:01:25 -07:00
Hanzo AI ed7dc4d333 fix(registry): stop the reserved-org password walk coupling orgs + unauth SuperAdmin lockout DoS (F-2 MEDIUM) [SECURITY]
userByPassword looped {admin, hanzo} calling users.Authenticate per org, so a
single wrong docker-login on a name present in BOTH orgs (z@hanzo.ai collides
across admin and hanzo) incremented BOTH rows — locking in ~3 requests not 5 —
and a correct hanzo/<name> login (wrong for admin/<name>) bumped admin/<name>
every use. On a PUBLIC unauthenticated endpoint this let an anonymous caller lock
the platform SuperAdmin out of every password door (login/ROPC/registry share the
one row counter) in five wrong tries, and offered a low-throttle brute-force
oracle for the super's password.

Skip the reserved candidate org in the password walk: a reserved-org
(SuperAdmin/built-in/service) principal is no longer authenticated by a guessable
WEB PASSWORD on the public registry realm — it pushes with its HIGH-ENTROPY
machine credential (API key via userByKey, or service account) which are
unchanged and are the documented CI/SuperAdmin push identity. That both (a)
removes the reserved-org password from the lockout path entirely — no unauth
account-lock DoS and no brute-force oracle on the super — and (b) collapses the
walk to the single non-reserved candidate, so a wrong attempt drives at most ONE
row's counter (login-parity, no double-speed) and a correct hanzo/<name> password
can never touch admin/<name>'s counter (no cross-org coupling).

PARITY: casdoor's registry path resolved {admin, hanzo} passwords with NO lockout
at all, so the per-account lock on this endpoint is NEW surface (added by F-D1);
narrowing the PASSWORD path to non-reserved orgs is a deliberate, tested
hardening of that new surface, aligned with ROPC which already refuses reserved-
org password grants (token.go). API-key and service-account paths are untouched.

Tests (internal/registry/registry_test.go), fail-before / pass-after:
- SuperAdminPassword_Denied: correct admin/z password -> 401 (pre-fix: 200+token).
- AdminPassword_NotDosableOnPublicRegistry: flood of 3x wrongs leaves admin/root
  counter at 0 (pre-fix: 5 -> locked, DoS).
- RegistryPassword_NoCrossOrgCoupling: one wrong 'z' attempt bumps ONLY hanzo/z
  (pre-fix: admin/z also 1); correct hanzo pw auths hanzo/z, admin/z untouched.
- SuperAdminKey_CanPush: SuperAdmin still pushes via API key (privileged).
go build ./... && go vet ./... && go test ./... green.
2026-07-23 00:01:25 -07:00
Hanzo AI 5d5e9836db fix(iam): make the lockout counter increment atomic under concurrency (F-D1 HIGH) [SECURITY]
The failed-attempt counter was read at handler entry, bumped in memory, and
persisted with a full-row write. A generation of C concurrent wrong attempts
each captured the same pre-increment snapshot, so all C persisted snapshot+1 —
C parallel guesses advanced the counter by only ONE. The account never locked
and the online brute-force oracle F-D1 exists to kill re-opened (Red PoC: 16
parallel wrongs -> counter 1, correct password still accepted).

Drive the increment inside a ROW-LOCKED transaction (recordAttempt): resolve the
row's real storage key via the (owner,name) query path — correct for both the
auto-int64 users.Create shape and the migrated owner/name shape — then
orm.GetForUpdate under RunInTransaction takes an exclusive row lock, re-reads the
FRESH counter, and writes back from it. C serialized wrongs now advance by
exactly C. Mechanism mirrors the wallet challenge-burn CAS (internal/wallet):
the STORE transaction serializes, not a process-local mutex — correct under the
single-writer SQLite topology today (MaxOpenConns(1) + write mutex held for the
full tx) AND a shared SQL backend under N replicas tomorrow (SELECT FOR UPDATE).
The locked read+write also removes the collateral full-row clobber: no concurrent
unrelated-field update can land between the read and the write.

The argon2id/bcrypt verify stays OUTSIDE the transaction, so a login never holds
the write lock across a password hash.

Tests (internal/users/lockout_test.go), fail-before / pass-after:
- ConcurrentWrongPasswords_NoLostUpdates: C(<threshold) parallel wrongs advance
  the counter by EXACTLY C (pre-fix: 1).
- ConcurrentFlood_Locks: C=32 parallel wrongs LOCK the account; correct password
  refused (pre-fix: never locks).
- SequentialLockAndReset: single-threaded lock/reset/no-early-unlock preserved.
go build ./... && go vet ./... && go test ./... green (-race on users).
2026-07-22 23:58:23 -07:00
Hanzo AI 259f9def3d fix(iam): make the lockout counter increment atomic under concurrency (F-D1 HIGH) [SECURITY]
The failed-attempt counter was read at handler entry, bumped in memory, and
persisted with a full-row write. A generation of C concurrent wrong attempts
each captured the same pre-increment snapshot, so all C persisted snapshot+1 —
C parallel guesses advanced the counter by only ONE. The account never locked
and the online brute-force oracle F-D1 exists to kill re-opened (Red PoC: 16
parallel wrongs -> counter 1, correct password still accepted).

Drive the increment inside a ROW-LOCKED transaction (recordAttempt): resolve the
row's real storage key via the (owner,name) query path — correct for both the
auto-int64 users.Create shape and the migrated owner/name shape — then
orm.GetForUpdate under RunInTransaction takes an exclusive row lock, re-reads the
FRESH counter, and writes back from it. C serialized wrongs now advance by
exactly C. Mechanism mirrors the wallet challenge-burn CAS (internal/wallet):
the STORE transaction serializes, not a process-local mutex — correct under the
single-writer SQLite topology today (MaxOpenConns(1) + write mutex held for the
full tx) AND a shared SQL backend under N replicas tomorrow (SELECT FOR UPDATE).
The locked read+write also removes the collateral full-row clobber: no concurrent
unrelated-field update can land between the read and the write.

The argon2id/bcrypt verify stays OUTSIDE the transaction, so a login never holds
the write lock across a password hash.

Tests (internal/users/lockout_test.go), fail-before / pass-after:
- ConcurrentWrongPasswords_NoLostUpdates: C(<threshold) parallel wrongs advance
  the counter by EXACTLY C (pre-fix: 1).
- ConcurrentFlood_Locks: C=32 parallel wrongs LOCK the account; correct password
  refused (pre-fix: never locks).
- SequentialLockAndReset: single-threaded lock/reset/no-early-unlock preserved.
go build ./... && go vet ./... && go test ./... green (-race on users).
2026-07-22 23:58:23 -07:00
Hanzo AI 93bad2224c docs(iam): note the owner/name-subject divergence in wallet + service-account create (F-A1 INFO)
wallet.provision and serviceaccounts create construct the User directly and
SetId(owner/name), so their token sub is the natural key, not a minted UUID — a
deliberate divergence from the 'sub is always a UUID' invariant. Documented in-code as
NOT an impersonation vector (owner is server-set, name deterministic, no client Id;
store.GetUserById fails closed on empty/duplicate Id) and DEFERRED: minting a UUID
would change the wallet re-login / M2M subject shape, a migration decision, not a
security-rework edit. No behavior change.
2026-07-22 23:20:16 -07:00
Hanzo AI b2e270c73d docs(iam): note the owner/name-subject divergence in wallet + service-account create (F-A1 INFO)
wallet.provision and serviceaccounts create construct the User directly and
SetId(owner/name), so their token sub is the natural key, not a minted UUID — a
deliberate divergence from the 'sub is always a UUID' invariant. Documented in-code as
NOT an impersonation vector (owner is server-set, name deterministic, no client Id;
store.GetUserById fails closed on empty/duplicate Id) and DEFERRED: minting a UUID
would change the wallet re-login / M2M subject shape, a migration decision, not a
security-rework edit. No behavior change.
2026-07-22 23:20:16 -07:00
Hanzo AI fdf3de8745 fix(oidc): reserved-org gate at the login grant tail (F-D2 login twin) [SECURITY]
login.go was the one credential door that omitted the store.IsReservedOrg refuse
signup.go and the ROPC grant enforce. A shared or org-choice app (whose tenant gate
accepts a user from ANY org) would mint a real SuperAdmin authorization code — or bare
session — on the correct admin password: POST /v1/iam/login type=code via such an app
resolved admin/<super> and returned a live grant.

Gate a RESERVED-org principal to an app that itself SERVES that reserved org (the
dedicated console). Placed at loginGrant ahead of the bare-session and type=code
branches — the ONE tail the credential post and the second-factor finish share — so
every credential grant shape is bound identically. The dedicated admin-console
(Organization=="admin") still signs the SuperAdmin in; a normal tenant never triggers
it, so shared/org-choice apps keep serving them.

Device approval is deliberately EXCLUDED: it has its own tenant model (a SuperAdmin may
approve a device across tenants — device.go), a blessed capability, so the gate follows
the type=device early return (TestDevice_ApprovalTenantBoundary stays green).

Tests: shared-app and org-choice-app SuperAdmin logins refused (FAIL before — mint a
real code; PASS after); admin-console SuperAdmin login still allowed; cross-org and
shared-app normal-tenant regressions unchanged.
2026-07-22 23:18:56 -07:00
Hanzo AI 3ac56ae66e fix(oidc): reserved-org gate at the login grant tail (F-D2 login twin) [SECURITY]
login.go was the one credential door that omitted the store.IsReservedOrg refuse
signup.go and the ROPC grant enforce. A shared or org-choice app (whose tenant gate
accepts a user from ANY org) would mint a real SuperAdmin authorization code — or bare
session — on the correct admin password: POST /v1/iam/login type=code via such an app
resolved admin/<super> and returned a live grant.

Gate a RESERVED-org principal to an app that itself SERVES that reserved org (the
dedicated console). Placed at loginGrant ahead of the bare-session and type=code
branches — the ONE tail the credential post and the second-factor finish share — so
every credential grant shape is bound identically. The dedicated admin-console
(Organization=="admin") still signs the SuperAdmin in; a normal tenant never triggers
it, so shared/org-choice apps keep serving them.

Device approval is deliberately EXCLUDED: it has its own tenant model (a SuperAdmin may
approve a device across tenants — device.go), a blessed capability, so the gate follows
the type=device early return (TestDevice_ApprovalTenantBoundary stays green).

Tests: shared-app and org-choice-app SuperAdmin logins refused (FAIL before — mint a
real code; PASS after); admin-console SuperAdmin login still allowed; cross-org and
shared-app normal-tenant regressions unchanged.
2026-07-22 23:18:56 -07:00
Hanzo AI 7f75c82753 fix(iam): make users.Authenticate the ONE human-credential choke point (F-D1) [SECURITY]
registry.userByPassword and featurestore.VerifyPassword (LDAP bind) called
users.VerifyPassword DIRECTLY — no lockout, no counter. The public
POST /v1/iam/registry/token endpoint let an unauthenticated attacker brute-force an
admin/hanzo (SuperAdmin) password with ZERO throttle while the login door locked, so
the F-D1 lockout was not actually a choke point — two other paths skipped it.

Decomplect: hoist the lockout-aware verify out of internal/oidc into the ONE
credential package as users.Authenticate (raw VerifyPassword stays the stateless
digest primitive; Authenticate wraps it with per-account lockout). Route login, the
ROPC grant, the registry token endpoint, and the LDAP-bind seam all through it. The
raw primitive now has exactly one production caller: Authenticate.

VerifyPassword caller audit (all human-auth paths -> choke point):
  - oidc login.go           -> users.Authenticate
  - oidc token.go (ROPC)    -> users.Authenticate
  - registry userByPassword -> users.Authenticate (locked = no-match, opaque 401)
  - featurestore (LDAP)     -> users.Authenticate (locked = bind fails)
  - users.VerifyPassword    -> raw digest primitive, called only by Authenticate

Registry test: mints an admin (org 'admin') via the REAL users.Create path, hammers
5 wrong passwords on the public token endpoint, asserts the correct admin password is
then REFUSED. FAILS before (mints a real sub:admin/root token), PASSES after.
2026-07-22 23:14:39 -07:00
Hanzo AI cddecc04cb fix(iam): make users.Authenticate the ONE human-credential choke point (F-D1) [SECURITY]
registry.userByPassword and featurestore.VerifyPassword (LDAP bind) called
users.VerifyPassword DIRECTLY — no lockout, no counter. The public
POST /v1/iam/registry/token endpoint let an unauthenticated attacker brute-force an
admin/hanzo (SuperAdmin) password with ZERO throttle while the login door locked, so
the F-D1 lockout was not actually a choke point — two other paths skipped it.

Decomplect: hoist the lockout-aware verify out of internal/oidc into the ONE
credential package as users.Authenticate (raw VerifyPassword stays the stateless
digest primitive; Authenticate wraps it with per-account lockout). Route login, the
ROPC grant, the registry token endpoint, and the LDAP-bind seam all through it. The
raw primitive now has exactly one production caller: Authenticate.

VerifyPassword caller audit (all human-auth paths -> choke point):
  - oidc login.go           -> users.Authenticate
  - oidc token.go (ROPC)    -> users.Authenticate
  - registry userByPassword -> users.Authenticate (locked = no-match, opaque 401)
  - featurestore (LDAP)     -> users.Authenticate (locked = bind fails)
  - users.VerifyPassword    -> raw digest primitive, called only by Authenticate

Registry test: mints an admin (org 'admin') via the REAL users.Create path, hammers
5 wrong passwords on the public token endpoint, asserts the correct admin password is
then REFUSED. FAILS before (mints a real sub:admin/root token), PASSES after.
2026-07-22 23:14:39 -07:00
Hanzo AI 420077585e fix(oidc): persist lockout counter by the loaded row's real key (F-D1) [SECURITY]
saveLoginCounters saved via orm.Get(db, owner+"/"+name), but schema.User is
registered without WithStringKey, so a users.Create'd account (signup / SCIM /
federation / CRUD) is keyed by an auto-allocated int64 — not owner/name. The save
therefore matched only migrated casdoor rows; every post-cutover account's
SigninWrongTimes never persisted, read back 0 each request, and NEVER locked — an
unauthenticated online brute-force oracle on the public ROPC endpoint.

Re-read the row through the ONE (owner,name) query path (First → SetKey stamps the
real storage key, int64 or owner/name) and write the counters back by THAT key, so
the persist targets the exact row the verify loaded, for both account shapes. The
re-read keeps the write counter-only (fresh mirrors stored state).

Test drives the REAL signup create path (no SetId) then 5 wrong + 1 correct: FAILS
before (6th accepted, status 200), PASSES after (locked). SetId-seeded migrated-shape
tests unchanged.
2026-07-22 23:10:46 -07:00
Hanzo AI 679e5ed1d9 fix(oidc): persist lockout counter by the loaded row's real key (F-D1) [SECURITY]
saveLoginCounters saved via orm.Get(db, owner+"/"+name), but schema.User is
registered without WithStringKey, so a users.Create'd account (signup / SCIM /
federation / CRUD) is keyed by an auto-allocated int64 — not owner/name. The save
therefore matched only migrated casdoor rows; every post-cutover account's
SigninWrongTimes never persisted, read back 0 each request, and NEVER locked — an
unauthenticated online brute-force oracle on the public ROPC endpoint.

Re-read the row through the ONE (owner,name) query path (First → SetKey stamps the
real storage key, int64 or owner/name) and write the counters back by THAT key, so
the persist targets the exact row the verify loaded, for both account shapes. The
re-read keeps the write counter-only (fresh mirrors stored state).

Test drives the REAL signup create path (no SetId) then 5 wrong + 1 correct: FAILS
before (6th accepted, status 200), PASSES after (locked). SetId-seeded migrated-shape
tests unchanged.
2026-07-22 23:10:46 -07:00
Hanzo AI 5115cc32c6 fix(oidc): account lockout on the shared password-verify path (F-D1) [SECURITY]
Red F-D1 [HIGH]: SigninWrongTimes/LastSigninWrongTime exist on schema.User but were
never enforced. Casdoor locked an account after a run of wrong passwords; commit D
adopted casdoor's PUBLIC-ROPC endpoint while dropping that compensating control,
making it an unauthenticated online brute-force oracle.

Fix: verifyLoginPassword is now the ONE credential-verify choke point the login form
AND the ROPC password grant share. It enforces casdoor-parity lockout on the user
row: a wrong password increments the count (restarting when the window lapsed) and
stamps the time; at signinWrongLimit (5) within lockoutWindow (15m) the account is
locked and even the correct password is refused, with a DISTINCT message that never
reveals correctness; a correct, unlocked password resets the count. A nil user
(unknown login) shares the opaque bad-password path — no first-attempt enumeration.
Counter writes are best-effort (a persist fault never turns a correct login into an
error). login.go and token.go now route through it (their direct users.VerifyPassword
calls, and imports, removed — one verify path, no drift).

Tests: 5 wrong attempts lock the account (correct password then refused with the
distinct lock message); two rounds of (limit-1 wrong, then success) both succeed,
proving a correct password resets the counter.
2026-07-22 22:35:54 -07:00
Hanzo AI c878a2f516 fix(oidc): account lockout on the shared password-verify path (F-D1) [SECURITY]
Red F-D1 [HIGH]: SigninWrongTimes/LastSigninWrongTime exist on schema.User but were
never enforced. Casdoor locked an account after a run of wrong passwords; commit D
adopted casdoor's PUBLIC-ROPC endpoint while dropping that compensating control,
making it an unauthenticated online brute-force oracle.

Fix: verifyLoginPassword is now the ONE credential-verify choke point the login form
AND the ROPC password grant share. It enforces casdoor-parity lockout on the user
row: a wrong password increments the count (restarting when the window lapsed) and
stamps the time; at signinWrongLimit (5) within lockoutWindow (15m) the account is
locked and even the correct password is refused, with a DISTINCT message that never
reveals correctness; a correct, unlocked password resets the count. A nil user
(unknown login) shares the opaque bad-password path — no first-attempt enumeration.
Counter writes are best-effort (a persist fault never turns a correct login into an
error). login.go and token.go now route through it (their direct users.VerifyPassword
calls, and imports, removed — one verify path, no drift).

Tests: 5 wrong attempts lock the account (correct password then refused with the
distinct lock message); two rounds of (limit-1 wrong, then success) both succeed,
proving a correct password resets the counter.
2026-07-22 22:35:54 -07:00
Hanzo AI 604984a2b3 fix(oidc): passwordGrant cross-tenant + reserved-org gate (F-D2) [SECURITY]
Red F-D2 [HIGH]: passwordGrant read the login org from the `organization` request
param with no check the client may serve it — the one mint path missing the gate
mint.go/login.go/signup.go/federation.go all enforce. A public zoo-console posting
organization=admin&username=z resolved admin/z (a SuperAdmin) and minted a real
SuperAdmin token on the correct password.

Fix: before the user lookup, refuse when the target org is reserved
(store.IsReservedOrg — admin/built-in/app) OR is not one this client may serve
(org != app.Organization && !app.IsShared && app.OrgChoiceMode == ""). Opaque
invalid_grant (same shape as a bad credential), so it is no org/user oracle. This
mirrors the established signup_reserved contract; the SuperAdmin authenticates via
a minted/code-flow bearer, never public ROPC into the admin org.

Also repoints the two token-exchange reserved-org tests: their setup minted the
admin/root subject_token via ROPC into org=admin — exactly what F-D2 now forbids —
so they obtain it via a new directSubjectToken helper that cert-signs the token
(verifyToken checks only trusted-kid+signature+time). The exchange's OWN reserved-org
gate remains the thing under test.

Tests: a public console with organization=admin (correct SuperAdmin password) is
refused and mints nothing; a foreign tenant org is refused; the normal same-org
password grant still succeeds; token-exchange reserved-org gate still enforced.
2026-07-22 22:33:50 -07:00
Hanzo AI 6bd613b358 fix(oidc): passwordGrant cross-tenant + reserved-org gate (F-D2) [SECURITY]
Red F-D2 [HIGH]: passwordGrant read the login org from the `organization` request
param with no check the client may serve it — the one mint path missing the gate
mint.go/login.go/signup.go/federation.go all enforce. A public zoo-console posting
organization=admin&username=z resolved admin/z (a SuperAdmin) and minted a real
SuperAdmin token on the correct password.

Fix: before the user lookup, refuse when the target org is reserved
(store.IsReservedOrg — admin/built-in/app) OR is not one this client may serve
(org != app.Organization && !app.IsShared && app.OrgChoiceMode == ""). Opaque
invalid_grant (same shape as a bad credential), so it is no org/user oracle. This
mirrors the established signup_reserved contract; the SuperAdmin authenticates via
a minted/code-flow bearer, never public ROPC into the admin org.

Also repoints the two token-exchange reserved-org tests: their setup minted the
admin/root subject_token via ROPC into org=admin — exactly what F-D2 now forbids —
so they obtain it via a new directSubjectToken helper that cert-signs the token
(verifyToken checks only trusted-kid+signature+time). The exchange's OWN reserved-org
gate remains the thing under test.

Tests: a public console with organization=admin (correct SuperAdmin password) is
refused and mints nothing; a foreign tenant org is refused; the normal same-org
password grant still succeeds; token-exchange reserved-org gate still enforced.
2026-07-22 22:33:50 -07:00
Hanzo AI 7dbe5a6f3d fix(iam): close sub-collision impersonation on the money path (F-A1/F-A2/F-L1/F-I1/F-I2)
Red F-A1 [CRITICAL]: User.Id IS the OIDC sub and the authz principal key, but it
was client-settable on create and client-mutable on update and non-unique in
storage. An org-admin of any tenant could POST a user whose id = a victim's UUID
(a public identifier); two rows then shared the sub and GetUserById.First()
returned the arbitrary (attacker-favorable) row → tenant-admin → SuperAdmin
impersonation. Closed at every layer:

- users.Create: ALWAYS mint the Id server-side (uuid.NewString); a client-supplied
  Id is discarded. Plus a write-path uniqueness guard (store.GetUserById) — the
  JSON store has no per-field DB UNIQUE index (confirmed: orm ModelMeta/parseStructTags
  handle only default+serialize), so uniqueness is enforced at the write exactly as
  clientId is, NOT by a decorative `unique` tag that the engine would ignore.
- users.Update: Id is immutable — carried from the stored row like CreatedTime; a
  body-supplied Id is ignored (this ALSO fixes F-A2: a benign edit omitting id no
  longer wipes the subject to "").
- store.GetUserById: FAIL CLOSED on >1 match (GetAll + count) instead of First() —
  any duplicate that somehow exists refuses resolution rather than returning a
  steerable row (F-L1).
- oidc.subjectOf: nil-guard (F-I1).
- signup usernamePolicyError: forbid '/' so a self-registered name can't inject a
  spurious owner/name separator into the subject discriminator (F-I2).

The migrator writes schema.User via the generic engine (not users.Create), so it
still carries casdoor UUIDs verbatim — confirmed unaffected (migrate tests green).

Tests: client-supplied Id ignored on create; attacker cannot adopt a victim's Id
(collision denied); Update preserves Id (immutable) and a no-id edit keeps it;
username with '/' refused. Full suite green.
2026-07-22 22:27:56 -07:00
Hanzo AI 02ce320cab fix(iam): close sub-collision impersonation on the money path (F-A1/F-A2/F-L1/F-I1/F-I2)
Red F-A1 [CRITICAL]: User.Id IS the OIDC sub and the authz principal key, but it
was client-settable on create and client-mutable on update and non-unique in
storage. An org-admin of any tenant could POST a user whose id = a victim's UUID
(a public identifier); two rows then shared the sub and GetUserById.First()
returned the arbitrary (attacker-favorable) row → tenant-admin → SuperAdmin
impersonation. Closed at every layer:

- users.Create: ALWAYS mint the Id server-side (uuid.NewString); a client-supplied
  Id is discarded. Plus a write-path uniqueness guard (store.GetUserById) — the
  JSON store has no per-field DB UNIQUE index (confirmed: orm ModelMeta/parseStructTags
  handle only default+serialize), so uniqueness is enforced at the write exactly as
  clientId is, NOT by a decorative `unique` tag that the engine would ignore.
- users.Update: Id is immutable — carried from the stored row like CreatedTime; a
  body-supplied Id is ignored (this ALSO fixes F-A2: a benign edit omitting id no
  longer wipes the subject to "").
- store.GetUserById: FAIL CLOSED on >1 match (GetAll + count) instead of First() —
  any duplicate that somehow exists refuses resolution rather than returning a
  steerable row (F-L1).
- oidc.subjectOf: nil-guard (F-I1).
- signup usernamePolicyError: forbid '/' so a self-registered name can't inject a
  spurious owner/name separator into the subject discriminator (F-I2).

The migrator writes schema.User via the generic engine (not users.Create), so it
still carries casdoor UUIDs verbatim — confirmed unaffected (migrate tests green).

Tests: client-supplied Id ignored on create; attacker cannot adopt a victim's Id
(collision denied); Update preserves Id (immutable) and a no-id edit keeps it;
username with '/' refused. Full suite green.
2026-07-22 22:27:56 -07:00
Hanzo AI dea817458a feat(oidc): allow public-client ROPC — casdoor parity (D) [SECURITY]
INTENTIONAL security-posture decision for the cutover, flagged for Red review.

The clean-room forbade public ROPC (confidential clients only). Casdoor ALLOWS a
public client (console/chat: no client_secret, no PKCE) to complete the password
grant, so those logins would 401 `invalid_client` at cutover. Owner directive:
default to parity — do not change behavior mid-migration.

Exact, bounded relaxation in passwordGrant:
  - A PUBLIC client (no registered ClientSecret) MAY now complete the password
    grant with NO client_secret and NO PKCE. This is the ONLY thing newly allowed.
  - A CONFIDENTIAL client (registered secret) is UNCHANGED: it must still present
    that secret, verified constant-time; a wrong secret is still 401.

Untouched: publicTokenEndpointForbidden still bars internal (<org>-iam) and
reserved-org (admin/built-in/app) apps; IsPasswordEnabled still gates; the password
is verified through the same per-row argon2id/bcrypt path; unknown-user and
bad-password share one opaque invalid_grant; forbidden/deleted users are denied.

Tests: public client (no secret/PKCE) SUCCEEDS for a console app; public client with
a WRONG password is denied; a forbidden user is denied even to a public client; a
confidential client with a wrong secret is still 401; reserved-org apps still refused.
2026-07-22 22:02:06 -07:00
Hanzo AI 2cf0b99986 feat(oidc): allow public-client ROPC — casdoor parity (D) [SECURITY]
INTENTIONAL security-posture decision for the cutover, flagged for Red review.

The clean-room forbade public ROPC (confidential clients only). Casdoor ALLOWS a
public client (console/chat: no client_secret, no PKCE) to complete the password
grant, so those logins would 401 `invalid_client` at cutover. Owner directive:
default to parity — do not change behavior mid-migration.

Exact, bounded relaxation in passwordGrant:
  - A PUBLIC client (no registered ClientSecret) MAY now complete the password
    grant with NO client_secret and NO PKCE. This is the ONLY thing newly allowed.
  - A CONFIDENTIAL client (registered secret) is UNCHANGED: it must still present
    that secret, verified constant-time; a wrong secret is still 401.

Untouched: publicTokenEndpointForbidden still bars internal (<org>-iam) and
reserved-org (admin/built-in/app) apps; IsPasswordEnabled still gates; the password
is verified through the same per-row argon2id/bcrypt path; unknown-user and
bad-password share one opaque invalid_grant; forbidden/deleted users are denied.

Tests: public client (no secret/PKCE) SUCCEEDS for a console app; public client with
a WRONG password is denied; a forbidden user is denied even to a public client; a
confidential client with a wrong secret is still 401; reserved-org apps still refused.
2026-07-22 22:02:06 -07:00
Hanzo AI 36d4f7b1f8 feat(migrate-v1): carry casdoor memberships — restore multi-org orgs (C)
The migrator carried no memberships, so a multi-org user's tenancy collapsed to
the home org alone (z: [hanzo,lux,zoo,pars] → [hanzo]). Casdoor's `membership`
table has the identical (owner,name,user,org,role) shape as schema.Membership, so
add it as an entitySpec driven by the generic engine — carried verbatim, keyed by
the (owner,name) natural key, idempotent on re-run. Ordered after users and
organizations, which it references. Included in the dry-run counts and the --only
selector set.

Test: 4 casdoor membership rows migrate to 4 clean rows (0 skipped); a user with
memberships in [hanzo,lux,zoo,pars] reproduces all 4 via store.MemberOrgRefs
(home ∪ explicit); a re-run is a pure no-op.
2026-07-22 22:00:16 -07:00
Hanzo AI 763006151f feat(migrate-v1): carry casdoor memberships — restore multi-org orgs (C)
The migrator carried no memberships, so a multi-org user's tenancy collapsed to
the home org alone (z: [hanzo,lux,zoo,pars] → [hanzo]). Casdoor's `membership`
table has the identical (owner,name,user,org,role) shape as schema.Membership, so
add it as an entitySpec driven by the generic engine — carried verbatim, keyed by
the (owner,name) natural key, idempotent on re-run. Ordered after users and
organizations, which it references. Included in the dry-run counts and the --only
selector set.

Test: 4 casdoor membership rows migrate to 4 clean rows (0 skipped); a user with
memberships in [hanzo,lux,zoo,pars] reproduces all 4 via store.MemberOrgRefs
(home ∪ explicit); a re-run is a pure no-op.
2026-07-22 22:00:16 -07:00
Hanzo AI 0ffcf056ae fix(oidc): resolve login username by NAME then email — casdoor precedence (B)
resolveLoginUser resolved the identifier by EMAIL first; casdoor resolves by NAME
first (object.GetUserByFields). When two rows collide on an email — hanzo/z (name
z) and hanzo/z@hanzo.ai (name z@hanzo.ai) — the ROPC/login username "z@hanzo.ai"
must land on the NAME match, exactly as casdoor did. Email-first silently
authenticated the OTHER identity at cutover. No rows are deduped; the fix is
deterministic name-first resolution. Email fallback is unchanged for a username
that matches no name.

Test: username "z@hanzo.ai" resolves to the NAME match hanzo/z@hanzo.ai (not the
email match hanzo/z); a plain username still resolves by name; an email with no
name match still falls back to the email lookup.
2026-07-22 21:59:06 -07:00
Hanzo AI 8a553db3aa fix(oidc): resolve login username by NAME then email — casdoor precedence (B)
resolveLoginUser resolved the identifier by EMAIL first; casdoor resolves by NAME
first (object.GetUserByFields). When two rows collide on an email — hanzo/z (name
z) and hanzo/z@hanzo.ai (name z@hanzo.ai) — the ROPC/login username "z@hanzo.ai"
must land on the NAME match, exactly as casdoor did. Email-first silently
authenticated the OTHER identity at cutover. No rows are deduped; the fix is
deterministic name-first resolution. Email fallback is unchanged for a username
that matches no name.

Test: username "z@hanzo.ai" resolves to the NAME match hanzo/z@hanzo.ai (not the
email match hanzo/z); a plain username still resolves by name; an email with no
name match still falls back to the email lookup.
2026-07-22 21:59:06 -07:00
Hanzo AI f2d35ba098 feat(oidc): sub continuity — emit casdoor UUID as sub across the cutover (A)
The clean-room emitted `sub` = owner/name; casdoor emits the user's UUID. Every
user's sub would change at cutover, breaking sessions, external refs, and the
money-path principal keyed on `sub`. Carry casdoor's per-row UUID and mint it as
the stable `sub` going forward.

- schema.User: add `Id string json:"id" orm:"index"` — the casdoor UUID; its json
  tag dominates the embedded orm storage id, so the persisted "id" is the UUID
  while the primary key stays (owner,name).
- store: GetUserById + GetUserBySubject — the ONE subject decoder (no "/" ⇒ Id,
  else natural key), matching how subjectOf mints a sub.
- users.Create: assign a fresh UUID when none supplied, so a native v2 user's sub
  is opaque from birth.
- oidc: subjectOf() + userClaims() resolve the sub once; issueTokens/signAccessToken,
  token-exchange, issue-user-token mint it; userinfo/get-account/whoami report the
  SAME sub; introspection already echoes it. Token rows keep the (owner/name) key.
- authz.principal: resolve the money-path principal via GetUserBySubject (Id-first),
  read Org/Admin/Super from the loaded row, fail closed on an orphan UUID.
- migrate-v1: the casdoor `id` column now maps to User.Id automatically; the UUID
  is a domain field, never the storage key.

Tests: migrated user mints UUID sub (access+id+userinfo agree); empty-Id user falls
back to owner/name; native Create generates a UUID; store decoder resolves both
shapes and preserves the (owner,name) PK; migrate carries every row's UUID.
2026-07-22 21:58:12 -07:00
Hanzo AI ceb5dee78f feat(oidc): sub continuity — emit casdoor UUID as sub across the cutover (A)
The clean-room emitted `sub` = owner/name; casdoor emits the user's UUID. Every
user's sub would change at cutover, breaking sessions, external refs, and the
money-path principal keyed on `sub`. Carry casdoor's per-row UUID and mint it as
the stable `sub` going forward.

- schema.User: add `Id string json:"id" orm:"index"` — the casdoor UUID; its json
  tag dominates the embedded orm storage id, so the persisted "id" is the UUID
  while the primary key stays (owner,name).
- store: GetUserById + GetUserBySubject — the ONE subject decoder (no "/" ⇒ Id,
  else natural key), matching how subjectOf mints a sub.
- users.Create: assign a fresh UUID when none supplied, so a native v2 user's sub
  is opaque from birth.
- oidc: subjectOf() + userClaims() resolve the sub once; issueTokens/signAccessToken,
  token-exchange, issue-user-token mint it; userinfo/get-account/whoami report the
  SAME sub; introspection already echoes it. Token rows keep the (owner/name) key.
- authz.principal: resolve the money-path principal via GetUserBySubject (Id-first),
  read Org/Admin/Super from the loaded row, fail closed on an orphan UUID.
- migrate-v1: the casdoor `id` column now maps to User.Id automatically; the UUID
  is a domain field, never the storage key.

Tests: migrated user mints UUID sub (access+id+userinfo agree); empty-Id user falls
back to owner/name; native Create generates a UUID; store decoder resolves both
shapes and preserves the (owner,name) PK; migrate carries every row's UUID.
2026-07-22 21:58:12 -07:00
Hanzo AI a687c2fd55 fix(registry): bind service-account path to candidateOrgs (F-R1)
Red F-R1 CRITICAL: the third cross-tenant PUSH path. serviceAccount
resolved store.GetApplicationByClientId GLOBALLY and returned
privileged:true on any clientId:clientSecret match, with NO candidateOrgs
bound — the boundary the prior fix added to the key/password paths but not
this sibling. Attack (Red-proven): self-onboard org "evil", POST an app
{Owner:"evil", clientId, clientSecret} (own-org admin write), docker login
with it -> privileged push to any repo -> supply-chain poisoning.

FIX (decomplected to ONE gate, per CTO): bind ALL privileged-yielding paths
in a single authoritative place so a future credential path can't skip it.
  - principal now carries `owner` (the user's org, or the app's Owner).
  - authenticate() = resolve() + ONE candidateOrgs gate over the resolved
    principal's owner. resolve() finds WHO the credential is (any org); the
    gate binds to {admin,hanzo} once. Dropped the now-redundant per-path
    inCandidateOrg check in userByKey (the tail gate subsumes it).
  - serviceAccount sets principal.owner = app.Owner. A tenant-org app is
    denied at the gate; a real CI/service account (admin/hanzo-owned) passes.
  - userPrivileged unchanged (the v1-parity push decision).

TESTS (26 green):
  - TestToken_ForeignTenantApp_Denied — app Owner="evil" with a MATCHING
    secret (so the 401 is the org gate, not a bad secret), GET + POST, pull
    + push scopes -> 401, no token.
  - TestToken_HanzoKey_PullToken — positive control: a hanzo-org pk-/sk- Key
    resolves + gets a pull token (the gate admits in-platform keys).
  - existing TestToken_ServiceAccount_PullPush (admin-owned app) still pushes
    — CI unaffected.

go build/vet/test ./... all green (23 pkgs ok, 0 FAIL).
2026-07-22 15:50:57 -07:00
Hanzo AI 75d7cacfda fix(registry): bind service-account path to candidateOrgs (F-R1)
build / docker (push) Successful in 1m37s
Red F-R1 CRITICAL: the third cross-tenant PUSH path. serviceAccount
resolved store.GetApplicationByClientId GLOBALLY and returned
privileged:true on any clientId:clientSecret match, with NO candidateOrgs
bound — the boundary the prior fix added to the key/password paths but not
this sibling. Attack (Red-proven): self-onboard org "evil", POST an app
{Owner:"evil", clientId, clientSecret} (own-org admin write), docker login
with it -> privileged push to any repo -> supply-chain poisoning.

FIX (decomplected to ONE gate, per CTO): bind ALL privileged-yielding paths
in a single authoritative place so a future credential path can't skip it.
  - principal now carries `owner` (the user's org, or the app's Owner).
  - authenticate() = resolve() + ONE candidateOrgs gate over the resolved
    principal's owner. resolve() finds WHO the credential is (any org); the
    gate binds to {admin,hanzo} once. Dropped the now-redundant per-path
    inCandidateOrg check in userByKey (the tail gate subsumes it).
  - serviceAccount sets principal.owner = app.Owner. A tenant-org app is
    denied at the gate; a real CI/service account (admin/hanzo-owned) passes.
  - userPrivileged unchanged (the v1-parity push decision).

TESTS (26 green):
  - TestToken_ForeignTenantApp_Denied — app Owner="evil" with a MATCHING
    secret (so the 401 is the org gate, not a bad secret), GET + POST, pull
    + push scopes -> 401, no token.
  - TestToken_HanzoKey_PullToken — positive control: a hanzo-org pk-/sk- Key
    resolves + gets a pull token (the gate admits in-platform keys).
  - existing TestToken_ServiceAccount_PullPush (admin-owned app) still pushes
    — CI unaffected.

go build/vet/test ./... all green (23 pkgs ok, 0 FAIL).
2026-07-22 15:50:57 -07:00
Hanzo AI aeb5dd3f85 fix(registry): v1-parity push gate — candidateOrg admin keeps push (owner decision); tightening deferred 2026-07-22 15:37:08 -07:00
Hanzo AI 24033dbdca fix(registry): v1-parity push gate — candidateOrg admin keeps push (owner decision); tightening deferred 2026-07-22 15:37:08 -07:00
Hanzo AI 2976307dd5 fix(registry): close cross-tenant key-auth push + fail-closed signing key
Red CRITICAL + MEDIUM on the Gap D registry port. Two coupled fixes in the
two registry files (the lazy-resolver mechanism of the MEDIUM fix changes the
same mount/handler signatures the CRITICAL fix's file touches, so they ship
green together rather than as a non-compiling split).

CRITICAL — cross-tenant image poisoning (supply-chain). The added API-key
path resolved store.UserByAccessKey to the key's OWNER in ANY tenant org,
bypassing the v1 {admin,hanzo} boundary the password path enforces; combined
with privileged = u.IsAdmin (onboard sets IsAdmin on every org creator), a
self-onboarded tenant admin could docker push to shared repos.
  - FIX 1 (restore v1 boundary): the key path now BINDS the resolved user to
    candidateOrgs {admin,hanzo} (inCandidateOrg). A foreign-tenant key
    resolves to nil -> 401, no token. v1 only ever authenticated those two
    orgs; parity restored, hole closed at the root.
  - FIX 2 (defense-in-depth): userPrivileged gates push to service-account OR
    (IsSigningCertOwner(owner) && (IsAdmin || IsSuperAdmin)). IsAdmin alone is
    not a push signal. Intersected with the auth bound, the only human push
    identity is the admin org (SuperAdmins); CI pushes via the service
    account; a hanzo-org admin authenticates but is pull-only.

MEDIUM — ephemeral signing key could ship in prod (fail-open). Inverted the
default to FAIL-CLOSED: resolveKeyring errors when no key is configured unless
the explicit dev opt-in REGISTRY_ALLOW_EPHEMERAL=true is set (retired
REGISTRY_REQUIRE_PERSISTENT_SIGNING_KEY). Resolution is now LAZY (per request,
memoized) so the fail-closed default never panics the 9 packages that mount
the full routes.Route; a missing key answers 503 (no untrusted token minted),
never an ephemeral key the registry ROOTCERTBUNDLE rejects. A configured-but-
broken key is an error in every environment, even with the opt-in.

Env: prod sets REGISTRY_SIGNING_KEY or _FILE (the current KMS key); dev may
set REGISTRY_ALLOW_EPHEMERAL=true.

Tests (21 green): TestToken_ForeignTenantKey_Denied (pk-/sk- foreign key in
username AND password, pull AND push scopes -> 401/no token),
TestToken_HanzoOrgAdmin_PullOnly (hanzo IsAdmin authenticates, pull-only),
TestToken_SuperAdmin_PullPush (admin org pushes), TestKeyring_FailsClosed_
NoEphemeralByDefault, _EphemeralRequiresOptIn, _LoadsConfiguredKey (golden
kid), _BrokenKeyIsError; existing token/kid/JWKS suite unchanged.
2026-07-22 15:32:37 -07:00
Hanzo AI a528dec5c2 fix(registry): close cross-tenant key-auth push + fail-closed signing key
Red CRITICAL + MEDIUM on the Gap D registry port. Two coupled fixes in the
two registry files (the lazy-resolver mechanism of the MEDIUM fix changes the
same mount/handler signatures the CRITICAL fix's file touches, so they ship
green together rather than as a non-compiling split).

CRITICAL — cross-tenant image poisoning (supply-chain). The added API-key
path resolved store.UserByAccessKey to the key's OWNER in ANY tenant org,
bypassing the v1 {admin,hanzo} boundary the password path enforces; combined
with privileged = u.IsAdmin (onboard sets IsAdmin on every org creator), a
self-onboarded tenant admin could docker push to shared repos.
  - FIX 1 (restore v1 boundary): the key path now BINDS the resolved user to
    candidateOrgs {admin,hanzo} (inCandidateOrg). A foreign-tenant key
    resolves to nil -> 401, no token. v1 only ever authenticated those two
    orgs; parity restored, hole closed at the root.
  - FIX 2 (defense-in-depth): userPrivileged gates push to service-account OR
    (IsSigningCertOwner(owner) && (IsAdmin || IsSuperAdmin)). IsAdmin alone is
    not a push signal. Intersected with the auth bound, the only human push
    identity is the admin org (SuperAdmins); CI pushes via the service
    account; a hanzo-org admin authenticates but is pull-only.

MEDIUM — ephemeral signing key could ship in prod (fail-open). Inverted the
default to FAIL-CLOSED: resolveKeyring errors when no key is configured unless
the explicit dev opt-in REGISTRY_ALLOW_EPHEMERAL=true is set (retired
REGISTRY_REQUIRE_PERSISTENT_SIGNING_KEY). Resolution is now LAZY (per request,
memoized) so the fail-closed default never panics the 9 packages that mount
the full routes.Route; a missing key answers 503 (no untrusted token minted),
never an ephemeral key the registry ROOTCERTBUNDLE rejects. A configured-but-
broken key is an error in every environment, even with the opt-in.

Env: prod sets REGISTRY_SIGNING_KEY or _FILE (the current KMS key); dev may
set REGISTRY_ALLOW_EPHEMERAL=true.

Tests (21 green): TestToken_ForeignTenantKey_Denied (pk-/sk- foreign key in
username AND password, pull AND push scopes -> 401/no token),
TestToken_HanzoOrgAdmin_PullOnly (hanzo IsAdmin authenticates, pull-only),
TestToken_SuperAdmin_PullPush (admin org pushes), TestKeyring_FailsClosed_
NoEphemeralByDefault, _EphemeralRequiresOptIn, _LoadsConfiguredKey (golden
kid), _BrokenKeyIsError; existing token/kid/JWKS suite unchanged.
2026-07-22 15:32:37 -07:00
Hanzo AI a78fe17311 docs(registry): record the preserved pull-any authz policy as an owner decision 2026-07-22 15:04:58 -07:00
Hanzo AI e2eb0d67c0 docs(registry): record the preserved pull-any authz policy as an owner decision 2026-07-22 15:04:58 -07:00
Hanzo AI a3b78a49a6 merge(registry): fold Gap D registry-token endpoint into the cutover-parity release 2026-07-22 15:03:41 -07:00
Hanzo AI 8f938ba7c1 merge(registry): fold Gap D registry-token endpoint into the cutover-parity release 2026-07-22 15:03:41 -07:00
Hanzo AI def448f1f8 feat(registry): Docker Registry v2 token endpoint (GAP D)
Port the OCI registry token auth into the clean-room IAM so the identity
cutover does not break CI image push / cluster image pull. registry:2 at
registry.hanzo.ai points REGISTRY_AUTH_TOKEN_REALM at
/v1/iam/registry/token and trusts issued tokens via /v1/iam/registry/jwks
(its ROOTCERTBUNDLE); the clean-room lacked both.

New package internal/registry, mounted PUBLIC in routes.Route:
  GET;POST /v1/iam/registry/token  — Docker Registry v2 token auth
  GET      /v1/iam/registry/jwks   — the verifying key (ROOTCERTBUNDLE set)

Wire fidelity (external verifier, byte-exact): jwt.MapClaims with iss
fixed "hanzo-iam", aud a bare STRING, exp/nbf/iat integer seconds, the
access[] array; RS256 with the libtrust kid (uppercase base32 of the first
240 bits of SHA-256 over the DER SPKI, colon-grouped quads) computed
identically to the beego source and pinned by a golden-vector test.

Auth (all fail-closed): user password via the SAME cred (argon2id) path
login uses; confidential app clientId:clientSecret (constant-time) as the
CI/service account; and the hk-/pk-/sk- API key via the ONE resolver
store.UserByAccessKey (no second key path). Authz mirrors the beego
source: privileged (service account / admin / SuperAdmin) gets every
requested action, any other authenticated principal is pull-only; a scope
with no authorized action is omitted — never a silent grant.

Signing key: ONE RSA key per process, loaded from REGISTRY_SIGNING_KEY /
REGISTRY_SIGNING_KEY_FILE (KMS -> KMSSecret -> Secret -> env, the one
clean-room secret path). Injecting the CURRENT key material keeps the
registry's existing ROOTCERTBUNDLE valid at cutover (same key -> same kid
-> no repoint). REGISTRY_REQUIRE_PERSISTENT_SIGNING_KEY=true makes a
missing key a hard boot failure; dev/test falls back to an ephemeral key.

16 tests green: service-account/admin/SuperAdmin pull+push, user pull-only,
push-only denied (empty access), hk- key via password and username,
bad/empty/unknown creds 401, OAuth2 POST flow, multi-scope, bare login,
JWKS round-trip verification, kid golden vector + determinism.
2026-07-22 14:59:23 -07:00
Hanzo AI e0f797ae92 feat(registry): Docker Registry v2 token endpoint (GAP D)
Port the OCI registry token auth into the clean-room IAM so the identity
cutover does not break CI image push / cluster image pull. registry:2 at
registry.hanzo.ai points REGISTRY_AUTH_TOKEN_REALM at
/v1/iam/registry/token and trusts issued tokens via /v1/iam/registry/jwks
(its ROOTCERTBUNDLE); the clean-room lacked both.

New package internal/registry, mounted PUBLIC in routes.Route:
  GET;POST /v1/iam/registry/token  — Docker Registry v2 token auth
  GET      /v1/iam/registry/jwks   — the verifying key (ROOTCERTBUNDLE set)

Wire fidelity (external verifier, byte-exact): jwt.MapClaims with iss
fixed "hanzo-iam", aud a bare STRING, exp/nbf/iat integer seconds, the
access[] array; RS256 with the libtrust kid (uppercase base32 of the first
240 bits of SHA-256 over the DER SPKI, colon-grouped quads) computed
identically to the beego source and pinned by a golden-vector test.

Auth (all fail-closed): user password via the SAME cred (argon2id) path
login uses; confidential app clientId:clientSecret (constant-time) as the
CI/service account; and the hk-/pk-/sk- API key via the ONE resolver
store.UserByAccessKey (no second key path). Authz mirrors the beego
source: privileged (service account / admin / SuperAdmin) gets every
requested action, any other authenticated principal is pull-only; a scope
with no authorized action is omitted — never a silent grant.

Signing key: ONE RSA key per process, loaded from REGISTRY_SIGNING_KEY /
REGISTRY_SIGNING_KEY_FILE (KMS -> KMSSecret -> Secret -> env, the one
clean-room secret path). Injecting the CURRENT key material keeps the
registry's existing ROOTCERTBUNDLE valid at cutover (same key -> same kid
-> no repoint). REGISTRY_REQUIRE_PERSISTENT_SIGNING_KEY=true makes a
missing key a hard boot failure; dev/test falls back to an ephemeral key.

16 tests green: service-account/admin/SuperAdmin pull+push, user pull-only,
push-only denied (empty access), hk- key via password and username,
bad/empty/unknown creds 401, OAuth2 POST flow, multi-scope, bare login,
JWKS round-trip verification, kid golden vector + determinism.
2026-07-22 14:59:23 -07:00
Hanzo AI 62d2f8b27c docs(authz): record RED F3 decision — CapKeyResolve stays name-keyed
F3 (LOW): CapKeyResolve matches app NAME (via Allowed → p.App), like all
four sibling authz.Caps, while the issuetoken mint verbs (appInList) match
clientId. Keying CapKeyResolve on clientId would (1) make it the sole
clientId-based Cap, inconsistent with its own family, and (2) require adding
ClientId to the Principal shape. The owner-pin already defeats the
name-collision vector, and name==clientId under <org>-<app>, so the two are
equivalent in practice. Documented in cap.go; gate NOT weakened.
2026-07-22 14:58:18 -07:00
Hanzo AI 473380591a docs(authz): record RED F3 decision — CapKeyResolve stays name-keyed
F3 (LOW): CapKeyResolve matches app NAME (via Allowed → p.App), like all
four sibling authz.Caps, while the issuetoken mint verbs (appInList) match
clientId. Keying CapKeyResolve on clientId would (1) make it the sole
clientId-based Cap, inconsistent with its own family, and (2) require adding
ClientId to the Principal shape. The owner-pin already defeats the
name-collision vector, and name==clientId under <org>-<app>, so the two are
equivalent in practice. Documented in cap.go; gate NOT weakened.
2026-07-22 14:58:18 -07:00
Hanzo AI 75baa7ce48 fix(security): membership grant into a reserved org is SuperAdmin-only (RED F2)
memberships ensure/remove gated on authz.Can(POST, organizations, 'admin',
in.Org). Because the membership row is always owned by the reserved 'admin'
org, that check takes the reserved-org branch of authorize() and, for a
CapOrgAdmin app, returns Allowed(CapOrgAdmin) with NO binding to in.Org — so
a brand console could create Membership{User:anyone, Org:'admin'}, which
flows into the target's orgs claim and the edge honors X-Org-Id in orgs =
SuperAdmin-org tenancy.

FIX: a shared mayGrant() gate for ensure AND remove refuses
store.IsReservedOrg(in.Org) unless authz.IsSuper(ctx). A CapOrgAdmin client
keeps its power over customer orgs; only a real SuperAdmin may target a
reserved org.

Test: TestEnsureMembership_reservedOrgRequiresSuper — CapOrgAdmin app denied
into admin+built-in (ensure and revoke), still allowed into a normal org,
and a SuperAdmin allowed into admin (escape hatch).
2026-07-22 14:57:08 -07:00
Hanzo AI 55aea83ff0 fix(security): membership grant into a reserved org is SuperAdmin-only (RED F2)
memberships ensure/remove gated on authz.Can(POST, organizations, 'admin',
in.Org). Because the membership row is always owned by the reserved 'admin'
org, that check takes the reserved-org branch of authorize() and, for a
CapOrgAdmin app, returns Allowed(CapOrgAdmin) with NO binding to in.Org — so
a brand console could create Membership{User:anyone, Org:'admin'}, which
flows into the target's orgs claim and the edge honors X-Org-Id in orgs =
SuperAdmin-org tenancy.

FIX: a shared mayGrant() gate for ensure AND remove refuses
store.IsReservedOrg(in.Org) unless authz.IsSuper(ctx). A CapOrgAdmin client
keeps its power over customer orgs; only a real SuperAdmin may target a
reserved org.

Test: TestEnsureMembership_reservedOrgRequiresSuper — CapOrgAdmin app denied
into admin+built-in (ensure and revoke), still allowed into a normal org,
and a SuperAdmin allowed into admin (escape hatch).
2026-07-22 14:57:08 -07:00
Hanzo AI a6dc277361 fix(security): close pk-/sk- cross-tenant + SuperAdmin identity forgery (RED F1 CRITICAL)
store.userOwningKey trusted Key.User's owner verbatim while Key.User,
AccessKey and AccessSecret are ALL attacker-controlled on write and keys
CRUD authorizes only (Key.Owner, Key.Name) — never the User field. An org
admin could plant Key{owner:attackerOrg, user:'admin/z', accessSecret:
'sk-live-KNOWN'} in its OWN org, then present the known secret so cloud's
get-user?accessKey resolved it to {owner:admin,name:z,isAdmin:true} = a
platform SuperAdmin (or any victim tenant's user).

Two-layer canonical fix:
- store.userOwningKey (authoritative, propagates to the registry branch that
  reuses UserByAccessKey): a pk-/sk- key may resolve ONLY to a user in the
  KEY ROW's own tenant — reject any resolved owner != k.Owner (ErrNotFound).
  A non-super can never own a Key under a reserved org (authorize gates keys
  writes), so no pk-/sk- key can ever reach a SuperAdmin identity.
- keys.create + keys.update (defense in depth): reject a '/'-qualified
  Key.User whose owner != Key.Owner, so no forged row is ever persisted.

Tests: store TestUserByAccessKey_RejectsCrossTenantUserRef (forged super +
victim keys → ErrNotFound though the users exist); keys
TestKeys_RejectCrossTenantUserOnWrite (create/update reject cross-tenant,
accept same-owner/bare); compat TestGetUserByAccessKey_CrossTenantForgeryDenied
(forged Key seeded directly → get-user?accessKey yields no identity).
2026-07-22 14:55:36 -07:00
Hanzo AI 5cca8457ed fix(security): close pk-/sk- cross-tenant + SuperAdmin identity forgery (RED F1 CRITICAL)
store.userOwningKey trusted Key.User's owner verbatim while Key.User,
AccessKey and AccessSecret are ALL attacker-controlled on write and keys
CRUD authorizes only (Key.Owner, Key.Name) — never the User field. An org
admin could plant Key{owner:attackerOrg, user:'admin/z', accessSecret:
'sk-live-KNOWN'} in its OWN org, then present the known secret so cloud's
get-user?accessKey resolved it to {owner:admin,name:z,isAdmin:true} = a
platform SuperAdmin (or any victim tenant's user).

Two-layer canonical fix:
- store.userOwningKey (authoritative, propagates to the registry branch that
  reuses UserByAccessKey): a pk-/sk- key may resolve ONLY to a user in the
  KEY ROW's own tenant — reject any resolved owner != k.Owner (ErrNotFound).
  A non-super can never own a Key under a reserved org (authorize gates keys
  writes), so no pk-/sk- key can ever reach a SuperAdmin identity.
- keys.create + keys.update (defense in depth): reject a '/'-qualified
  Key.User whose owner != Key.Owner, so no forged row is ever persisted.

Tests: store TestUserByAccessKey_RejectsCrossTenantUserRef (forged super +
victim keys → ErrNotFound though the users exist); keys
TestKeys_RejectCrossTenantUserOnWrite (create/update reject cross-tenant,
accept same-owner/bare); compat TestGetUserByAccessKey_CrossTenantForgeryDenied
(forged Key seeded directly → get-user?accessKey yields no identity).
2026-07-22 14:55:36 -07:00
Hanzo AI b99e584bca test(wallet): thread nil orgs into the harness Sign call (GAP C ripple)
The Signer.Sign signature gained an orgs param in GAP C; the wallet test
harness mints a token through it. Chained onto NewRSASigner(...).Sign so
the initial call-site sweep missed it. No production code — a machine-path
harness token carries no membership set, so nil is correct.
2026-07-22 14:35:04 -07:00
Hanzo AI edde73e42a test(wallet): thread nil orgs into the harness Sign call (GAP C ripple)
The Signer.Sign signature gained an orgs param in GAP C; the wallet test
harness mints a token through it. Chained onto NewRSASigner(...).Sign so
the initial call-site sweep missed it. No production code — a machine-path
harness token carries no membership set, so nil is correct.
2026-07-22 14:35:04 -07:00
Hanzo AI 6a2eec41ec feat(compat): get-user by accessKey key resolution (GAP B)
cloud's identity boundary resolves API keys via GET /v1/iam/get-user?
accessKey=<hk-/pk-/sk-> expecting {owner,name,email,isAdmin}. The
clean-room get-user resolved only owner/name/id, so EVERY hk-/pk-/sk- key
failed closed to anonymous at cutover. Resolve the key to its principal —
gated as a trusted service capability.

- store.UserByAccessKey: the ONE key->principal resolver. hk- => the User
  row's AccessKey (user-owned or service-account key); pk- => schema.Key
  AccessKey -> owning user; sk- => schema.Key AccessSecret -> owning user.
  Fail-closed: empty/unknown/wrong-shape/user-less => orm.ErrNotFound,
  never a wrong user.
- CapKeyResolve (env IAM_KEY_RESOLVE_APPS, fail-secure deny-all) — a
  credential-disclosure boundary held by the cloud service app only.
- get-user is handler-authorized via a new authz.handlerAuthorizedExact
  (exact, NOT a prefix: '/v1/iam/get-user' is a prefix of get-users, whose
  Guard read-gate must stay). The handler authorizes both variants: the
  owner/name read through the SAME authz.Can the Guard applied (no
  regression, cross-tenant still 403), the key read behind CapKeyResolve
  AND app-only (p.App != '' — a human holds a cap vacuously).
- Response is a minimal {owner,name,email,isAdmin} projection, TIGHTER than
  User.Mask (which leaves AccessKey): a pk-/sk- resolution must never
  disclose the resolved user's hk- credential.

Tests: store resolves each shape + fails closed on 9 bad inputs; compat
cap-holder resolves hk-/pk-/sk- with correct fields and zero secret leak
(incl. the user's hk- key on pk-/sk-); non-cap denied; unknown => not-found;
empty accessKey falls through to owner/name.
2026-07-22 14:33:40 -07:00
Hanzo AI 56410f472f feat(compat): get-user by accessKey key resolution (GAP B)
cloud's identity boundary resolves API keys via GET /v1/iam/get-user?
accessKey=<hk-/pk-/sk-> expecting {owner,name,email,isAdmin}. The
clean-room get-user resolved only owner/name/id, so EVERY hk-/pk-/sk- key
failed closed to anonymous at cutover. Resolve the key to its principal —
gated as a trusted service capability.

- store.UserByAccessKey: the ONE key->principal resolver. hk- => the User
  row's AccessKey (user-owned or service-account key); pk- => schema.Key
  AccessKey -> owning user; sk- => schema.Key AccessSecret -> owning user.
  Fail-closed: empty/unknown/wrong-shape/user-less => orm.ErrNotFound,
  never a wrong user.
- CapKeyResolve (env IAM_KEY_RESOLVE_APPS, fail-secure deny-all) — a
  credential-disclosure boundary held by the cloud service app only.
- get-user is handler-authorized via a new authz.handlerAuthorizedExact
  (exact, NOT a prefix: '/v1/iam/get-user' is a prefix of get-users, whose
  Guard read-gate must stay). The handler authorizes both variants: the
  owner/name read through the SAME authz.Can the Guard applied (no
  regression, cross-tenant still 403), the key read behind CapKeyResolve
  AND app-only (p.App != '' — a human holds a cap vacuously).
- Response is a minimal {owner,name,email,isAdmin} projection, TIGHTER than
  User.Mask (which leaves AccessKey): a pk-/sk- resolution must never
  disclose the resolved user's hk- credential.

Tests: store resolves each shape + fails closed on 9 bad inputs; compat
cap-holder resolves hk-/pk-/sk- with correct fields and zero secret leak
(incl. the user's hk- key on pk-/sk-); non-cap denied; unknown => not-found;
empty accessKey falls through to owner/name.
2026-07-22 14:33:40 -07:00
Hanzo AI c3b5caea75 feat(memberships): Casdoor membership verb aliases (GAP A)
cloud's clients/team invite path hard-codes get-memberships /
add-membership / delete-membership; the clean-room served only REST
/v1/iam/memberships (list+ensure, no delete). Serve the verbs over the
SAME store + the SAME authz gates — no second implementation.

- store.DeleteMembership: idempotent revoke keyed by the same (user, org)
  natural key EnsureMembership uses; absent row => (false, nil).
- memberships.Route registers the three verbs: get/add REUSE the REST
  list/ensure handlers verbatim; delete adds a remove handler under the
  same authz.Can(POST, organizations, admin, org) gate as ensure.
- authz.handlerAuthorizedPrefixes gains /v1/iam/get-memberships: its
  target rides in ?user=/?org= (not owner/name), so the Guard authenticates
  and the list handler's scoped() check is the tenant gate — exactly as its
  REST twin /v1/iam/memberships already is. The POST verbs are raw handlers
  the Guard never pre-authorizes; each self-authorizes.

Tests: get by ?user and ?org; add then get shows it; delete removes and is
idempotent; cross-tenant add/delete/read all refused auth:Unauthorized
operation; unauthenticated => 401.
2026-07-22 14:24:07 -07:00
Hanzo AI 56a48cb64a feat(memberships): Casdoor membership verb aliases (GAP A)
cloud's clients/team invite path hard-codes get-memberships /
add-membership / delete-membership; the clean-room served only REST
/v1/iam/memberships (list+ensure, no delete). Serve the verbs over the
SAME store + the SAME authz gates — no second implementation.

- store.DeleteMembership: idempotent revoke keyed by the same (user, org)
  natural key EnsureMembership uses; absent row => (false, nil).
- memberships.Route registers the three verbs: get/add REUSE the REST
  list/ensure handlers verbatim; delete adds a remove handler under the
  same authz.Can(POST, organizations, admin, org) gate as ensure.
- authz.handlerAuthorizedPrefixes gains /v1/iam/get-memberships: its
  target rides in ?user=/?org= (not owner/name), so the Guard authenticates
  and the list handler's scoped() check is the tenant gate — exactly as its
  REST twin /v1/iam/memberships already is. The POST verbs are raw handlers
  the Guard never pre-authorizes; each self-authorizes.

Tests: get by ?user and ?org; add then get shows it; delete removes and is
idempotent; cross-tenant add/delete/read all refused auth:Unauthorized
operation; unauthenticated => 401.
2026-07-22 14:24:07 -07:00
Hanzo AI d90ca78bd8 feat(oidc): mint the orgs membership claim (GAP C)
Clean-room user tokens carried owner/organization but NOT the membership
set, so a multi-org identity would silently collapse to home-org-only at
the prod cutover. Thread the tenancy set into every user mint.

- Claims gains Orgs []schema.OrgRef (json orgs,omitempty).
- Sign/SignID/SignUserToken take an orgs param and stamp claims.Orgs;
  nil (a machine token) omits the claim. Signer stays schema.User-decoupled.
- store.MemberOrgRefs is the ONE resolver: home org first (role from
  HomeRole), then explicit MembershipsByUser rows, deduped (home wins,
  never twice). Mirrors beego token_jwt.go home-union-explicit; nil-safe.
- issueTokens (code/refresh/password) resolves the user once via userClaims
  and threads orgs into access + id token; token-exchange and
  issue-user-token thread the already-resolved user; client_credentials
  passes nil.

Tests: store home-first/dedup + home-only/nil; oidc drives the real
authorization_code path and asserts orgs on access AND id token, home-only,
and NO orgs on a client_credentials machine token.
2026-07-22 14:19:08 -07:00
Hanzo AI 8ebbc078f0 feat(oidc): mint the orgs membership claim (GAP C)
Clean-room user tokens carried owner/organization but NOT the membership
set, so a multi-org identity would silently collapse to home-org-only at
the prod cutover. Thread the tenancy set into every user mint.

- Claims gains Orgs []schema.OrgRef (json orgs,omitempty).
- Sign/SignID/SignUserToken take an orgs param and stamp claims.Orgs;
  nil (a machine token) omits the claim. Signer stays schema.User-decoupled.
- store.MemberOrgRefs is the ONE resolver: home org first (role from
  HomeRole), then explicit MembershipsByUser rows, deduped (home wins,
  never twice). Mirrors beego token_jwt.go home-union-explicit; nil-safe.
- issueTokens (code/refresh/password) resolves the user once via userClaims
  and threads orgs into access + id token; token-exchange and
  issue-user-token thread the already-resolved user; client_credentials
  passes nil.

Tests: store home-first/dedup + home-only/nil; oidc drives the real
authorization_code path and asserts orgs on access AND id token, home-only,
and NO orgs on a client_credentials machine token.
2026-07-22 14:19:08 -07:00
zeekayandhanzo-dev 407658ef58 build(iam2): digest-pin alpine + golang base images (DEK trust path)
alpine:latest is the runtime base providing the sqlcipher CLI the migrator's
--wal-inclusive path pipes the raw 32-byte DEK to on stdin, so the base image
sits in the decryption-key trust path. A floating :latest is not acceptable for
a one-shot migration of irreplaceable auth data — a moved/trojaned base could
exfiltrate every credential. Pin both bases to current manifest-list digests
(golang was version- but not digest-pinned; alpine was fully floating).

  alpine:latest  @ sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
  golang:1.26.4  @ sha256:f96cc555eb8db430159a3aa6797cd5bae561945b7b0fe7d0e284c63a3b291609

RED re-verify of v1.32.6 (migrator WAL guards) LOW finding; canary runs this
exact image. No functional change.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-22 11:40:08 -07:00
zeekayandClaude Opus 4.8 0acde518e5 build(iam2): digest-pin alpine + golang base images (DEK trust path)
alpine:latest is the runtime base providing the sqlcipher CLI the migrator's
--wal-inclusive path pipes the raw 32-byte DEK to on stdin, so the base image
sits in the decryption-key trust path. A floating :latest is not acceptable for
a one-shot migration of irreplaceable auth data — a moved/trojaned base could
exfiltrate every credential. Pin both bases to current manifest-list digests
(golang was version- but not digest-pinned; alpine was fully floating).

  alpine:latest  @ sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
  golang:1.26.4  @ sha256:f96cc555eb8db430159a3aa6797cd5bae561945b7b0fe7d0e284c63a3b291609

RED re-verify of v1.32.6 (migrator WAL guards) LOW finding; canary runs this
exact image. No functional change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:40:08 -07:00
zeekayandhanzo-dev 99c0aef6bf security(iam2): ONE header-immune host accessor — SIWE domain/CSRF via c.Host(), fail-loud issuer
Close the X-Forwarded-Host footgun repo-wide and make "never echo the request
host into `iss`" structural in every issuer mode. One host accessor now: the
header-immune zip.Ctx.Host() (zip has no trusted-proxy knob, so a client
X-Forwarded-Host is ignored), the same seam the OIDC issuer resolver uses.

Part A — internal/oidc/issuer.go (issuer resolver uniformity):
- Fix #1: gate the host-relative dev issuer behind an explicit opt-in
  (IAM_DEV_HOST_RELATIVE=1). Absent it, empty IAM_ISSUER + unset map is a HARD
  boot error (errNoIssuer) instead of a silent fail-open host echo. issuerFor
  echoes the host ONLY inside the opt-in branch; every other branch returns a
  config value or the fixed devFallbackIssuer, so the no-echo property is
  structural, not just emergent from construction.
- Fix #3: https-validate a non-empty IAM_ISSUER at construction, the same bar
  map entries already clear (checked first, before the map).

Part B — retire httpx.EffectiveHost (internal/httpx/response.go, internal/wallet/*):
- EffectiveHost read X-Forwarded-Host DIRECTLY, bypassing zip's (absent)
  proxy-trust, so TrustProxy=false did NOT protect it. It fed the SIWE `domain`
  (the EIP-4361/CAIP-122 anti-phishing binding a user signs) and the CSRF
  same-origin check. A client who could inject X-Forwarded-Host could steer the
  signed domain (cross-brand phishing) and pass the same-origin check cross-site
  (wallet-linking CSRF).
- Route all three wallet call-sites (nonce domain, verify domain, same-origin
  check) through c.Host(), then DELETE EffectiveHost so exactly one host
  accessor remains repo-wide. c.Host() is the true routed brand host the ingress
  preserves — exactly what the SIWE domain must bind to — and immune to the
  header, so the binding and CSRF check are correct AND unspoofable.

Tests: issuer boot-error/opt-in/https (Part A) + SIWE domain & CSRF
header-immunity driven end-to-end, plus a legit same-brand login still
succeeds (Part B). A mutation reintroducing the old X-Forwarded-Host behavior
fails all four Part B guards, including a demonstrated cross-site wallet-link
CSRF — proving they are non-vacuous.

CGO_ENABLED=0 GOWORK=off go build ./... && go vet ./... && go test ./... : green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-22 10:43:54 -07:00
zeekayandClaude Opus 4.8 ce2ff32c87 security(iam2): ONE header-immune host accessor — SIWE domain/CSRF via c.Host(), fail-loud issuer
Close the X-Forwarded-Host footgun repo-wide and make "never echo the request
host into `iss`" structural in every issuer mode. One host accessor now: the
header-immune zip.Ctx.Host() (zip has no trusted-proxy knob, so a client
X-Forwarded-Host is ignored), the same seam the OIDC issuer resolver uses.

Part A — internal/oidc/issuer.go (issuer resolver uniformity):
- Fix #1: gate the host-relative dev issuer behind an explicit opt-in
  (IAM_DEV_HOST_RELATIVE=1). Absent it, empty IAM_ISSUER + unset map is a HARD
  boot error (errNoIssuer) instead of a silent fail-open host echo. issuerFor
  echoes the host ONLY inside the opt-in branch; every other branch returns a
  config value or the fixed devFallbackIssuer, so the no-echo property is
  structural, not just emergent from construction.
- Fix #3: https-validate a non-empty IAM_ISSUER at construction, the same bar
  map entries already clear (checked first, before the map).

Part B — retire httpx.EffectiveHost (internal/httpx/response.go, internal/wallet/*):
- EffectiveHost read X-Forwarded-Host DIRECTLY, bypassing zip's (absent)
  proxy-trust, so TrustProxy=false did NOT protect it. It fed the SIWE `domain`
  (the EIP-4361/CAIP-122 anti-phishing binding a user signs) and the CSRF
  same-origin check. A client who could inject X-Forwarded-Host could steer the
  signed domain (cross-brand phishing) and pass the same-origin check cross-site
  (wallet-linking CSRF).
- Route all three wallet call-sites (nonce domain, verify domain, same-origin
  check) through c.Host(), then DELETE EffectiveHost so exactly one host
  accessor remains repo-wide. c.Host() is the true routed brand host the ingress
  preserves — exactly what the SIWE domain must bind to — and immune to the
  header, so the binding and CSRF check are correct AND unspoofable.

Tests: issuer boot-error/opt-in/https (Part A) + SIWE domain & CSRF
header-immunity driven end-to-end, plus a legit same-brand login still
succeeds (Part B). A mutation reintroducing the old X-Forwarded-Host behavior
fails all four Part B guards, including a demonstrated cross-site wallet-link
CSRF — proving they are non-vacuous.

CGO_ENABLED=0 GOWORK=off go build ./... && go vet ./... && go test ./... : green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 10:43:54 -07:00
zeekayandhanzo-dev a6c0c89b87 fix(migrate-v1): fail-closed WAL safety (copy fence + WAL-blind default block); ship sqlcipher + migrator
Close the two HIGH silent-data-loss vectors RED flagged in the WAL-inclusive
Casdoor->iam2 migrator, plus the release-image sqlcipher gap. A silent bug here
loses production auth data at cutover, so both guards fail closed.

HIGH #1 (wal.go) — non-atomic multi-file copy of a possibly-live shard. The
WAL-inclusive path copied iam.db, -wal and -shm as three separate reads; a
checkpoint firing mid-copy moves committed frames out of the -wal we already read
into a main db we did not (then TRUNCATEs the -wal), silently dropping those rows.
Add a read-consistency fence: snapshot (existence,size,mtime) of all three files
before the copy and re-snapshot after; if anything moved, the source was written
during the window and the copied triple may be internally inconsistent -> abort
loudly. Pass => a consistent point-in-time image (zero frames lost); fail => a
hard refusal. Never a silent drop. Plus a defensive post-export drain check: the
copy's -wal must be emptied by wal_checkpoint(TRUNCATE) or the run aborts.

HIGH #2 (encrypted.go, main.go) — WAL-blind default was a silent lossy default.
The default checkpointed path only printed a non-blocking warning; forgetting
--wal-inclusive silently dropped every uncheckpointed-WAL row and reported
success. Now the default path HARD-FAILS on any shard carrying a non-empty -wal,
naming --wal-inclusive (capture the rows) or the new --ignore-wal (intentionally
drop them, with a loud warning). Checked before the dest store is opened, so a
refusal writes nothing.

Dockerfile — build /migrate-v1 alongside /iam2 and apk add sqlcipher (alpine
ships SQLCipher 4.x: 4.5.6 stable / 4.6.x edge, v4 format matches the production
data and the pure-Go codec) so ONE image serves both the server and the migration
Job; fix the stale image.source label iam2 -> iam.

Tests (real C sqlcipher present, the e2e is NOT skipped): concurrent-writer /
moving-WAL abort; default-path -wal hard-fail + --ignore-wal override; guard unit
matrix. Full tree green. Both new guards proven load-bearing via neuter-and-fail
negative controls.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-22 10:19:19 -07:00
zeekayandClaude Opus 4.8 a6f615aeed fix(migrate-v1): fail-closed WAL safety (copy fence + WAL-blind default block); ship sqlcipher + migrator
Close the two HIGH silent-data-loss vectors RED flagged in the WAL-inclusive
Casdoor->iam2 migrator, plus the release-image sqlcipher gap. A silent bug here
loses production auth data at cutover, so both guards fail closed.

HIGH #1 (wal.go) — non-atomic multi-file copy of a possibly-live shard. The
WAL-inclusive path copied iam.db, -wal and -shm as three separate reads; a
checkpoint firing mid-copy moves committed frames out of the -wal we already read
into a main db we did not (then TRUNCATEs the -wal), silently dropping those rows.
Add a read-consistency fence: snapshot (existence,size,mtime) of all three files
before the copy and re-snapshot after; if anything moved, the source was written
during the window and the copied triple may be internally inconsistent -> abort
loudly. Pass => a consistent point-in-time image (zero frames lost); fail => a
hard refusal. Never a silent drop. Plus a defensive post-export drain check: the
copy's -wal must be emptied by wal_checkpoint(TRUNCATE) or the run aborts.

HIGH #2 (encrypted.go, main.go) — WAL-blind default was a silent lossy default.
The default checkpointed path only printed a non-blocking warning; forgetting
--wal-inclusive silently dropped every uncheckpointed-WAL row and reported
success. Now the default path HARD-FAILS on any shard carrying a non-empty -wal,
naming --wal-inclusive (capture the rows) or the new --ignore-wal (intentionally
drop them, with a loud warning). Checked before the dest store is opened, so a
refusal writes nothing.

Dockerfile — build /migrate-v1 alongside /iam2 and apk add sqlcipher (alpine
ships SQLCipher 4.x: 4.5.6 stable / 4.6.x edge, v4 format matches the production
data and the pure-Go codec) so ONE image serves both the server and the migration
Job; fix the stale image.source label iam2 -> iam.

Tests (real C sqlcipher present, the e2e is NOT skipped): concurrent-writer /
moving-WAL abort; default-path -wal hard-fail + --ignore-wal override; guard unit
matrix. Full tree green. Both new guards proven load-bearing via neuter-and-fail
negative controls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 10:19:19 -07:00
zeekay 4c40862da6 Merge remote-tracking branch 'origin/main' into feat/migrate-v1-wal-inclusive 2026-07-22 10:00:26 -07:00
zeekay 86c05f630a Merge remote-tracking branch 'origin/main' into feat/migrate-v1-wal-inclusive 2026-07-22 10:00:26 -07:00
zeekayandhanzo-dev 85842442b9 security(iam2): per-host OIDC issuer resolver for single multi-tenant instance
iam2 runs as ONE instance behind the ingress for every brand host (hanzo.id,
lux.id, id.zoo.network, pars.id, and their iam.* aliases). A single pinned
IAM_ISSUER made every non-matching brand emit the wrong `iss`, failing all their
RP validations. This adds a fail-closed, config-driven issuer resolver so each
brand emits its OWN pinned issuer from the SAME instance.

- internal/oidc/issuer.go: the ONE resolver. newIssuerResolver parses
  IAM_ISSUER (default) + IAM_ISSUER_MAP (JSON host->issuer) once into an
  immutable value. issuerFor(host): configured brand -> its pinned issuer;
  unknown/spoofed host -> the pinned default (fail-closed, never echoes the
  host); no config at all -> dev host-relative from the TRUSTED host. A map
  without a default, malformed JSON, or a non-https/empty entry is a hard
  startup error (fail LOUD, never silently mint under the wrong iss).
- tokenIssuer and federationBaseURL now both route through resolveIssuer(c.Host()).
  c.Host() is zip's X-Forwarded-Host-immune accessor, so the issuer is ALWAYS a
  trusted CONFIG value — a client header can only SELECT a configured brand,
  never inject an arbitrary/foreign iss. This is the single source of truth:
  discovery `issuer`, the derived `jwks_uri`, token `iss`, userinfo, device
  verify, and the federation callback origin all resolve identically per brand.
- main.go serve(): InitIssuerResolver() pins the map before the listener opens.
- Backward compatible: unset IAM_ISSUER_MAP == today's single-issuer behavior.

Tests: table resolution (brands, aliases, case/whitespace/port/trailing-dot),
attacker-host-never-echoed (incl. suffix/prefix confusion), backward-compat,
hard-error config, and HTTP e2e proving token `iss` == discovery `issuer` ==
`jwks_uri` base per brand and that a spoofed Host/X-Forwarded-Host fails closed.
go build/vet/test all green (CGO_ENABLED=0 GOWORK=off).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-22 09:33:14 -07:00
zeekayandClaude Opus 4.8 ba58e6db9d security(iam2): per-host OIDC issuer resolver for single multi-tenant instance
iam2 runs as ONE instance behind the ingress for every brand host (hanzo.id,
lux.id, id.zoo.network, pars.id, and their iam.* aliases). A single pinned
IAM_ISSUER made every non-matching brand emit the wrong `iss`, failing all their
RP validations. This adds a fail-closed, config-driven issuer resolver so each
brand emits its OWN pinned issuer from the SAME instance.

- internal/oidc/issuer.go: the ONE resolver. newIssuerResolver parses
  IAM_ISSUER (default) + IAM_ISSUER_MAP (JSON host->issuer) once into an
  immutable value. issuerFor(host): configured brand -> its pinned issuer;
  unknown/spoofed host -> the pinned default (fail-closed, never echoes the
  host); no config at all -> dev host-relative from the TRUSTED host. A map
  without a default, malformed JSON, or a non-https/empty entry is a hard
  startup error (fail LOUD, never silently mint under the wrong iss).
- tokenIssuer and federationBaseURL now both route through resolveIssuer(c.Host()).
  c.Host() is zip's X-Forwarded-Host-immune accessor, so the issuer is ALWAYS a
  trusted CONFIG value — a client header can only SELECT a configured brand,
  never inject an arbitrary/foreign iss. This is the single source of truth:
  discovery `issuer`, the derived `jwks_uri`, token `iss`, userinfo, device
  verify, and the federation callback origin all resolve identically per brand.
- main.go serve(): InitIssuerResolver() pins the map before the listener opens.
- Backward compatible: unset IAM_ISSUER_MAP == today's single-issuer behavior.

Tests: table resolution (brands, aliases, case/whitespace/port/trailing-dot),
attacker-host-never-echoed (incl. suffix/prefix confusion), backward-compat,
hard-error config, and HTTP e2e proving token `iss` == discovery `issuer` ==
`jwks_uri` base per brand and that a spoofed Host/X-Forwarded-Host fails closed.
go build/vet/test all green (CGO_ENABLED=0 GOWORK=off).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:33:14 -07:00
zeekayandhanzo-dev 2485548c9f security(iam2): owner-pin app capabilities + mint; enforce global clientId uniqueness
Closes the app-capability/mint escalation RED found after the prior pass: an
app principal's authority was keyed on its bare NAME, and the mint gate trusted
the resolved row's clientId as globally unique — neither the app's OWNING org nor
clientId uniqueness was enforced. Once public signup opens, a tenant could
signup -> onboard -> register <theirOrg>/hanzo-console (or an app whose clientId
collides with a mint-allow-listed one) and Basic-auth as it to inherit platform
capabilities / mint tokens for any user.

The fix is ONE conceptual change — pin the app's OWNING org to a reserved platform
signing owner (store.IsSigningCertOwner) — applied at the two gates an app's
authority flows through:

- [CRITICAL A] Principal carries AppOwner (set from the app row's Owner, not its
  served Organization). authz.Allowed — the single funnel for EVERY app capability
  (CapOrgAdmin/CapUserAdmin in authz, CapKeyMint/CapServiceAccountRead in
  serviceaccounts) — grants nothing unless AppOwner is admin/built-in. The NAME
  allowlist is thereby reserved to the admin-owned app, as its comment always
  claimed but never enforced.
- [HIGH B] oidc mintAllowed/adminMintAllowed take the resolved app and require the
  same owner-pin, so a colliding-clientId tenant app mints nothing. Additionally:
  clientId is now globally unique — enforced at the applications create/update
  write (ensureClientIdUnique), the JSON-document store's equivalent of the
  (owner,name) natural key since it has no column for a DB UNIQUE index — and
  store.GetApplicationByClientId resolves DETERMINISTICALLY, admin-preferring, so a
  stray duplicate resolves to the platform row on every backend (the First()
  no-ORDER-BY vector, unspecified on Postgres, is closed).
- [INFO] raw entity CRUD now gates the reserved-owner clause on store.IsReservedOrg
  (admin/built-in/app), the SAME predicate signup/onboarding use, so an "app"-org
  user is platform-reserved consistently across every surface.

All legit allow-listed/minter apps are admin-owned (seed Owner="admin"), so no
legitimate grant regresses. PoC tests for both attacks now DENY (incl. RED's four
assertions verbatim), and the full suite is green. [LOW] verified read-only against
the live iam-init-data ConfigMap: no admin/built-in/app-org app relies on
client_credentials/password, and all 80 seeded clientIds are already globally
unique (no dedup migration needed).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-22 03:46:20 -07:00
zeekayandClaude Opus 4.8 90d11bd646 security(iam2): owner-pin app capabilities + mint; enforce global clientId uniqueness
Closes the app-capability/mint escalation RED found after the prior pass: an
app principal's authority was keyed on its bare NAME, and the mint gate trusted
the resolved row's clientId as globally unique — neither the app's OWNING org nor
clientId uniqueness was enforced. Once public signup opens, a tenant could
signup -> onboard -> register <theirOrg>/hanzo-console (or an app whose clientId
collides with a mint-allow-listed one) and Basic-auth as it to inherit platform
capabilities / mint tokens for any user.

The fix is ONE conceptual change — pin the app's OWNING org to a reserved platform
signing owner (store.IsSigningCertOwner) — applied at the two gates an app's
authority flows through:

- [CRITICAL A] Principal carries AppOwner (set from the app row's Owner, not its
  served Organization). authz.Allowed — the single funnel for EVERY app capability
  (CapOrgAdmin/CapUserAdmin in authz, CapKeyMint/CapServiceAccountRead in
  serviceaccounts) — grants nothing unless AppOwner is admin/built-in. The NAME
  allowlist is thereby reserved to the admin-owned app, as its comment always
  claimed but never enforced.
- [HIGH B] oidc mintAllowed/adminMintAllowed take the resolved app and require the
  same owner-pin, so a colliding-clientId tenant app mints nothing. Additionally:
  clientId is now globally unique — enforced at the applications create/update
  write (ensureClientIdUnique), the JSON-document store's equivalent of the
  (owner,name) natural key since it has no column for a DB UNIQUE index — and
  store.GetApplicationByClientId resolves DETERMINISTICALLY, admin-preferring, so a
  stray duplicate resolves to the platform row on every backend (the First()
  no-ORDER-BY vector, unspecified on Postgres, is closed).
- [INFO] raw entity CRUD now gates the reserved-owner clause on store.IsReservedOrg
  (admin/built-in/app), the SAME predicate signup/onboarding use, so an "app"-org
  user is platform-reserved consistently across every surface.

All legit allow-listed/minter apps are admin-owned (seed Owner="admin"), so no
legitimate grant regresses. PoC tests for both attacks now DENY (incl. RED's four
assertions verbatim), and the full suite is green. [LOW] verified read-only against
the live iam-init-data ConfigMap: no admin/built-in/app-org app relies on
client_credentials/password, and all 80 seeded clientIds are already globally
unique (no dedup migration needed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 03:46:20 -07:00
zeekayandhanzo-dev 259d37c78d security(iam2): close signup→admin-org SuperAdmin escalation; DRY the reserved-org gate
The 7-round Casdoor audit's invariant 1 (no admin-org self-signup) was the one
gap in iam2's clean-room model: onboarding and federated provisioning refused a
reserved system org, but POST /v1/iam/signup did not. Its only org gate was the
same-org/shared/org-choice tenant check — so a signup-enabled app that is admin-org,
shared, or org-choice admitted `organization=admin`, and a user under "admin" IS a
SuperAdmin (authz derives Super from owner == "admin"). Reachability depends on the
deploy-time init_data (no signup app ships in-repo), so this is closed structurally.

- store.IsReservedOrg — ONE reserved-org predicate {admin, built-in, app}, composed
  on IsSigningCertOwner so a new signing owner is covered for free. Replaces the
  package-private oidc.reservedOrgs map; onboard + federation now consult it too, so
  the reserved set can never drift across the three self-service surfaces.
- signup: refuse a reserved org before the tenant gate, independent of the app —
  byte-identical message to the tenant refuse, so there is no reserved-vs-tenant oracle.
- token endpoint (invariant 5, defense in depth): publicTokenEndpointForbidden refuses
  a reserved-Organization app (admin/built-in/app) on client_credentials + password,
  composing the existing <org>-iam gate — an admin-org app cannot mint on the public
  endpoint, structurally.
- signup.go: corrected a stale "bcrypt" comment (the canonical users.Create path
  stamps argon2id).

Tests: store.IsReservedOrg; signup refusal through shared / admin-org / org-choice
apps (+ no-oracle proof, + legitimate-tenant regression); token-endpoint reserved-org
refusal on both machine grants (+ tenant-org still-works regression); and the
lux.cloud signup-readiness proof (public PKCE lux-cloud client → PLAIN non-admin user
in the lux org, argon2id, EmailVerified=false).

Patch bump v1.32.2 -> v1.32.3 (tag cut on merge, post-RED, owner-gated).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-22 01:24:37 -07:00
zeekayandClaude Opus 4.8 ebccc6a854 security(iam2): close signup→admin-org SuperAdmin escalation; DRY the reserved-org gate
The 7-round Casdoor audit's invariant 1 (no admin-org self-signup) was the one
gap in iam2's clean-room model: onboarding and federated provisioning refused a
reserved system org, but POST /v1/iam/signup did not. Its only org gate was the
same-org/shared/org-choice tenant check — so a signup-enabled app that is admin-org,
shared, or org-choice admitted `organization=admin`, and a user under "admin" IS a
SuperAdmin (authz derives Super from owner == "admin"). Reachability depends on the
deploy-time init_data (no signup app ships in-repo), so this is closed structurally.

- store.IsReservedOrg — ONE reserved-org predicate {admin, built-in, app}, composed
  on IsSigningCertOwner so a new signing owner is covered for free. Replaces the
  package-private oidc.reservedOrgs map; onboard + federation now consult it too, so
  the reserved set can never drift across the three self-service surfaces.
- signup: refuse a reserved org before the tenant gate, independent of the app —
  byte-identical message to the tenant refuse, so there is no reserved-vs-tenant oracle.
- token endpoint (invariant 5, defense in depth): publicTokenEndpointForbidden refuses
  a reserved-Organization app (admin/built-in/app) on client_credentials + password,
  composing the existing <org>-iam gate — an admin-org app cannot mint on the public
  endpoint, structurally.
- signup.go: corrected a stale "bcrypt" comment (the canonical users.Create path
  stamps argon2id).

Tests: store.IsReservedOrg; signup refusal through shared / admin-org / org-choice
apps (+ no-oracle proof, + legitimate-tenant regression); token-endpoint reserved-org
refusal on both machine grants (+ tenant-org still-works regression); and the
lux.cloud signup-readiness proof (public PKCE lux-cloud client → PLAIN non-admin user
in the lux org, argon2id, EmailVerified=false).

Patch bump v1.32.2 -> v1.32.3 (tag cut on merge, post-RED, owner-gated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 01:24:37 -07:00
zeekayandhanzo-dev e510976526 feat(migrate-v1): WAL-inclusive extraction for the encrypted sharded source
The encrypted-source path decrypted only each shard's CHECKPOINTED main db
(sqlcipher.DecryptFile), so rows still in an uncheckpointed `-wal` were
invisible. On the real production store most org shards carry 600-800KB of
uncheckpointed WAL, so a cutover built on the checkpointed image alone
undercounts users and locks recent signups out.

Add `--wal-inclusive` (with `--sqlcipher-bin`, default `sqlcipher`): per shard,
copy iam.db(+-wal/-shm) into a fresh 0700 temp, derive the DEK with the SAME
pure-Go recipe (deriveDEK, now shared with the checkpointed path), then drive
the C sqlcipher binary to `wal_checkpoint(TRUNCATE)` + `sqlcipher_export` a
plaintext copy, which the existing Migrate engine reads. The checkpointed path
stays the default so nothing regresses.

Safety: the DEK reaches the child ONLY on stdin (raw x'..' key), never on argv
or in logs; script bytes + hex are zeroed and child stderr is scrubbed of the
hex. A non-zero exit (-bail) or a non-SQLite export (wrong key) fails LOUDLY;
--wal-inclusive with an absent binary errors at preflight (never silent
fallback). The plaintext temp dir is shredded on every path incl. error.

Tests (CGO_ENABLED=0): a self-exec fake-sqlcipher proves key-off-argv,
plaintext validation, non-zero-exit and missing-binary fail loud, and shred;
and a real-C-sqlcipher end-to-end builds an org shard whose user lives ONLY in
an uncheckpointed -wal, proving the default path MISSES it while
--wal-inclusive recovers it and its golden argon2id digest verifies (skipped
when the binary is absent).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-21 17:02:48 -07:00
zeekayandClaude Opus 4.8 5885b52fd2 feat(migrate-v1): WAL-inclusive extraction for the encrypted sharded source
The encrypted-source path decrypted only each shard's CHECKPOINTED main db
(sqlcipher.DecryptFile), so rows still in an uncheckpointed `-wal` were
invisible. On the real production store most org shards carry 600-800KB of
uncheckpointed WAL, so a cutover built on the checkpointed image alone
undercounts users and locks recent signups out.

Add `--wal-inclusive` (with `--sqlcipher-bin`, default `sqlcipher`): per shard,
copy iam.db(+-wal/-shm) into a fresh 0700 temp, derive the DEK with the SAME
pure-Go recipe (deriveDEK, now shared with the checkpointed path), then drive
the C sqlcipher binary to `wal_checkpoint(TRUNCATE)` + `sqlcipher_export` a
plaintext copy, which the existing Migrate engine reads. The checkpointed path
stays the default so nothing regresses.

Safety: the DEK reaches the child ONLY on stdin (raw x'..' key), never on argv
or in logs; script bytes + hex are zeroed and child stderr is scrubbed of the
hex. A non-zero exit (-bail) or a non-SQLite export (wrong key) fails LOUDLY;
--wal-inclusive with an absent binary errors at preflight (never silent
fallback). The plaintext temp dir is shredded on every path incl. error.

Tests (CGO_ENABLED=0): a self-exec fake-sqlcipher proves key-off-argv,
plaintext validation, non-zero-exit and missing-binary fail loud, and shred;
and a real-C-sqlcipher end-to-end builds an org shard whose user lives ONLY in
an uncheckpointed -wal, proving the default path MISSES it while
--wal-inclusive recovers it and its golden argon2id digest verifies (skipped
when the binary is absent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:02:48 -07:00
zeekayandhanzo-dev ba3fb0ae81 feat(store): export in-process Project reads + model.Project for embedders
Add pkg/store — the in-process project store surface for a host binary that
EMBEDS iam2 (hanzoai/cloud) rather than talking to it over HTTP. GetProjects /
GetOrganizationProjects / GetProject / AddProject / DeleteProject take an explicit
orm.DB (v2 has no package-global engine) and reproduce the ONE project CRUD path's
orm calls, preserving the "owner/name" id semantics, so an embedder reads/writes
the SAME `projects` rows the mounted /v1/iam/projects surface serves. GetProject
returns (nil,nil) on miss — the embedder pre-check convention the retired iam-v1
object store used.

Also alias model.Project = schema.Project (mirroring model.OrgRef) so embedders
share the ONE project type, never a local clone.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-21 15:41:39 -07:00
zeekayandClaude Opus 4.8 97acc4cdd3 feat(store): export in-process Project reads + model.Project for embedders
Add pkg/store — the in-process project store surface for a host binary that
EMBEDS iam2 (hanzoai/cloud) rather than talking to it over HTTP. GetProjects /
GetOrganizationProjects / GetProject / AddProject / DeleteProject take an explicit
orm.DB (v2 has no package-global engine) and reproduce the ONE project CRUD path's
orm calls, preserving the "owner/name" id semantics, so an embedder reads/writes
the SAME `projects` rows the mounted /v1/iam/projects surface serves. GetProject
returns (nil,nil) on miss — the embedder pre-check convention the retired iam-v1
object store used.

Also alias model.Project = schema.Project (mirroring model.OrgRef) so embedders
share the ONE project type, never a local clone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:41:39 -07:00
zeekayandhanzo-dev 733cb7148b feat(migrate-v1): read encrypted, sharded production source directly
Add a pure-Go encrypted-source path to the Phase-5 cutover migrator so the
whole fork -> clean migration is one command. The plaintext --src path is
unchanged.

New --src-datadir reads the SQLCipher/envelope-encrypted, sharded prod layout
in place: the GLOBAL shard (<dir>/iam.db; certs/apps/orgs; principal
global/"iam") first, then every <dir>/orgs/<slug>/iam.db (users; principal
org/<slug>) in sorted order. Each shard is decrypted with the proven recipe --
sqlite.DeriveKey (HKDF) -> read .dek sidecar -> sqlite.UnwrapDEK (AES-256-GCM)
-> sqlcipher.DecryptFile (pure-Go page codec) -> a 0600 temp -> the existing
Migrate engine (modernc, read-only) -> shred. Shards merge because Migrate
upserts by natural key.

Driver collision: DeriveKey/UnwrapDEK/PrincipalAAD are pure funcs in the ROOT
hanzoai/sqlite package that migrate.go already imports for the "sqlite" driver;
promoting that to a named import registers no new driver (one modernc
registrant under CGO_ENABLED=0), and hanzoai/sqlcipher registers none. So the
decrypt and the read live in one binary -- no os/exec helper, crypto stays in
hanzoai/sqlite. Documented in encrypted.go.

Safety: master key read from a NAMED env var (--src-master-key-env, default
IAM_KMS_MASTER_KEY), never an arg, never logged; wrong key fails loud at
UnwrapDEK before any write; temps shredded on every path incl. error; a
--note logs that the run captures checkpointed state (no -wal merge).

Tests (CGO_ENABLED=0): build a real encrypted fixture in-test via a reserved
canvas (DecryptFile the C libsqlcipher interop vector -> reserved plaintext;
modernc preserves the 80-byte reserve; EncryptFile our own schema) and run the
full DeriveKey->Unwrap->DecryptFile->migrate chain across a global + two org
shards -- the golden argon2id digest verifies under internal/cred after
decryption, wrong master fails loud with no writes, temps are shredded.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-21 13:43:28 -07:00
zeekayandClaude Opus 4.8 a69e1ed555 feat(migrate-v1): read encrypted, sharded production source directly
Add a pure-Go encrypted-source path to the Phase-5 cutover migrator so the
whole fork -> clean migration is one command. The plaintext --src path is
unchanged.

New --src-datadir reads the SQLCipher/envelope-encrypted, sharded prod layout
in place: the GLOBAL shard (<dir>/iam.db; certs/apps/orgs; principal
global/"iam") first, then every <dir>/orgs/<slug>/iam.db (users; principal
org/<slug>) in sorted order. Each shard is decrypted with the proven recipe --
sqlite.DeriveKey (HKDF) -> read .dek sidecar -> sqlite.UnwrapDEK (AES-256-GCM)
-> sqlcipher.DecryptFile (pure-Go page codec) -> a 0600 temp -> the existing
Migrate engine (modernc, read-only) -> shred. Shards merge because Migrate
upserts by natural key.

Driver collision: DeriveKey/UnwrapDEK/PrincipalAAD are pure funcs in the ROOT
hanzoai/sqlite package that migrate.go already imports for the "sqlite" driver;
promoting that to a named import registers no new driver (one modernc
registrant under CGO_ENABLED=0), and hanzoai/sqlcipher registers none. So the
decrypt and the read live in one binary -- no os/exec helper, crypto stays in
hanzoai/sqlite. Documented in encrypted.go.

Safety: master key read from a NAMED env var (--src-master-key-env, default
IAM_KMS_MASTER_KEY), never an arg, never logged; wrong key fails loud at
UnwrapDEK before any write; temps shredded on every path incl. error; a
--note logs that the run captures checkpointed state (no -wal merge).

Tests (CGO_ENABLED=0): build a real encrypted fixture in-test via a reserved
canvas (DecryptFile the C libsqlcipher interop vector -> reserved plaintext;
modernc preserves the 80-byte reserve; EncryptFile our own schema) and run the
full DeriveKey->Unwrap->DecryptFile->migrate chain across a global + two org
shards -- the golden argon2id digest verifies under internal/cred after
decryption, wrong master fails loud with no writes, temps are shredded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 13:43:28 -07:00
zeekayandhanzo-dev 9023d7394a feat(migrate-v1): Phase-5 legacy Casdoor -> clean IAM data migrator
Reads the legacy Casdoor-fork SQLite iam.db and upserts every identity
record into the clean IAM v2 orm store, preserving credentials and signing
keys byte-for-byte (the cutover gate).

- cmd/migrate-v1: standalone `migrate-v1 --src <iam.db> --dest <data-dir>
  [--dry-run] [--only ...]`. Opens the source read-only; opens the dest
  through the SAME store.Open the server uses (schema parity).
- Column mapping is discovered, not assumed: PRAGMA table_info + sqlite_master,
  matched to clean schema fields by a normalized key (lowercase, strip
  underscores) so xorm snake_case lines up with camelCase json tags. The one
  rename normalization can't bridge — legacy `password` column -> clean
  PasswordHash — is declared explicitly, so no user's hash is dropped.
- Credential/key material (PasswordHash/Type/Salt, Cert.PrivateKey/Certificate)
  is reconstructed as JSON and unmarshaled, never passed through a lossy typed
  conversion. Natural key is owner/name (== the clean OIDC sub); the legacy
  per-row UUID has no clean home and is reported as a gap.
- Idempotent: create/overwrite-if-changed/no-op, so re-runs don't duplicate or
  churn. --dry-run reports counts + a secret-redacted sample without writing.
- Entity order: organizations -> certs -> applications -> providers -> users
  -> roles -> permissions. Membership has no legacy table (noted, skipped).
- store.Open extracted from main.go so the server and the migrator share one
  store-open path.

Tests (CGO_ENABLED=0): synthetic legacy iam.db proves PasswordHash and
Cert.PrivateKey land verbatim, cred.Verify succeeds against the migrated
argon2id golden vector (own type + org fallback), re-run is a pure no-op, and
--dry-run writes nothing.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-21 10:28:37 -07:00
zeekayandClaude Opus 4.8 0fc3dee643 feat(migrate-v1): Phase-5 legacy Casdoor -> clean IAM data migrator
Reads the legacy Casdoor-fork SQLite iam.db and upserts every identity
record into the clean IAM v2 orm store, preserving credentials and signing
keys byte-for-byte (the cutover gate).

- cmd/migrate-v1: standalone `migrate-v1 --src <iam.db> --dest <data-dir>
  [--dry-run] [--only ...]`. Opens the source read-only; opens the dest
  through the SAME store.Open the server uses (schema parity).
- Column mapping is discovered, not assumed: PRAGMA table_info + sqlite_master,
  matched to clean schema fields by a normalized key (lowercase, strip
  underscores) so xorm snake_case lines up with camelCase json tags. The one
  rename normalization can't bridge — legacy `password` column -> clean
  PasswordHash — is declared explicitly, so no user's hash is dropped.
- Credential/key material (PasswordHash/Type/Salt, Cert.PrivateKey/Certificate)
  is reconstructed as JSON and unmarshaled, never passed through a lossy typed
  conversion. Natural key is owner/name (== the clean OIDC sub); the legacy
  per-row UUID has no clean home and is reported as a gap.
- Idempotent: create/overwrite-if-changed/no-op, so re-runs don't duplicate or
  churn. --dry-run reports counts + a secret-redacted sample without writing.
- Entity order: organizations -> certs -> applications -> providers -> users
  -> roles -> permissions. Membership has no legacy table (noted, skipped).
- store.Open extracted from main.go so the server and the migrator share one
  store-open path.

Tests (CGO_ENABLED=0): synthetic legacy iam.db proves PasswordHash and
Cert.PrivateKey land verbatim, cred.Verify succeeds against the migrated
argon2id golden vector (own type + org fallback), re-run is a pure no-op, and
--dry-run writes nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:28:37 -07:00
zeekayandhanzo-dev 09d57feb74 feat(model): export OrgRef so consumers can drop the dead iam-v1
cloud reads the `orgs` claim (the tenancy set) off a v2 token as []OrgRef,
today importing it from the dead github.com/hanzoai/iam-v1. Export the same
shape from v2's consumer surface so iam-v1 can die:

- schema.OrgRef{Org string `json:"org"`; Role string `json:"role,omitempty"`}
  — the claim-side projection of a Membership row (Membership is how the
  relation is stored, OrgRef is how it travels in a JWT). Same JSON tags as
  iam-v1, so a token minted by v2 and read by a consumer round-trips
  byte-for-byte.
- schema.OrgRefsFromMemberships / (*Membership).AsOrgRef — the ONE way to
  build the orgs-claim slice from stored memberships.
- pkg/model.OrgRef aliases schema.OrgRef (one canonical type, no drift),
  the ONE import path for external consumers: github.com/hanzoai/iam/pkg/model.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-21 10:04:04 -07:00
zeekayandClaude Opus 4.8 69bbda7368 feat(model): export OrgRef so consumers can drop the dead iam-v1
cloud reads the `orgs` claim (the tenancy set) off a v2 token as []OrgRef,
today importing it from the dead github.com/hanzoai/iam-v1. Export the same
shape from v2's consumer surface so iam-v1 can die:

- schema.OrgRef{Org string `json:"org"`; Role string `json:"role,omitempty"`}
  — the claim-side projection of a Membership row (Membership is how the
  relation is stored, OrgRef is how it travels in a JWT). Same JSON tags as
  iam-v1, so a token minted by v2 and read by a consumer round-trips
  byte-for-byte.
- schema.OrgRefsFromMemberships / (*Membership).AsOrgRef — the ONE way to
  build the orgs-claim slice from stored memberships.
- pkg/model.OrgRef aliases schema.OrgRef (one canonical type, no drift),
  the ONE import path for external consumers: github.com/hanzoai/iam/pkg/model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:04:04 -07:00
zeekayandhanzo-dev 4c22be0a1d feat(workspace): Workspace first-class entity — Organization → Workspace → Project
Add Workspace between Organization and Project, mirroring the Project entity
across every layer of IAM v2 (native orm, no Casdoor/xorm):

- schema.Workspace (orm.Model[Workspace]) — same shape as Project plus a
  Bucket field: IAM records the storage binding, storage owns the physical
  bucket name. Registered as kind "workspaces" in schema.go.
- internal/workspaces: the ONE workspace CRUD path (List/Get/Create/Update/
  Delete), a 1:1 mirror of internal/projects, mounted after the Guard.
- compat: get-organization-workspaces (ScopeSwitcher read, ?organization=,
  handler-authorized via authz.Scope) + add-/delete-workspace verbs, the same
  seam add-/delete-project use.
- Project gains a Workspace FK (empty ⇒ org-level, backward compatible),
  settable through the same projects.Input/apply path as every other field.

Native tests (real mounted router): lifecycle, bucket+isDefault round-trip,
Project Workspace-FK round-trip, write-needs-admin, cross-tenant isolation.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-21 10:03:52 -07:00
zeekayandClaude Opus 4.8 985463a763 feat(workspace): Workspace first-class entity — Organization → Workspace → Project
Add Workspace between Organization and Project, mirroring the Project entity
across every layer of IAM v2 (native orm, no Casdoor/xorm):

- schema.Workspace (orm.Model[Workspace]) — same shape as Project plus a
  Bucket field: IAM records the storage binding, storage owns the physical
  bucket name. Registered as kind "workspaces" in schema.go.
- internal/workspaces: the ONE workspace CRUD path (List/Get/Create/Update/
  Delete), a 1:1 mirror of internal/projects, mounted after the Guard.
- compat: get-organization-workspaces (ScopeSwitcher read, ?organization=,
  handler-authorized via authz.Scope) + add-/delete-workspace verbs, the same
  seam add-/delete-project use.
- Project gains a Workspace FK (empty ⇒ org-level, backward compatible),
  settable through the same projects.Input/apply path as every other field.

Native tests (real mounted router): lifecycle, bucket+isDefault round-trip,
Project Workspace-FK round-trip, write-needs-admin, cross-tenant isolation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:03:52 -07:00
zeekayandhanzo-dev c39010ffc0 ci: publish to ghcr.io/hanzoai/iam (repo renamed from iam2 — canonical)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-21 09:46:32 -07:00
zeekayandClaude d57e0a21f6 ci: publish to ghcr.io/hanzoai/iam (repo renamed from iam2 — canonical)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 09:46:32 -07:00
zeekayandhanzo-dev 50ce7670aa refactor(module): promote module path hanzoai/iam2 → hanzoai/iam (canonical)
The clean-room rewrite takes the canonical hanzoai/iam name; the retired Casdoor
fork now lives at hanzoai/iam-v1. Module path + self-imports rewritten; builds green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-21 08:56:32 -07:00
zeekayandClaude 950d255ecb refactor(module): promote module path hanzoai/iam2 → hanzoai/iam (canonical)
The clean-room rewrite takes the canonical hanzoai/iam name; the retired Casdoor
fork now lives at hanzoai/iam-v1. Module path + self-imports rewritten; builds green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 08:56:32 -07:00
hanzo-dev 26591fc031 Merge fix/federation-mfa-gate: close the federated-login MFA bypass + unlink
Two commits: (1) a redirect-flow 2FA resume so a federated login runs the same
second-factor gate a password login does — the callback parks a subject-pinned
KindFederation LoginChallenge and mints only after the factor verifies at
POST /v1/iam/oauth/federation/mfa; (2) POST /v1/iam/unlink, the account-linking
law's fail-closed inverse (self or SuperAdmin, app-permission-gated). go build,
vet, and full test ./... green.
2026-07-19 21:20:19 -07:00
hanzo-dev 4465ed5f2d Merge fix/federation-mfa-gate: close the federated-login MFA bypass + unlink
Two commits: (1) a redirect-flow 2FA resume so a federated login runs the same
second-factor gate a password login does — the callback parks a subject-pinned
KindFederation LoginChallenge and mints only after the factor verifies at
POST /v1/iam/oauth/federation/mfa; (2) POST /v1/iam/unlink, the account-linking
law's fail-closed inverse (self or SuperAdmin, app-permission-gated). go build,
vet, and full test ./... green.
2026-07-19 21:20:19 -07:00
hanzo-dev cb941de499 feat(oidc): POST /v1/iam/unlink — remove a federated link, fail-closed
Port the account-linking law's inverse from feat/social onto main's connector
model: the account holder (self) or a SuperAdmin may clear one provider link; an
org admin may not (unlinking is unpicking a sign-in method, not tenant admin). A
self-unlink additionally needs the application's CanUnlink flag, so an org that
mandates federated sign-in cannot let users strand themselves; a SuperAdmin is the
platform recovery path and is not bound by it. Re-linking still runs the full
verified-subject / verified-email law, so unlink only ever LEAVES an account
unlinked.

- Reads/writes the link through the ONE connectorFor registry (never reflecting
  the provider type onto a Go field name — the bug that made v1's GitLab unlink
  silently no-op). Self-authenticates via callerOf (session/bearer) on the public
  group, exactly as get-account/userinfo do (oidc cannot import authz).

Tests: self-unlink clears the connector when permitted; cross-user is refused and
the target's link survives; a forbidding app blocks self-unlink; an
unauthenticated request is refused and clears nothing.
2026-07-19 21:19:44 -07:00
hanzo-dev 450264fa00 feat(oidc): POST /v1/iam/unlink — remove a federated link, fail-closed
Port the account-linking law's inverse from feat/social onto main's connector
model: the account holder (self) or a SuperAdmin may clear one provider link; an
org admin may not (unlinking is unpicking a sign-in method, not tenant admin). A
self-unlink additionally needs the application's CanUnlink flag, so an org that
mandates federated sign-in cannot let users strand themselves; a SuperAdmin is the
platform recovery path and is not bound by it. Re-linking still runs the full
verified-subject / verified-email law, so unlink only ever LEAVES an account
unlinked.

- Reads/writes the link through the ONE connectorFor registry (never reflecting
  the provider type onto a Go field name — the bug that made v1's GitLab unlink
  silently no-op). Self-authenticates via callerOf (session/bearer) on the public
  group, exactly as get-account/userinfo do (oidc cannot import authz).

Tests: self-unlink clears the connector when permitted; cross-user is refused and
the target's link survives; a forbidding app blocks self-unlink; an
unauthenticated request is refused and clears nothing.
2026-07-19 21:19:44 -07:00
hanzo-dev ab6608deb7 fix(federation): gate federated login through the MFA second factor
A 2FA-enrolled user signing in through Google/GitHub skipped the factor a password
login demands: the callback minted the authorization code directly. Now that the
MFA gate is live, close the bypass with a redirect-flow 2FA resume.

- The callback, after linkOrProvision resolves the user, runs the second-factor
  gate BEFORE minting: if the user owes a factor (factor.Enabled and not
  remembered) it mints NOTHING — it parks the resume in a single-use, expiring,
  subject-pinned LoginChallenge (the SAME lifecycle the password gate uses, new
  KindFederation) and redirects the browser to the hosted 2FA page. An org that
  REQUIRES an unenrolled factor fails closed.
- POST /v1/iam/oauth/federation/mfa is the resume: it spends the challenge, loads
  the PINNED user from its subject (never the request), verifies the factor
  through the shared factor.Verify / factor.UseRecovery seam, and only then mints
  the code for the PINNED authorize request. The challenge id rides the httpOnly,
  SameSite=Lax cookie, so a cross-site POST carries none and fails closed.
- federationMint is the ONE mint path both the no-factor completion and the
  post-2FA resume reach; the resume body carries the factor and NOTHING else, so
  the target user and redirect_uri are structurally unswappable mid-flow.

Tests (federation_mfa_test.go): an enrolled user via federation gets NO code
without the factor then resumes to one; an unenrolled user flows through
unchanged; the challenge is single-use and expiring; user + redirect_uri pinning
holds against a steering body; a missing challenge fails closed; recovery codes
resume too. go build/vet/test ./... green.
2026-07-19 21:19:44 -07:00
hanzo-dev dccecc50ef fix(federation): gate federated login through the MFA second factor
A 2FA-enrolled user signing in through Google/GitHub skipped the factor a password
login demands: the callback minted the authorization code directly. Now that the
MFA gate is live, close the bypass with a redirect-flow 2FA resume.

- The callback, after linkOrProvision resolves the user, runs the second-factor
  gate BEFORE minting: if the user owes a factor (factor.Enabled and not
  remembered) it mints NOTHING — it parks the resume in a single-use, expiring,
  subject-pinned LoginChallenge (the SAME lifecycle the password gate uses, new
  KindFederation) and redirects the browser to the hosted 2FA page. An org that
  REQUIRES an unenrolled factor fails closed.
- POST /v1/iam/oauth/federation/mfa is the resume: it spends the challenge, loads
  the PINNED user from its subject (never the request), verifies the factor
  through the shared factor.Verify / factor.UseRecovery seam, and only then mints
  the code for the PINNED authorize request. The challenge id rides the httpOnly,
  SameSite=Lax cookie, so a cross-site POST carries none and fails closed.
- federationMint is the ONE mint path both the no-factor completion and the
  post-2FA resume reach; the resume body carries the factor and NOTHING else, so
  the target user and redirect_uri are structurally unswappable mid-flow.

Tests (federation_mfa_test.go): an enrolled user via federation gets NO code
without the factor then resumes to one; an unenrolled user flows through
unchanged; the challenge is single-use and expiring; user + redirect_uri pinning
holds against a steering body; a missing challenge fails closed; recovery codes
resume too. go build/vet/test ./... green.
2026-07-19 21:19:44 -07:00
hanzo-dev c62cad6be4 Merge feat/org-accounts: confidential-client capabilities + service accounts + memberships
Re-implemented on main's current architecture (not merged from feat/org): app
principals whose entire authority is a capability allowlist (closes v1's global-
admin-client hole), service-account identities with argon2id key secrets returned
once, and the User×Org×Role membership relation. Uses main's internal/cred (no
duplicate). The orgs token claim is a noted follow-up (needs the jwt Subject work).
go build/vet/test ./... green.
2026-07-19 20:55:32 -07:00
hanzo-dev 5c48e1cae9 Merge feat/org-accounts: confidential-client capabilities + service accounts + memberships
Re-implemented on main's current architecture (not merged from feat/org): app
principals whose entire authority is a capability allowlist (closes v1's global-
admin-client hole), service-account identities with argon2id key secrets returned
once, and the User×Org×Role membership relation. Uses main's internal/cred (no
duplicate). The orgs token claim is a noted follow-up (needs the jwt Subject work).
go build/vet/test ./... green.
2026-07-19 20:55:32 -07:00
hanzo-dev 3f3874acbb feat(org): confidential-client capabilities + service accounts + memberships
Three org/tenancy primitives main lacked, re-implemented on main's current
architecture (zip-group Route, main's internal/cred). Closes v1's "every client
credential is a global admin" hole.

- Confidential-client capabilities: authz.Principal gains App (the application
  NAME when the request authenticated via client_secret_basic); authz.app resolves
  `Authorization: Basic <clientId:secret>` against a constant-time secret compare;
  cap.go is the allowlist model (Cap{Name,Env}, Allowed, BoundToOrg, capFor). An
  app principal is NEVER Admin and NEVER Super — its ENTIRE authority is its
  capability allowlist, keyed on the admin-owned application name, so a leaked
  credential can neither cross a tenant nor reach signing material, and an unmapped
  entity or unset allowlist denies. httpx.Basic is the one RFC 7617 parser.
- Service accounts (/v1/iam/service-accounts): agent/bot identities as
  Type=service-account user rows — no password, key secret stored ONLY as an
  argon2id digest (internal/cred), the raw secret returned exactly once at mint and
  never again. create/rotate/revoke need the mint capability (app) or org-admin
  over the target org (human); list takes the read-only capability and is
  tenant-bound by the <org>-<app> name. keys.Mint exported as the one minting
  primitive.
- Memberships (/v1/iam/memberships): the (User x Org x Role) relation + store ops
  (EnsureMembership idempotent + no-downgrade, MembershipsByUser/ByOrg, home-org
  BackfillMemberships). Registered as the `memberships` kind. NOTE: emitting the
  `orgs` token claim needs the jwt Subject work (deliberately NOT landed here); the
  relation ships stored + queryable, the claim is a follow-up.

Structural: memberships/serviceaccounts Route on the AUTHED group and self-
authorize via the Principal/capabilities the Guard attached; their query-targeted
reads join handlerAuthorizedPrefixes so the Guard authenticates and the handler
org-scopes (no revived publicPaths).

Tests: authz capability policy (app acts only on its allowlisted entity, never
crosses to signing material, a non-allowlisted client is inert, never Super/Admin;
Allowed/BoundToOrg/capFor fail-secure); service-account mint (argon2id digest,
one-time, rotation retires the prior secret) + admin/read gates (capability-gated,
tenant-bound list); membership Ensure idempotency + no-downgrade + by-user/by-org +
backfill. go build/vet/test ./... all green.
2026-07-19 20:55:23 -07:00
hanzo-dev b68269ce11 feat(org): confidential-client capabilities + service accounts + memberships
Three org/tenancy primitives main lacked, re-implemented on main's current
architecture (zip-group Route, main's internal/cred). Closes v1's "every client
credential is a global admin" hole.

- Confidential-client capabilities: authz.Principal gains App (the application
  NAME when the request authenticated via client_secret_basic); authz.app resolves
  `Authorization: Basic <clientId:secret>` against a constant-time secret compare;
  cap.go is the allowlist model (Cap{Name,Env}, Allowed, BoundToOrg, capFor). An
  app principal is NEVER Admin and NEVER Super — its ENTIRE authority is its
  capability allowlist, keyed on the admin-owned application name, so a leaked
  credential can neither cross a tenant nor reach signing material, and an unmapped
  entity or unset allowlist denies. httpx.Basic is the one RFC 7617 parser.
- Service accounts (/v1/iam/service-accounts): agent/bot identities as
  Type=service-account user rows — no password, key secret stored ONLY as an
  argon2id digest (internal/cred), the raw secret returned exactly once at mint and
  never again. create/rotate/revoke need the mint capability (app) or org-admin
  over the target org (human); list takes the read-only capability and is
  tenant-bound by the <org>-<app> name. keys.Mint exported as the one minting
  primitive.
- Memberships (/v1/iam/memberships): the (User x Org x Role) relation + store ops
  (EnsureMembership idempotent + no-downgrade, MembershipsByUser/ByOrg, home-org
  BackfillMemberships). Registered as the `memberships` kind. NOTE: emitting the
  `orgs` token claim needs the jwt Subject work (deliberately NOT landed here); the
  relation ships stored + queryable, the claim is a follow-up.

Structural: memberships/serviceaccounts Route on the AUTHED group and self-
authorize via the Principal/capabilities the Guard attached; their query-targeted
reads join handlerAuthorizedPrefixes so the Guard authenticates and the handler
org-scopes (no revived publicPaths).

Tests: authz capability policy (app acts only on its allowlisted entity, never
crosses to signing material, a non-allowlisted client is inert, never Super/Admin;
Allowed/BoundToOrg/capFor fail-secure); service-account mint (argon2id digest,
one-time, rotation retires the prior secret) + admin/read gates (capability-gated,
tenant-bound list); membership Ensure idempotency + no-downgrade + by-user/by-org +
backfill. go build/vet/test ./... all green.
2026-07-19 20:55:23 -07:00
hanzo-dev 5e01c7d656 Merge feat/mfa-gate: login-time second-factor gate + recovery + challenge lifecycle
Re-implemented on main's current architecture: extends internal/mfa (no fork) via
a decomplected leaf domain internal/mfa/factor that the gate and enrollment share,
adds schema.LoginChallenge distinct from the web3 Challenge, gates every
interactive sign-in before minting. Recovery codes now hashed at rest. 12 gate
tests + full go test ./... green.
2026-07-19 20:44:29 -07:00
hanzo-dev e7c05f56ff Merge feat/mfa-gate: login-time second-factor gate + recovery + challenge lifecycle
Re-implemented on main's current architecture: extends internal/mfa (no fork) via
a decomplected leaf domain internal/mfa/factor that the gate and enrollment share,
adds schema.LoginChallenge distinct from the web3 Challenge, gates every
interactive sign-in before minting. Recovery codes now hashed at rest. 12 gate
tests + full go test ./... green.
2026-07-19 20:44:29 -07:00
hanzo-dev 0461d71f97 feat(mfa): login-time second-factor gate + recovery codes + challenge lifecycle
The MFA GATE that main's TOTP enrollment surface never had: a verified password
proves ONE factor, and the sign-in is held until a SECOND lands — before any
token or device approval. Built on main's existing internal/mfa + schema, not a
second mfa package.

- internal/mfa/factor: the pure multi-factor DOMAIN decomplected out of the
  enrollment surface — Verify (the ONE TOTP check both enrollment and the gate
  call), UseRecovery (bcrypt-or-legacy-plaintext, one-time), Enabled/Prompt/
  AllProps/Props, Copy/Save (column-scoped, so an MFA write can't carry isAdmin).
  A LEAF (imports only store+schema, never authz/oidc), which is what lets the
  gate use it without an authz→oidc→mfa→authz import cycle.
- internal/oidc/mfa_gate.go: gate() answers RequiredMfa (org demands an unenrolled
  factor) / NextMfa (data2 carries the choosable factors, no code minted) —
  verbatim v1 wire strings; finishMfa() verifies the factor and loads the user
  from the CHALLENGE subject, never the request body; the "remember this device"
  window fails closed on an unparsable deadline and preserves the zero-window =
  always-challenge behavior every live org relies on.
- internal/oidc/challenge.go + schema.LoginChallenge (kind login_challenges): a
  server-side, single-use, owner-scoped row replacing v1's beego cookie session —
  distinct from the web3 Challenge (that is a pre-auth nonce; this is bound to a
  known subject). TakeChallenge spends on read, so a captured id never replays.
- login.go: the gate runs for EVERY interactive sign-in; loginGrant is the one
  minting tail (device approval / session / code) both the credential post and
  the second-factor finish reach — so a second factor over a device approval
  lands at approveDevice, not a token. httpx.Ok gains a variadic data2.
- HARDENING: enrollment now stores recovery codes as bcrypt digests
  (factor.HashRecoveryCodes), never the plaintext bearer credential; UseRecovery
  still verifies v1-era plaintext rows so no live 2FA user loses their way back.

Tests: internal/oidc/login_mfa_test.go (12 cases — the enrolled-user-challenged
regression, single-use, subject-binding, recovery hashed + legacy plaintext,
repeat-factor refusal, remember-window round-trip + zero-window, org-required
enrollment, unenrolled-unchanged). go build ./..., go vet ./..., full go test
./... green; main's TotP enrollment tests unchanged.
2026-07-19 20:44:19 -07:00
hanzo-dev f6ca34e4b1 feat(mfa): login-time second-factor gate + recovery codes + challenge lifecycle
The MFA GATE that main's TOTP enrollment surface never had: a verified password
proves ONE factor, and the sign-in is held until a SECOND lands — before any
token or device approval. Built on main's existing internal/mfa + schema, not a
second mfa package.

- internal/mfa/factor: the pure multi-factor DOMAIN decomplected out of the
  enrollment surface — Verify (the ONE TOTP check both enrollment and the gate
  call), UseRecovery (bcrypt-or-legacy-plaintext, one-time), Enabled/Prompt/
  AllProps/Props, Copy/Save (column-scoped, so an MFA write can't carry isAdmin).
  A LEAF (imports only store+schema, never authz/oidc), which is what lets the
  gate use it without an authz→oidc→mfa→authz import cycle.
- internal/oidc/mfa_gate.go: gate() answers RequiredMfa (org demands an unenrolled
  factor) / NextMfa (data2 carries the choosable factors, no code minted) —
  verbatim v1 wire strings; finishMfa() verifies the factor and loads the user
  from the CHALLENGE subject, never the request body; the "remember this device"
  window fails closed on an unparsable deadline and preserves the zero-window =
  always-challenge behavior every live org relies on.
- internal/oidc/challenge.go + schema.LoginChallenge (kind login_challenges): a
  server-side, single-use, owner-scoped row replacing v1's beego cookie session —
  distinct from the web3 Challenge (that is a pre-auth nonce; this is bound to a
  known subject). TakeChallenge spends on read, so a captured id never replays.
- login.go: the gate runs for EVERY interactive sign-in; loginGrant is the one
  minting tail (device approval / session / code) both the credential post and
  the second-factor finish reach — so a second factor over a device approval
  lands at approveDevice, not a token. httpx.Ok gains a variadic data2.
- HARDENING: enrollment now stores recovery codes as bcrypt digests
  (factor.HashRecoveryCodes), never the plaintext bearer credential; UseRecovery
  still verifies v1-era plaintext rows so no live 2FA user loses their way back.

Tests: internal/oidc/login_mfa_test.go (12 cases — the enrolled-user-challenged
regression, single-use, subject-binding, recovery hashed + legacy plaintext,
repeat-factor refusal, remember-window round-trip + zero-window, org-required
enrollment, unenrolled-unchanged). go build ./..., go vet ./..., full go test
./... green; main's TotP enrollment tests unchanged.
2026-07-19 20:44:19 -07:00
hanzo-dev 02c05c9fc5 Merge feat/device-flow: RFC 8628 device authorization grant
Re-implemented on main's current architecture (public zip-group + the one token
endpoint + the login endpoint), not merged from feat/token. Confidential clients
authenticate at the device request and the poll (§3.1/§3.4); public device
clients bind by client_id. 15 device tests + full go test ./... green.
2026-07-19 20:23:35 -07:00
hanzo-dev f4ffb08aff Merge feat/device-flow: RFC 8628 device authorization grant
Re-implemented on main's current architecture (public zip-group + the one token
endpoint + the login endpoint), not merged from feat/token. Confidential clients
authenticate at the device request and the poll (§3.1/§3.4); public device
clients bind by client_id. 15 device tests + full go test ./... green.
2026-07-19 20:23:35 -07:00
hanzo-dev d7ff8eff89 feat(oidc): RFC 8628 device authorization grant
Browserless CLI sign-in (`hanzo login` on a GPU box, over ssh, in CI),
re-implemented on main's current architecture: routeDevice on the public group,
the poll dispatched from the one token endpoint beside the existing
introspection/revocation, approval on the existing login endpoint. No revived
publicPaths — reachability is group membership.

- POST /v1/iam/oauth/device mints a device_code + a 40-bit unambiguous user_code
  (RFC 8628 §6.1 alphabet, uniform draw) and returns the verification URIs; the
  pending grant is a Token row (Code=device_code, UserCode=user_code, User empty
  until approved), not process-local state — it survives restart and replicas.
- POST /v1/iam/login {type:"device"} binds the approver onto the row against the
  identity the credential check just proved; approval is org-scoped (a foreign
  tenant is refused, a SuperAdmin crosses deliberately) and the user_code refusal
  is non-differential (unknown/expired/used are indistinguishable — no oracle).
- grant_type=urn:...:device_code polls the token endpoint: authorization_pending
  until approved, then mints exactly once through the shared issueTokens.
- CLIENT AUTHENTICATION (RFC 8628 §3.1 request + §3.4 poll -> RFC 6749 §3.2.1):
  a confidential client (registered secret) authenticates its secret at BOTH
  legs, checked before the pending/mint split so an unauthenticated confidential
  poll never learns the grant state; a public device client is bound by client_id
  alone. Kind guards both ways: an auth code can't redeem at the device grant
  (no PKCE/redirect there) and a device code can't redeem at the auth-code grant
  (no human approved).
- schema.Token gains UserCode (indexed); store.GetTokenByUserCode +
  store.IsSuperAdmin (the reserved-org predicate, below the authz seam);
  discovery advertises device_authorization_endpoint + the device grant.

Tests: internal/oidc device_test.go (15 cases incl. confidential-client auth,
tenant boundary, non-differential refusal, both cross-grant redemption guards);
the pre-existing error-taxonomy test updated (device_code is now implemented, so
its "unsupported" example moves to RFC 7523 jwt-bearer). go build ./... and full
go test ./... green.
2026-07-19 20:23:23 -07:00
hanzo-dev 2caa184e53 feat(oidc): RFC 8628 device authorization grant
Browserless CLI sign-in (`hanzo login` on a GPU box, over ssh, in CI),
re-implemented on main's current architecture: routeDevice on the public group,
the poll dispatched from the one token endpoint beside the existing
introspection/revocation, approval on the existing login endpoint. No revived
publicPaths — reachability is group membership.

- POST /v1/iam/oauth/device mints a device_code + a 40-bit unambiguous user_code
  (RFC 8628 §6.1 alphabet, uniform draw) and returns the verification URIs; the
  pending grant is a Token row (Code=device_code, UserCode=user_code, User empty
  until approved), not process-local state — it survives restart and replicas.
- POST /v1/iam/login {type:"device"} binds the approver onto the row against the
  identity the credential check just proved; approval is org-scoped (a foreign
  tenant is refused, a SuperAdmin crosses deliberately) and the user_code refusal
  is non-differential (unknown/expired/used are indistinguishable — no oracle).
- grant_type=urn:...:device_code polls the token endpoint: authorization_pending
  until approved, then mints exactly once through the shared issueTokens.
- CLIENT AUTHENTICATION (RFC 8628 §3.1 request + §3.4 poll -> RFC 6749 §3.2.1):
  a confidential client (registered secret) authenticates its secret at BOTH
  legs, checked before the pending/mint split so an unauthenticated confidential
  poll never learns the grant state; a public device client is bound by client_id
  alone. Kind guards both ways: an auth code can't redeem at the device grant
  (no PKCE/redirect there) and a device code can't redeem at the auth-code grant
  (no human approved).
- schema.Token gains UserCode (indexed); store.GetTokenByUserCode +
  store.IsSuperAdmin (the reserved-org predicate, below the authz seam);
  discovery advertises device_authorization_endpoint + the device grant.

Tests: internal/oidc device_test.go (15 cases incl. confidential-client auth,
tenant boundary, non-differential refusal, both cross-grant redemption guards);
the pre-existing error-taxonomy test updated (device_code is now implemented, so
its "unsupported" example moves to RFC 7523 jwt-bearer). go build ./... and full
go test ./... green.
2026-07-19 20:23:23 -07:00
hanzo-dev 4bef928c2b Merge feat/wallet: native multi-chain wallet sign-in (CAIP-122)
Rebased onto main and integrated onto the current structural-auth model:
wallet.Route registers GET /v1/iam/web3/nonce + POST /v1/iam/web3/verify on the
public route group (before the Guard), authz.Optional resolves a public route's
optional caller, and schema registers the Challenge + Wallet kinds. The feature
is entirely net-new (no web3 surface existed on main); go build ./... and
go test ./... are green.
2026-07-19 20:02:17 -07:00
hanzo-dev 4eee035b6a Merge feat/wallet: native multi-chain wallet sign-in (CAIP-122)
Rebased onto main and integrated onto the current structural-auth model:
wallet.Route registers GET /v1/iam/web3/nonce + POST /v1/iam/web3/verify on the
public route group (before the Guard), authz.Optional resolves a public route's
optional caller, and schema registers the Challenge + Wallet kinds. The feature
is entirely net-new (no web3 surface existed on main); go build ./... and
go test ./... are green.
2026-07-19 20:02:17 -07:00
hanzo-dev 79c18cb2b0 feat(wallet): native multi-chain wallet sign-in (CAIP-122)
Keyless wallet login for IAM v2 over github.com/luxwallet/connect/go — the SAME
VerifyProof the TypeScript SDK runs, so Go and TS verify identically.

- GET /v1/iam/web3/nonce mints a single-use CAIP-122 challenge; POST
  /v1/iam/web3/verify verifies a signed proof and IS the login. Both are
  anonymous by construction (public allowlist) — they precede any token.
- internal/wallet: HTTP shell (wallet.go) + chain-agnostic core (verify.go, no
  zip.Ctx, unit-testable) + transactional store (store.go). The nonce is BURNED
  before any crypto runs, so a captured proof cannot be redeemed twice; the burn
  and the (chain,address) link close their races with transactions, since orm
  has no conditional UPDATE or UNIQUE constraint.
- schema.Challenge (web3_nonce) binds the signed Domain for phishing defense and
  re-checks Chain at verify; schema.Wallet is the (Chain,Address)->user side
  table (globally unique pair), address stored exactly as the verifier
  canonicalized it (EVM lowercased, case-sensitive chains trimmed only).
- oidc.MintFor/ResolveApp (mint.go): the shared tail of every interactive login
  — tenant isolation, redirect binding, PKCE, code mint — so wallet sign-in
  inherits the same rules as password login instead of restating them.
- authz.Optional resolves a public route's optional caller (fail-closed, nil
  when anonymous or the bearer does not verify); store.GetOrganizationByName;
  drift-compare mappings for web3_nonce and wallet_link.
- deps: luxwallet/connect/go and its luxfi / secp256k1 / base58 / curve25519
  graph; golang.org/x/crypto 0.52.0->0.53.0 and siblings bumped by that graph.
2026-07-19 20:01:47 -07:00
hanzo-dev 1b7fbeafeb feat(wallet): native multi-chain wallet sign-in (CAIP-122)
Keyless wallet login for IAM v2 over github.com/luxwallet/connect/go — the SAME
VerifyProof the TypeScript SDK runs, so Go and TS verify identically.

- GET /v1/iam/web3/nonce mints a single-use CAIP-122 challenge; POST
  /v1/iam/web3/verify verifies a signed proof and IS the login. Both are
  anonymous by construction (public allowlist) — they precede any token.
- internal/wallet: HTTP shell (wallet.go) + chain-agnostic core (verify.go, no
  zip.Ctx, unit-testable) + transactional store (store.go). The nonce is BURNED
  before any crypto runs, so a captured proof cannot be redeemed twice; the burn
  and the (chain,address) link close their races with transactions, since orm
  has no conditional UPDATE or UNIQUE constraint.
- schema.Challenge (web3_nonce) binds the signed Domain for phishing defense and
  re-checks Chain at verify; schema.Wallet is the (Chain,Address)->user side
  table (globally unique pair), address stored exactly as the verifier
  canonicalized it (EVM lowercased, case-sensitive chains trimmed only).
- oidc.MintFor/ResolveApp (mint.go): the shared tail of every interactive login
  — tenant isolation, redirect binding, PKCE, code mint — so wallet sign-in
  inherits the same rules as password login instead of restating them.
- authz.Optional resolves a public route's optional caller (fail-closed, nil
  when anonymous or the bearer does not verify); store.GetOrganizationByName;
  drift-compare mappings for web3_nonce and wallet_link.
- deps: luxwallet/connect/go and its luxfi / secp256k1 / base58 / curve25519
  graph; golang.org/x/crypto 0.52.0->0.53.0 and siblings bumped by that graph.
2026-07-19 20:01:47 -07:00
zeekay 82aeba360f feat(cred): hash all new passwords with argon2id (SOTA), one way
Password VERIFICATION was already scheme-aware (argon2id for v1 rows, bcrypt
for iam2-minted rows), but new/updated passwords were hashed with bcrypt.
Switch the ONE hashing path to argon2id — the state-of-the-art scheme (PHC
winner) — so every credential iam2 mints is the strongest one, one way to
hash everywhere.

- cred.Hash: new argon2id (PHC) hasher, OWASP-aligned params (64 MiB, 2
  passes, p=1); params + per-hash salt ride in the digest so Verify reads
  them back. cred is the single place hashing lives.
- users.Create/Update + bootstrap.upsertUser route through cred.Hash and
  stamp PasswordType=argon2id; signup inherits it via the canonical path.
- Verify stays scheme-aware, so pre-existing bcrypt/argon2id rows keep
  validating — no forced reset. New rows are argon2id.

Tests updated to expect the argon2id stamp; full suite green.
2026-07-17 17:34:33 -07:00
zeekay a555f4ec71 feat(cred): hash all new passwords with argon2id (SOTA), one way
Password VERIFICATION was already scheme-aware (argon2id for v1 rows, bcrypt
for iam2-minted rows), but new/updated passwords were hashed with bcrypt.
Switch the ONE hashing path to argon2id — the state-of-the-art scheme (PHC
winner) — so every credential iam2 mints is the strongest one, one way to
hash everywhere.

- cred.Hash: new argon2id (PHC) hasher, OWASP-aligned params (64 MiB, 2
  passes, p=1); params + per-hash salt ride in the digest so Verify reads
  them back. cred is the single place hashing lives.
- users.Create/Update + bootstrap.upsertUser route through cred.Hash and
  stamp PasswordType=argon2id; signup inherits it via the canonical path.
- Verify stays scheme-aware, so pre-existing bcrypt/argon2id rows keep
  validating — no forced reset. New rows are argon2id.

Tests updated to expect the argon2id stamp; full suite green.

Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:34:33 -07:00
zeekay ea5219face fix(federation): close red-team CRITICAL — reserved-org SuperAdmin mint + SSRF
Red-team found a critical privilege escalation in the v0.15.0 federation
broker: social login could mint a SuperAdmin (or take over a cross-tenant
account). Two root causes, both closed:

F1(A) — Application.Organization was attacker-controlled and unauthorized:
apps Create/Update copied Organization from the body while the authz seam
gated only the top-level Owner. Add authz.CanSetOrg (one policy: super may
set any org; anyone else only their own, never a reserved platform org or
another tenant) and enforce it on the Organization field at app write.

F1(B) — federation link/provision had no reserved-org guard: it minted the
IdP identity into app.Organization with no check, so Organization="admin"
yielded Owner="admin" = SuperAdmin. Add the reserved-org refusal (never
admin/built-in/app) + the login.go-style app-org-legitimacy check to
linkOrProvision/provisionFederatedUser — re-asserted at the mint boundary.

F2 — SSRF: requireSafeURL permitted any https host (private/link-local/
169.254.169.254/ULA) and http-loopback. Now refuses private/loopback/
link-local/metadata addresses AT DIAL TIME (after DNS resolution), so a
tenant-writable IssuerUrl/Custom*Url can't drive an internal fetch.

Red's 3 PoCs are added and now REFUSE (federation refused, no admin-org
user provisioned, linkOrProvision errors); a legitimate platform-app-for-a-
tenant case and the SSRF-refusal case are covered. Full suite green.
2026-07-17 15:01:10 -07:00
zeekay f1f2aecf3d fix(federation): close red-team CRITICAL — reserved-org SuperAdmin mint + SSRF
Red-team found a critical privilege escalation in the v0.15.0 federation
broker: social login could mint a SuperAdmin (or take over a cross-tenant
account). Two root causes, both closed:

F1(A) — Application.Organization was attacker-controlled and unauthorized:
apps Create/Update copied Organization from the body while the authz seam
gated only the top-level Owner. Add authz.CanSetOrg (one policy: super may
set any org; anyone else only their own, never a reserved platform org or
another tenant) and enforce it on the Organization field at app write.

F1(B) — federation link/provision had no reserved-org guard: it minted the
IdP identity into app.Organization with no check, so Organization="admin"
yielded Owner="admin" = SuperAdmin. Add the reserved-org refusal (never
admin/built-in/app) + the login.go-style app-org-legitimacy check to
linkOrProvision/provisionFederatedUser — re-asserted at the mint boundary.

F2 — SSRF: requireSafeURL permitted any https host (private/link-local/
169.254.169.254/ULA) and http-loopback. Now refuses private/loopback/
link-local/metadata addresses AT DIAL TIME (after DNS resolution), so a
tenant-writable IssuerUrl/Custom*Url can't drive an internal fetch.

Red's 3 PoCs are added and now REFUSE (federation refused, no admin-org
user provisioned, linkOrProvision errors); a legitimate platform-app-for-a-
tenant case and the SSRF-refusal case are covered. Full suite green.

Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:01:10 -07:00
z ab5778cb42 ci: use GH_PAT directly for private-dep fetch + ghcr, matching cloud
The || fallback chain resolved to a token that cannot read the private transitive
dep hanzoai/dbx (via hanzoai/orm) — go mod download 404'd on it. Pin GH_PAT (the
org admin:org+write:packages secret hanzoai/cloud uses to fetch the SAME private
cross-repo modules) directly, for both the ghcr login and the GIT_AUTH_TOKEN build
secret. No fallback: the automatic GITHUB_TOKEN only reads its own repo, so it can
never fetch dbx.
2026-07-17 14:48:53 -07:00
z d94d9cfcef ci: use GH_PAT directly for private-dep fetch + ghcr, matching cloud
The || fallback chain resolved to a token that cannot read the private transitive
dep hanzoai/dbx (via hanzoai/orm) — go mod download 404'd on it. Pin GH_PAT (the
org admin:org+write:packages secret hanzoai/cloud uses to fetch the SAME private
cross-repo modules) directly, for both the ghcr login and the GIT_AUTH_TOKEN build
secret. No fallback: the automatic GITHUB_TOKEN only reads its own repo, so it can
never fetch dbx.
2026-07-17 14:48:53 -07:00
z e1a5e72286 ci: run the build on the ARC fleet, not frozen GitHub-hosted
The build used runs-on: ubuntu-latest — GitHub-hosted, which is billing-frozen for
the org, so the run never STARTED and thus never produced an image (the earlier
failures were the frozen queue, not the build). Route to hanzo-build-linux-amd64,
the ARC self-hosted scale set every working hanzo build uses. Combined with the
private-dep + ghcr-write fixes, this build can finally publish ghcr.io/hanzoai/iam2.
2026-07-17 14:42:21 -07:00
z 0e4aee48bc ci: run the build on the ARC fleet, not frozen GitHub-hosted
The build used runs-on: ubuntu-latest — GitHub-hosted, which is billing-frozen for
the org, so the run never STARTED and thus never produced an image (the earlier
failures were the frozen queue, not the build). Route to hanzo-build-linux-amd64,
the ARC self-hosted scale set every working hanzo build uses. Combined with the
private-dep + ghcr-write fixes, this build can finally publish ghcr.io/hanzoai/iam2.
2026-07-17 14:42:21 -07:00
z 914eda6ebb ci: fix the image build — private-dep auth + ghcr write permission
Every iam2 image build has failed, so no image was ever published and iam2 could
not be rolled. Two causes, both fixed by mirroring hanzoai/cloud's proven pattern:

  * go mod download had no credentials for iam2's PRIVATE modules (hanzoai/orm,
    hanzoai/sqlite), so it failed before compiling. The Dockerfile now sets
    GOPRIVATE=github.com/hanzoai/* and mounts a GIT_AUTH_TOKEN secret to rewrite
    github.com to an authenticated fetch; the workflow passes the token as that
    build secret. Without a token it is a no-op, so a public build still works.

  * The automatic GITHUB_TOKEN is denied write to ghcr.io/hanzoai/* — the same
    permission_denied: write_package cloud hit. Add permissions: packages: write
    and prefer GH_PAT for the registry login.

A green build here publishes ghcr.io/hanzoai/iam2:<tag>, the prerequisite for the
iam2 canary deployment.
2026-07-17 14:36:06 -07:00
z e1d764d053 ci: fix the image build — private-dep auth + ghcr write permission
Every iam2 image build has failed, so no image was ever published and iam2 could
not be rolled. Two causes, both fixed by mirroring hanzoai/cloud's proven pattern:

  * go mod download had no credentials for iam2's PRIVATE modules (hanzoai/orm,
    hanzoai/sqlite), so it failed before compiling. The Dockerfile now sets
    GOPRIVATE=github.com/hanzoai/* and mounts a GIT_AUTH_TOKEN secret to rewrite
    github.com to an authenticated fetch; the workflow passes the token as that
    build secret. Without a token it is a no-op, so a public build still works.

  * The automatic GITHUB_TOKEN is denied write to ghcr.io/hanzoai/* — the same
    permission_denied: write_package cloud hit. Add permissions: packages: write
    and prefer GH_PAT for the registry login.

A green build here publishes ghcr.io/hanzoai/iam2:<tag>, the prerequisite for the
iam2 canary deployment.
2026-07-17 14:36:06 -07:00
zeekay 4f2ca2e9f5 fix(seed): mint signing keys for keyless reserved-org certs (JWKS was empty)
The shadow-canary deploy in prod surfaced this: init_data seeds signing
certs (owner=admin, RS256) with NO key material — a signing key cannot ride
the init_data.json ConfigMap (it's a secret). iam2's JWKS filters to certs
that carry key material, so it published {keys:[]} → iam2 could neither sign
tokens nor be trusted by relying parties. The legacy Beego iam avoids this
by generating+persisting a keypair on first boot; iam2 didn't.

Fix: at seed time, for a reserved-org (JWKS-eligible) signing cert with a
recognized algorithm and no key material, generate a keypair (RSA for
RS256/512, ECDSA for ES256/384/512) and PEM-encode the private half — the
same first-boot provisioning the legacy iam does. Gated to reserved-org
certs; SSL, tenant-owned, ML-DSA/unrecognized, and already-keyed certs are
left untouched. Safe every boot: the key is minted in memory and the
new-only upsert persists it exactly once, so a re-seed keeps the existing
row (stable kid + key, no rotation).

Tests: keyless reserved RS256/ES256 certs get parseable keys; SSL + tenant
certs stay keyless; an explicit key is never overwritten.
2026-07-17 01:33:37 -07:00
zeekay 7e2a9268fd fix(seed): mint signing keys for keyless reserved-org certs (JWKS was empty)
The shadow-canary deploy in prod surfaced this: init_data seeds signing
certs (owner=admin, RS256) with NO key material — a signing key cannot ride
the init_data.json ConfigMap (it's a secret). iam2's JWKS filters to certs
that carry key material, so it published {keys:[]} → iam2 could neither sign
tokens nor be trusted by relying parties. The legacy Beego iam avoids this
by generating+persisting a keypair on first boot; iam2 didn't.

Fix: at seed time, for a reserved-org (JWKS-eligible) signing cert with a
recognized algorithm and no key material, generate a keypair (RSA for
RS256/512, ECDSA for ES256/384/512) and PEM-encode the private half — the
same first-boot provisioning the legacy iam does. Gated to reserved-org
certs; SSL, tenant-owned, ML-DSA/unrecognized, and already-keyed certs are
left untouched. Safe every boot: the key is minted in memory and the
new-only upsert persists it exactly once, so a re-seed keeps the existing
row (stable kid + key, no rotation).

Tests: keyless reserved RS256/ES256 certs get parseable keys; SSL + tenant
certs stay keyless; an explicit key is never overwritten.

Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 01:33:37 -07:00
zeekayandhanzo-dev d457834f1e feat(federation): standards-based OIDC/OAuth2 identity-federation broker
iam2 completes Google/GitHub (and the existing provider set) social sign-in as a
standard OIDC/OAuth2 Relying Party — the one remaining login-backend gap before
hanzo.id cuts over from Casdoor. No Casdoor verbs, no tokens-in-query, no legacy
/oauth/* paths (HIP-0111).

- authorize `?provider=<name>` (after client_id + EXACT redirect_uri + PKCE
  validation) stashes the app-leg request in a single-use, expiring,
  browser-bound FederationState and 302s to the IdP with an IdP-leg S256 PKCE
  verifier and (OIDC) a nonce.
- fixed public callback /v1/iam/oauth/callback burns the transaction (expiry +
  browser-binding cookie), exchanges the IdP code, and VERIFIES the response:
  OIDC id_token signature (discovered JWKS, alg pinned to RS/ES), iss, aud, exp,
  nonce; GitHub userinfo + verified primary email.
- links by provider subject, else by VERIFIED email, else provisions a new user
  (never isAdmin, no password); mints iam2's own PKCE/redirect/nonce-bound
  authorization code so the SPA's existing code->token exchange is unchanged.
- new schema.FederationState entity + store helpers + a connector registry that
  passes the EXACT lowercase json field name (dodging orm's LowercaseFirst
  footgun that would break the GitHub connector lookup).

Fail-closed on every path; no IdP tokens persisted; no secrets logged. 15 new
tests drive the real mounted routes against httptest mock IdPs (real discovery/
JWKS/RS256 id_token + GitHub userinfo, no live calls): happy path + code
exchange, link-by-verified-email, relogin-by-subject no-dup, unknown/replayed
state, missing/wrong browser cookie, nonce/signature/alg-none/audience
rejection, unverified-email no-autolink, non-allowlisted redirect refused.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-17 01:27:18 -07:00
zeekayandClaude Opus 4.8 a8409e59c1 feat(federation): standards-based OIDC/OAuth2 identity-federation broker
iam2 completes Google/GitHub (and the existing provider set) social sign-in as a
standard OIDC/OAuth2 Relying Party — the one remaining login-backend gap before
hanzo.id cuts over from Casdoor. No Casdoor verbs, no tokens-in-query, no legacy
/oauth/* paths (HIP-0111).

- authorize `?provider=<name>` (after client_id + EXACT redirect_uri + PKCE
  validation) stashes the app-leg request in a single-use, expiring,
  browser-bound FederationState and 302s to the IdP with an IdP-leg S256 PKCE
  verifier and (OIDC) a nonce.
- fixed public callback /v1/iam/oauth/callback burns the transaction (expiry +
  browser-binding cookie), exchanges the IdP code, and VERIFIES the response:
  OIDC id_token signature (discovered JWKS, alg pinned to RS/ES), iss, aud, exp,
  nonce; GitHub userinfo + verified primary email.
- links by provider subject, else by VERIFIED email, else provisions a new user
  (never isAdmin, no password); mints iam2's own PKCE/redirect/nonce-bound
  authorization code so the SPA's existing code->token exchange is unchanged.
- new schema.FederationState entity + store helpers + a connector registry that
  passes the EXACT lowercase json field name (dodging orm's LowercaseFirst
  footgun that would break the GitHub connector lookup).

Fail-closed on every path; no IdP tokens persisted; no secrets logged. 15 new
tests drive the real mounted routes against httptest mock IdPs (real discovery/
JWKS/RS256 id_token + GitHub userinfo, no live calls): happy path + code
exchange, link-by-verified-email, relogin-by-subject no-dup, unknown/replayed
state, missing/wrong browser cookie, nonce/signature/alg-none/audience
rejection, unverified-email no-autolink, non-allowlisted redirect refused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 01:27:18 -07:00
zeekay a0a47dc6f0 feat(server): expose standalone Handler for wildcard sub-mount (cloud embed)
Add server.NewApp(db) + server.Handler(db) — build the whole IAM v2 surface
as one self-contained zip.App and adapt it to a net/http handler. This is
the drop-in shape hanzoai/cloud uses to swap the legacy Beego IAM catch-all
for iam2: mounted behind the /v1/iam/* (and root /.well-known/*) wildcards,
the specific self-service routes the account fold layers in front still win
by Fiber specificity, so the swap is collision-free — identical topology to
the Beego mount it replaces.
2026-07-16 21:29:34 -07:00
zeekay 67653e73f5 feat(server): expose standalone Handler for wildcard sub-mount (cloud embed)
Add server.NewApp(db) + server.Handler(db) — build the whole IAM v2 surface
as one self-contained zip.App and adapt it to a net/http handler. This is
the drop-in shape hanzoai/cloud uses to swap the legacy Beego IAM catch-all
for iam2: mounted behind the /v1/iam/* (and root /.well-known/*) wildcards,
the specific self-service routes the account fold layers in front still win
by Fiber specificity, so the swap is collision-free — identical topology to
the Beego mount it replaces.

Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:29:34 -07:00
zeekay aa3dc8c44a feat(projects): organization-scoped projects entity — C3 console parity
Add the `projects` entity (v2 kind, org-owned like users/roles) with the
full typed REST CRUD (internal/projects) plus the three Casdoor verbs the
console ScopeSwitcher / Projects page hard-codes through the /org/iam proxy:

  - get-organization-projects  (?organization=, handler-authorized: any org
    member may list its org's projects — the switcher is shown to everyone,
    not only admins — scoped by authz.Scope so no param widens the tenant)
  - add-project / delete-project (typed ops → app.Authorize gates the write
    to an org-admin of that org, the same clause as add-role)

CRUD lives once in internal/projects; the verb aliases reuse it via New —
no CRUD reimplemented in compat. get-organization-projects is registered in
authz.handlerAuthorizedPrefixes because its target rides in ?organization=,
which the Guard cannot pre-authorize generically (the read analogue of
SCIM's path-targeted authorization).

Tests drive the real router: lifecycle (add → member-list → delete), the
write-needs-admin gate (regular user 403 on write, 200 on list), and
cross-tenant scoping (a hanzo user asking ?organization=orgb never sees
orgb's projects).
2026-07-16 21:19:44 -07:00
zeekay 9940de7278 feat(projects): organization-scoped projects entity — C3 console parity
Add the `projects` entity (v2 kind, org-owned like users/roles) with the
full typed REST CRUD (internal/projects) plus the three Casdoor verbs the
console ScopeSwitcher / Projects page hard-codes through the /org/iam proxy:

  - get-organization-projects  (?organization=, handler-authorized: any org
    member may list its org's projects — the switcher is shown to everyone,
    not only admins — scoped by authz.Scope so no param widens the tenant)
  - add-project / delete-project (typed ops → app.Authorize gates the write
    to an org-admin of that org, the same clause as add-role)

CRUD lives once in internal/projects; the verb aliases reuse it via New —
no CRUD reimplemented in compat. get-organization-projects is registered in
authz.handlerAuthorizedPrefixes because its target rides in ?organization=,
which the Guard cannot pre-authorize generically (the read analogue of
SCIM's path-targeted authorization).

Tests drive the real router: lifecycle (add → member-list → delete), the
write-needs-admin gate (regular user 403 on write, 200 on list), and
cross-tenant scoping (a hanzo user asking ?organization=orgb never sees
orgb's projects).

Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:19:44 -07:00
zeekay c1f1a4be18 feat(mfa): TOTP multi-factor enrollment (RFC 6238) — C4 parity
Port the account security page's MFA flow to iam2: initiate → verify →
enable, plus delete-mfa and set-preferred-mfa. Serves the console's
existing /v1/iam/mfa/setup/{initiate,verify,enable} + /v1/iam/delete-mfa +
/v1/iam/set-preferred-mfa contract.

Enrollment is self-service, mounted AFTER the Guard so it acts on the
authenticated caller's own user (authz.From). The handshake is stateless:
initiate mints a TOTP secret + otpauth URL + recovery code the client
holds; verify checks a passcode against the echoed secret; enable commits
it to the user row. Touching another user's MFA requires admin authority
over that org (authz.Can) — the same seam SCIM writes use — because the
general user-write policy correctly refuses a non-admin writing a user row.

Tests drive the real mounted router: full enroll lifecycle (a generated
TOTP code verifies and persists), bad-code rejection, the cross-user admin
gate (regular user 403, org-admin/super 200), and bearer-required.
2026-07-16 21:12:35 -07:00
zeekay 7be61340fd feat(mfa): TOTP multi-factor enrollment (RFC 6238) — C4 parity
Port the account security page's MFA flow to iam2: initiate → verify →
enable, plus delete-mfa and set-preferred-mfa. Serves the console's
existing /v1/iam/mfa/setup/{initiate,verify,enable} + /v1/iam/delete-mfa +
/v1/iam/set-preferred-mfa contract.

Enrollment is self-service, mounted AFTER the Guard so it acts on the
authenticated caller's own user (authz.From). The handshake is stateless:
initiate mints a TOTP secret + otpauth URL + recovery code the client
holds; verify checks a passcode against the echoed secret; enable commits
it to the user row. Touching another user's MFA requires admin authority
over that org (authz.Can) — the same seam SCIM writes use — because the
general user-write policy correctly refuses a non-admin writing a user row.

Tests drive the real mounted router: full enroll lifecycle (a generated
TOTP code verifies and persists), bad-code rejection, the cross-user admin
gate (regular user 403, org-admin/super 200), and bearer-required.

Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:12:35 -07:00
zeekayandhanzo-dev dc140a6ec2 feat(iam2): operator bootstrap upsert (parity C5 — restores IAM CR reconciliation)
Parity audit C5: the K8s operator (operator-core/src/iam_admin.rs) reconciles an IAM
CR's spec.applications[]/users[] by POSTing to /v1/iam/admin/{applications,users}/upsert
— wiring the service-account OAuth apps KMS/signers authenticate with, no human admin.
iam2 didn't serve these, so an embed would lose IAM reconciliation.

New internal/bootstrap: both idempotent upsert endpoints, keyed by the natural key
(apps: admin/name; users: owner/name) — create OR update, so a ~30s reconcile is a
no-op once converged. Auth is the unified SERVICE TOKEN presented as Bearer,
validated constant-time against HANZO_API_KEY / KMS_SERVICE_TOKEN / IAM_SERVICE_TOKEN
(the same pipeline the old iam used); unset → fail closed. Registered on the PUBLIC
group (self-authenticated, not a bearer principal). Response matches operator-core's
parser: {status:"ok", action:"created"|"updated", data:{...}}. A missing clientSecret
preserves the existing one (no rotation storm); user passwords are bcrypt-hashed.

Tests: app create→idempotent-update (secret preserved), user create (password hashed),
service-token required (no/wrong token → 401).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-16 19:42:54 -07:00
zeekayandClaude Opus 4.8 c8913d7039 feat(iam2): operator bootstrap upsert (parity C5 — restores IAM CR reconciliation)
Parity audit C5: the K8s operator (operator-core/src/iam_admin.rs) reconciles an IAM
CR's spec.applications[]/users[] by POSTing to /v1/iam/admin/{applications,users}/upsert
— wiring the service-account OAuth apps KMS/signers authenticate with, no human admin.
iam2 didn't serve these, so an embed would lose IAM reconciliation.

New internal/bootstrap: both idempotent upsert endpoints, keyed by the natural key
(apps: admin/name; users: owner/name) — create OR update, so a ~30s reconcile is a
no-op once converged. Auth is the unified SERVICE TOKEN presented as Bearer,
validated constant-time against HANZO_API_KEY / KMS_SERVICE_TOKEN / IAM_SERVICE_TOKEN
(the same pipeline the old iam used); unset → fail closed. Registered on the PUBLIC
group (self-authenticated, not a bearer principal). Response matches operator-core's
parser: {status:"ok", action:"created"|"updated", data:{...}}. A missing clientSecret
preserves the existing one (no rotation storm); user passwords are bcrypt-hashed.

Tests: app create→idempotent-update (secret preserved), user create (password hashed),
service-token required (no/wrong token → 401).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 19:42:54 -07:00
zeekayandhanzo-dev 1618ef32b4 fix(iam2): Casdoor write-verb aliases (parity C2 — restores console admin mutations)
Parity audit C2: the console admin BFF forwards literal Casdoor verbs, but compat
served only add-organization/add-user/update-user/update-application — so the
console's Users/Roles/Providers/Apps/Tenants admin MUTATIONS all 404'd. Added the
missing verb aliases over the SAME entity CRUD the REST routes use (one path,
wrapped in the casibase {status,data} envelope; each a TYPED zip.Post so the ONE
app.Authorize seam authorizes the decoded target — no CRUD reimplemented):
delete-user, add-/delete-application, add-/update-/delete-provider,
add-/update-/delete-role, update-/delete-organization.

Minimal exports to reuse the entity logic: applications.Delete, roles.New,
providers.Add/Update/Delete. Tests: delete-user lifecycle, add-provider (super-only,
non-super 403 — platform-owned), add-role (org-admin, tenant-owned), update-org.

With C1 (issue-user-token) the console's authenticated + admin surface is now fully
served by iam2.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-16 19:34:57 -07:00
zeekayandClaude Opus 4.8 52fa502259 fix(iam2): Casdoor write-verb aliases (parity C2 — restores console admin mutations)
Parity audit C2: the console admin BFF forwards literal Casdoor verbs, but compat
served only add-organization/add-user/update-user/update-application — so the
console's Users/Roles/Providers/Apps/Tenants admin MUTATIONS all 404'd. Added the
missing verb aliases over the SAME entity CRUD the REST routes use (one path,
wrapped in the casibase {status,data} envelope; each a TYPED zip.Post so the ONE
app.Authorize seam authorizes the decoded target — no CRUD reimplemented):
delete-user, add-/delete-application, add-/update-/delete-provider,
add-/update-/delete-role, update-/delete-organization.

Minimal exports to reuse the entity logic: applications.Delete, roles.New,
providers.Add/Update/Delete. Tests: delete-user lifecycle, add-provider (super-only,
non-super 403 — platform-owned), add-role (org-admin, tenant-owned), update-org.

With C1 (issue-user-token) the console's authenticated + admin surface is now fully
served by iam2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 19:34:57 -07:00
zeekay 7d6e80977c iam2(compat): extend write-path parity to providers, roles, applications (+ compat writes test) 2026-07-16 17:08:27 -07:00
zeekay 29848eea26 iam2(compat): extend write-path parity to providers, roles, applications (+ compat writes test) 2026-07-16 17:08:27 -07:00
zeekayandhanzo-dev 25c64ef9f2 fix(iam2): restore issue-user-token as the compat shim (parity C1 — unblocks the console)
Parity audit found the P0 gap: the console's ENTIRE authenticated surface calls
POST /v1/iam/issue-user-token (identity.ts issueUserToken → adminBearer → the
bearer-proxy behind every /v1/* BFF call + the /admin/iam,/org/iam,/admin/kms,/ai
proxies). v0.7.0 retired that verb for the RFC 8693 token-exchange grant, but the
console has ZERO token-exchange usage — so the swap would take the whole console dark.

Restored as a COMPAT SHIM over the exact same machinery token-exchange uses
(authorizeMinter allow-list + reserved-org gate + SignUserToken + audit): a
confidential, allow-listed client mints a token bound to the ?id=<owner>/<name>
target user (optional ?aud= resource), returning {accessToken, expiresIn}. Same
authority, same red-hardened controls; equivalent to token-exchange minus the
subject_token proof (the console has the user's id, not a token — the reason the
shim exists). RFC 8693 stays the canonical forward path; the console migrates to it
and this shim retires. Tests: mints the target user's verifiable token, off-allowlist
403.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-16 16:17:23 -07:00
zeekayandClaude Opus 4.8 722bc4b7d9 fix(iam2): restore issue-user-token as the compat shim (parity C1 — unblocks the console)
Parity audit found the P0 gap: the console's ENTIRE authenticated surface calls
POST /v1/iam/issue-user-token (identity.ts issueUserToken → adminBearer → the
bearer-proxy behind every /v1/* BFF call + the /admin/iam,/org/iam,/admin/kms,/ai
proxies). v0.7.0 retired that verb for the RFC 8693 token-exchange grant, but the
console has ZERO token-exchange usage — so the swap would take the whole console dark.

Restored as a COMPAT SHIM over the exact same machinery token-exchange uses
(authorizeMinter allow-list + reserved-org gate + SignUserToken + audit): a
confidential, allow-listed client mints a token bound to the ?id=<owner>/<name>
target user (optional ?aud= resource), returning {accessToken, expiresIn}. Same
authority, same red-hardened controls; equivalent to token-exchange minus the
subject_token proof (the console has the user's id, not a token — the reason the
shim exists). RFC 8693 stays the canonical forward path; the console migrates to it
and this shim retires. Tests: mints the target user's verifiable token, off-allowlist
403.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:17:23 -07:00
zeekayandhanzo-dev 50c28a9d25 test(iam2): end-to-end journey suite — the behavioral parity proof
internal/e2e boots the WHOLE mounted router (routes.Route) and drives the real
client flows in sequence, asserting the response contract each depends on — the
proof that the old Casdoor IAM's clients work against iam2, beyond the per-package
unit tests:
- OIDC: discovery (self-consistent + RFC 7662/7009/8414 endpoints advertised) →
  JWKS → PKCE login → authorization_code → userinfo (owner+isAdmin) → introspect
  (active) → revoke (→ inactive).
- Non-interactive grants: password (RFC 6749 §4.3) + RFC 8693 token exchange.
- Admin console Casdoor surface: get-account (security contract), get-organizations
  (OrgSwitcher, SuperAdmin sees all), get-users (owner-scoped, no secret leak).
- SCIM 2.0 provisioning: create → get → delete.

All green. This harness also verifies any parity gap-fixes going forward.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-16 16:05:46 -07:00
zeekayandClaude Opus 4.8 a5ce39280c test(iam2): end-to-end journey suite — the behavioral parity proof
internal/e2e boots the WHOLE mounted router (routes.Route) and drives the real
client flows in sequence, asserting the response contract each depends on — the
proof that the old Casdoor IAM's clients work against iam2, beyond the per-package
unit tests:
- OIDC: discovery (self-consistent + RFC 7662/7009/8414 endpoints advertised) →
  JWKS → PKCE login → authorization_code → userinfo (owner+isAdmin) → introspect
  (active) → revoke (→ inactive).
- Non-interactive grants: password (RFC 6749 §4.3) + RFC 8693 token exchange.
- Admin console Casdoor surface: get-account (security contract), get-organizations
  (OrgSwitcher, SuperAdmin sees all), get-users (owner-scoped, no secret leak).
- SCIM 2.0 provisioning: create → get → delete.

All green. This harness also verifies any parity gap-fixes going forward.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:05:46 -07:00
hanzo-dev 1a41a86d54 refactor(iam2): rename every sub-package Mount(app,db)→Route — one Mount, the public entry
Completes the group refactor: the flat Mount(app *zip.App, db) convention is
gone from internal/*. Every registration func is now Route, mirroring commerce's
Route(zip.Router) idiom:

  - 13 entity CRUD packages + compat + scim: Mount → Route (still *App — the
    typed-op projection into REST/OpenAPI/MCP needs it).
  - internal helpers organizations.mount → route, compat.mountWrites → routeWrites.
  - the route table internal/routes: Mount → Route (server.Mount embeds it).

server.Mount(app, db) stays the ONE Mount — the frozen public embed entry the
host (cloud) calls; its signature is unchanged, only its body now calls
routes.Route. feature.Mount(app, store) is the feature-seam interface method (a
different signature), untouched.

Pure rename — no route, path, behavior, or auth outcome changes. Callers updated
(main.go, server.go, authz/compat/scim tests). gofmt clean, go build ./... green,
go test ./... green. feature/, pkg/model/, internal/featurestore/ untouched.
2026-07-16 14:40:11 -07:00
hanzo-dev 237076a075 refactor(iam2): rename every sub-package Mount(app,db)→Route — one Mount, the public entry
Completes the group refactor: the flat Mount(app *zip.App, db) convention is
gone from internal/*. Every registration func is now Route, mirroring commerce's
Route(zip.Router) idiom:

  - 13 entity CRUD packages + compat + scim: Mount → Route (still *App — the
    typed-op projection into REST/OpenAPI/MCP needs it).
  - internal helpers organizations.mount → route, compat.mountWrites → routeWrites.
  - the route table internal/routes: Mount → Route (server.Mount embeds it).

server.Mount(app, db) stays the ONE Mount — the frozen public embed entry the
host (cloud) calls; its signature is unchanged, only its body now calls
routes.Route. feature.Mount(app, store) is the feature-seam interface method (a
different signature), untouched.

Pure rename — no route, path, behavior, or auth outcome changes. Callers updated
(main.go, server.go, authz/compat/scim tests). gofmt clean, go build ./... green,
go test ./... green. feature/, pkg/model/, internal/featurestore/ untouched.
2026-07-16 14:40:11 -07:00
hanzo-dev d4365770ed refactor(iam2): structural auth via zip groups — delete publicPaths, oidc.Mount→Route(zip.Router)
Auth is now decided by WHICH GROUP a route is registered on, not a hand-
maintained allow-list. routes.Mount wires two phases around one seam:

  - PUBLIC group (app.Group("") + oidc.Route + /healthz) registered BEFORE
    app.Use(authz.Guard): a matched public route terminates fiber's middleware
    walk, so the Guard never runs on it. Membership here IS "public".
  - app.Use(authz.Guard) is the ONE authentication seam; every route after it
    (typed entity CRUD, Casdoor verb aliases, SCIM, and the framework's /mcp +
    /openapi projections) requires a verified bearer.

A public route can no longer be accidentally gated, nor an authed route
accidentally public — the front-door bug the old publicPaths list had to patch
is structurally impossible.

- Delete authz.publicPaths + isPublic entirely; drop the isPublic checks from
  Guard and Authorize (every typed op is authed by construction). Keep Can/
  IsSuper/pathAuthorized (SCIM) intact.
- oidc.Mount(*App) → oidc.Route(zip.Router); MountToken/Login/FrontDoor/
  IntrospectRevoke/IssueToken → unexported route*(zip.Router) helpers on the
  public group. Absolute paths preserved (root + /v1/iam), so every URL is
  byte-identical (discovery, JWKS, AS-metadata, oauth/*, login, front door,
  introspect/revoke, key minters).
- Replace the isPublic unit test with an end-to-end structural boundary proof:
  TestPublicRoutesNeedNoBearer extended to the full front door + AS metadata;
  TestUnauthenticatedWriteIs401 / TestCrossOrgWriteIs403 /
  TestFrameworkSideDoorsAreGated prove authed + /mcp + /openapi stay gated.

server.Mount, feature/, pkg/model/, internal/featurestore/ untouched.
gofmt clean, go build ./... green, go test ./... green.
2026-07-16 14:33:37 -07:00
hanzo-dev 34ef9dd8fb refactor(iam2): structural auth via zip groups — delete publicPaths, oidc.Mount→Route(zip.Router)
Auth is now decided by WHICH GROUP a route is registered on, not a hand-
maintained allow-list. routes.Mount wires two phases around one seam:

  - PUBLIC group (app.Group("") + oidc.Route + /healthz) registered BEFORE
    app.Use(authz.Guard): a matched public route terminates fiber's middleware
    walk, so the Guard never runs on it. Membership here IS "public".
  - app.Use(authz.Guard) is the ONE authentication seam; every route after it
    (typed entity CRUD, Casdoor verb aliases, SCIM, and the framework's /mcp +
    /openapi projections) requires a verified bearer.

A public route can no longer be accidentally gated, nor an authed route
accidentally public — the front-door bug the old publicPaths list had to patch
is structurally impossible.

- Delete authz.publicPaths + isPublic entirely; drop the isPublic checks from
  Guard and Authorize (every typed op is authed by construction). Keep Can/
  IsSuper/pathAuthorized (SCIM) intact.
- oidc.Mount(*App) → oidc.Route(zip.Router); MountToken/Login/FrontDoor/
  IntrospectRevoke/IssueToken → unexported route*(zip.Router) helpers on the
  public group. Absolute paths preserved (root + /v1/iam), so every URL is
  byte-identical (discovery, JWKS, AS-metadata, oauth/*, login, front door,
  introspect/revoke, key minters).
- Replace the isPublic unit test with an end-to-end structural boundary proof:
  TestPublicRoutesNeedNoBearer extended to the full front door + AS metadata;
  TestUnauthenticatedWriteIs401 / TestCrossOrgWriteIs403 /
  TestFrameworkSideDoorsAreGated prove authed + /mcp + /openapi stay gated.

server.Mount, feature/, pkg/model/, internal/featurestore/ untouched.
gofmt clean, go build ./... green, go test ./... green.
2026-07-16 14:33:37 -07:00
hanzo-dev fe78f38921 feat(iam2): seam GetProvider — SP-inbound SAML/OAuth (corporate IdP login)
feature.Store gains GetProvider(owner,name) over the core's store.GetProvider,
and pkg/model aliases schema.Provider. Unblocks hanzoiam/saml SP-initiated login
(Hanzo as Service Provider to an external IdP). Completes the seam: the union of
what SCIM/SAML/LDAP need.
2026-07-16 14:27:45 -07:00
hanzo-dev 3907d269f1 feat(iam2): seam GetProvider — SP-inbound SAML/OAuth (corporate IdP login)
feature.Store gains GetProvider(owner,name) over the core's store.GetProvider,
and pkg/model aliases schema.Provider. Unblocks hanzoiam/saml SP-initiated login
(Hanzo as Service Provider to an external IdP). Completes the seam: the union of
what SCIM/SAML/LDAP need.
2026-07-16 14:27:45 -07:00
hanzo-dev c323a4f4ad feat(iam2): seam password channel — SetPassword/VerifyPassword
feature.Store gains SetPassword (core hashes plaintext once, discards it) and
VerifyPassword (argon2id v1 / bcrypt v2, per the org's password type). Hashing
and verification stay in the ONE place (internal/users) — enterprise modules
never see a digest. Unblocks hanzoiam/ldap bind + hanzoiam/scim password attr.
2026-07-16 14:22:59 -07:00
hanzo-dev 3a26fb4918 feat(iam2): seam password channel — SetPassword/VerifyPassword
feature.Store gains SetPassword (core hashes plaintext once, discards it) and
VerifyPassword (argon2id v1 / bcrypt v2, per the org's password type). Hashing
and verification stay in the ONE place (internal/users) — enterprise modules
never see a digest. Unblocks hanzoiam/ldap bind + hanzoiam/scim password attr.
2026-07-16 14:22:59 -07:00
zeekayandhanzo-dev 37a83dbf99 docs(iam2): MIGRATION §2.1 — the RFC/IETF-standard surface (HIP-0111), all shipped
Document the RFC-standard endpoint surface (OAuth grants incl. token-exchange,
introspection/revocation, AS-metadata, UserInfo=get-account contract, SCIM 2.0),
the deploy env, and the remaining client-migration for cutover.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-16 14:15:50 -07:00
zeekayandClaude Opus 4.8 9c751107e9 docs(iam2): MIGRATION §2.1 — the RFC/IETF-standard surface (HIP-0111), all shipped
Document the RFC-standard endpoint surface (OAuth grants incl. token-exchange,
introspection/revocation, AS-metadata, UserInfo=get-account contract, SCIM 2.0),
the deploy env, and the remaining client-migration for cutover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:15:50 -07:00
zeekayandhanzo-dev 1606cbf946 feat(iam2): UserInfo carries the get-account security contract (isAdmin + type)
OIDC UserInfo (/v1/iam/oauth/userinfo) now emits `isAdmin` (the gateway
admin-guard's SuperAdmin-predicate input, with owner==adminOrg) and `type` (the
console's anonymous-user check), read from the loaded user record — authoritative,
never a token claim, matching the authz Principal. Emitted regardless of scope
(identity, not profile), as an EXPLICIT bool so the admin-guard reads a definite
false rather than inferring it from a missing key.

This makes standard OIDC UserInfo a drop-in for the retired Casdoor get-account
security contract (HIP-0111): a consumer reads sub/owner/organization/email/
email_verified/isAdmin/type off the RFC endpoint, so the gateway admin-guard +
console resolveUser can migrate off get-account to /oauth/userinfo. (The migration
of those clients is the follow-on; UserInfo now carries what they need.)

Tests: admin-guard contract (isAdmin:true + type present at openid-only scope),
non-admin explicit-false, existing scope-gating unaffected.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-16 14:14:19 -07:00
zeekayandClaude Opus 4.8 7a29e5027b feat(iam2): UserInfo carries the get-account security contract (isAdmin + type)
OIDC UserInfo (/v1/iam/oauth/userinfo) now emits `isAdmin` (the gateway
admin-guard's SuperAdmin-predicate input, with owner==adminOrg) and `type` (the
console's anonymous-user check), read from the loaded user record — authoritative,
never a token claim, matching the authz Principal. Emitted regardless of scope
(identity, not profile), as an EXPLICIT bool so the admin-guard reads a definite
false rather than inferring it from a missing key.

This makes standard OIDC UserInfo a drop-in for the retired Casdoor get-account
security contract (HIP-0111): a consumer reads sub/owner/organization/email/
email_verified/isAdmin/type off the RFC endpoint, so the gateway admin-guard +
console resolveUser can migrate off get-account to /oauth/userinfo. (The migration
of those clients is the follow-on; UserInfo now carries what they need.)

Tests: admin-guard contract (isAdmin:true + type present at openid-only scope),
non-admin explicit-false, existing scope-gating unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:14:19 -07:00
zeekayandhanzo-dev c4936cbda8 fix(iam2): close red-team CRITICAL — SCIM writes bypassed the authz seam
Red review of v0.8.0 found the SCIM write path never reached authz.Authorize (the
op-invoke seam where the admin/self policy lives) — SCIM registers RAW handlers and
calls users.API directly, so the only authz was authz.Scope (pins the org, NOT the
admin flag). Proven exploits (all now closed, red's 6 proof tests green):
- a regular org member self-promoted to org-admin in one PUT (isAdmin extension)
- reset an org-admin's password via PATCH → full admin account takeover
- created/deleted users; a machine token (no user row) got full user-write
Plus a HIGH (PUT/PATCH rebuilt from a blank record → stripped MFA enrollment +
resurrected soft-deleted accounts), MEDIUMs (count<=0 → orm Limit(0) → unbounded
dump; client-supplied isAdmin), a LOW (raw err.Error() on 500).

Fixes (one policy, one place):
- authz.Can(ctx, method, entity, owner, name) exposes the SAME predicate the op
  seam applies, for raw handlers. Every SCIM write (POST/PUT/PATCH/DELETE) and the
  list/get reads now gate through it → regular user 403, org-admin/super admitted.
  authz.IsSuper gates the privileged isAdmin field (provision-don't-promote: only a
  super may set it).
- PUT/PATCH now read-modify-write the FULL current row (overlay mapped attrs onto
  cur), preserving MFA/IsDeleted/Type/Groups/Ldap — never rebuilt from blank.
- count<=0 clamps to the default page size (never unbounded).
- active is *bool (RFC 7643 default-true when omitted); generic 500 (no detail).

Tests: red's red_bypass_test.go (6, all green) + regression matrix (org-admin
allowed, isAdmin super-only, PATCH preserves MFA, PUT no-resurrect/no-MFA-strip).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-16 13:16:20 -07:00
zeekayandClaude Opus 4.8 405a59c1d4 fix(iam2): close red-team CRITICAL — SCIM writes bypassed the authz seam
Red review of v0.8.0 found the SCIM write path never reached authz.Authorize (the
op-invoke seam where the admin/self policy lives) — SCIM registers RAW handlers and
calls users.API directly, so the only authz was authz.Scope (pins the org, NOT the
admin flag). Proven exploits (all now closed, red's 6 proof tests green):
- a regular org member self-promoted to org-admin in one PUT (isAdmin extension)
- reset an org-admin's password via PATCH → full admin account takeover
- created/deleted users; a machine token (no user row) got full user-write
Plus a HIGH (PUT/PATCH rebuilt from a blank record → stripped MFA enrollment +
resurrected soft-deleted accounts), MEDIUMs (count<=0 → orm Limit(0) → unbounded
dump; client-supplied isAdmin), a LOW (raw err.Error() on 500).

Fixes (one policy, one place):
- authz.Can(ctx, method, entity, owner, name) exposes the SAME predicate the op
  seam applies, for raw handlers. Every SCIM write (POST/PUT/PATCH/DELETE) and the
  list/get reads now gate through it → regular user 403, org-admin/super admitted.
  authz.IsSuper gates the privileged isAdmin field (provision-don't-promote: only a
  super may set it).
- PUT/PATCH now read-modify-write the FULL current row (overlay mapped attrs onto
  cur), preserving MFA/IsDeleted/Type/Groups/Ldap — never rebuilt from blank.
- count<=0 clamps to the default page size (never unbounded).
- active is *bool (RFC 7643 default-true when omitted); generic 500 (no detail).

Tests: red's red_bypass_test.go (6, all green) + regression matrix (org-admin
allowed, isAdmin super-only, PATCH preserves MFA, PUT no-resurrect/no-MFA-strip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:16:20 -07:00
zeekayandhanzo-dev 1928b5e328 feat(iam2): SCIM 2.0 Users provisioning (RFC 7644/7643) — the standard entity surface
Per HIP-0111 (RFC-only), identity provisioning is SCIM 2.0 — the replacement for the
Casdoor entity verbs (get-users/get-user/add-user/update-user/delete-user). New
internal/scim serves /v1/iam/scim/v2:
- Users: GET list (owner-scoped, filter `userName|emails eq "x"`, startIndex/count
  pagination, ListResponse envelope), POST create (password write-only → hashed via
  the canonical users.API), GET/PUT/PATCH/DELETE on the two-segment item path
  {owner}/{name} (the SCIM id is the natural key "owner/name", appended verbatim by
  clients — no slash-in-id encoding trap). PATCH implements the RFC 7644 §3.5.2 op
  subset (add/replace/remove on active/displayName/password/name.*/emails/phones,
  plus path-less merge) — read-modify-write so a partial change never blanks the row.
- ServiceProviderConfig advertises patch+filter+changePassword; SCIM Error envelope
  with scimType; application/scim+json handled content-type-independently.
- SECURITY: every user projected through schema.User.Mask() (no hash/secret ever
  crosses a response); password is write-only. Authorization: the SCIM subtree is a
  new "path-authorized" category in the Guard — authenticated (bearer required) but
  the handler scopes via authz.Scope on the PATH target (SCIM ids ride the path, not
  the query), so a non-super is pinned to its own org and can't reach another tenant
  by spelling its id.

Tests (real router): create→get→delete lifecycle, ListResponse + owner-scoping
(super sees all, org-admin own-org-only), userName filter, PATCH deactivate, no
secret leak, cross-tenant re-scope→404 (no leak), requires-auth, SPConfig.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-16 12:59:10 -07:00
zeekayandClaude Opus 4.8 9bd5bbb8c3 feat(iam2): SCIM 2.0 Users provisioning (RFC 7644/7643) — the standard entity surface
Per HIP-0111 (RFC-only), identity provisioning is SCIM 2.0 — the replacement for the
Casdoor entity verbs (get-users/get-user/add-user/update-user/delete-user). New
internal/scim serves /v1/iam/scim/v2:
- Users: GET list (owner-scoped, filter `userName|emails eq "x"`, startIndex/count
  pagination, ListResponse envelope), POST create (password write-only → hashed via
  the canonical users.API), GET/PUT/PATCH/DELETE on the two-segment item path
  {owner}/{name} (the SCIM id is the natural key "owner/name", appended verbatim by
  clients — no slash-in-id encoding trap). PATCH implements the RFC 7644 §3.5.2 op
  subset (add/replace/remove on active/displayName/password/name.*/emails/phones,
  plus path-less merge) — read-modify-write so a partial change never blanks the row.
- ServiceProviderConfig advertises patch+filter+changePassword; SCIM Error envelope
  with scimType; application/scim+json handled content-type-independently.
- SECURITY: every user projected through schema.User.Mask() (no hash/secret ever
  crosses a response); password is write-only. Authorization: the SCIM subtree is a
  new "path-authorized" category in the Guard — authenticated (bearer required) but
  the handler scopes via authz.Scope on the PATH target (SCIM ids ride the path, not
  the query), so a non-super is pinned to its own org and can't reach another tenant
  by spelling its id.

Tests (real router): create→get→delete lifecycle, ListResponse + owner-scoping
(super sees all, org-admin own-org-only), userName filter, PATCH deactivate, no
secret leak, cross-tenant re-scope→404 (no leak), requires-auth, SPConfig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:59:10 -07:00
zeekayandhanzo-dev 1f62213175 feat(iam2): RFC 8693 Token Exchange grant; retire the issue-user-token verb
Per HIP-0111 (RFC-standard only), the Casdoor issue-user-token verb is replaced by
the standard OAuth 2.0 Token Exchange grant on /v1/iam/oauth/token:

- grant_type=urn:ietf:params:oauth:grant-type:token-exchange. An allow-listed
  confidential client presents a subject_token identifying the end user and receives
  an access token bound to that user (subject + owner = the user's, so a resource
  server scopes on the validated owner claim), re-scoped via RFC 8707 resource/
  audience to a downstream server, azp = the acting client. RFC 8693 §2.2 response
  (access_token/issued_token_type/token_type/expires_in/scope).
- Reuses the red-hardened controls verbatim: mint allow-list matched by the globally
  unique clientId ONLY (the closed CRITICAL), reserved-org (admin/built-in) subject
  gated behind the separate IAM_ADMIN_MINT_ALLOWED_APPS capability, audit on every
  exchange, and the trusted-cert signing. Stronger than the retired verb: the caller
  must prove the subject with a verifiable subject_token, not just name an ?id=.
- /v1/iam/issue-user-token is GONE (removed from routes + the authz allowlist);
  discovery advertises the token-exchange grant. The `hk-` Cloud API-key primitives
  (mint/revoke-user-keys) stay — a product credential with no RFC, flagged for a
  product call — over the same authorizeMinter seam.

Tests (token_exchange_test): mints for a subject + verifies claims/aud, off-allowlist
403, the name-collision priv-esc regression guard (moved from the red-team file),
reserved-org needs the admin capability (and admits with it), invalid subject_token →
invalid_grant, public client 401, audit emitted.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-16 12:23:52 -07:00
zeekayandClaude Opus 4.8 e24054922e feat(iam2): RFC 8693 Token Exchange grant; retire the issue-user-token verb
Per HIP-0111 (RFC-standard only), the Casdoor issue-user-token verb is replaced by
the standard OAuth 2.0 Token Exchange grant on /v1/iam/oauth/token:

- grant_type=urn:ietf:params:oauth:grant-type:token-exchange. An allow-listed
  confidential client presents a subject_token identifying the end user and receives
  an access token bound to that user (subject + owner = the user's, so a resource
  server scopes on the validated owner claim), re-scoped via RFC 8707 resource/
  audience to a downstream server, azp = the acting client. RFC 8693 §2.2 response
  (access_token/issued_token_type/token_type/expires_in/scope).
- Reuses the red-hardened controls verbatim: mint allow-list matched by the globally
  unique clientId ONLY (the closed CRITICAL), reserved-org (admin/built-in) subject
  gated behind the separate IAM_ADMIN_MINT_ALLOWED_APPS capability, audit on every
  exchange, and the trusted-cert signing. Stronger than the retired verb: the caller
  must prove the subject with a verifiable subject_token, not just name an ?id=.
- /v1/iam/issue-user-token is GONE (removed from routes + the authz allowlist);
  discovery advertises the token-exchange grant. The `hk-` Cloud API-key primitives
  (mint/revoke-user-keys) stay — a product credential with no RFC, flagged for a
  product call — over the same authorizeMinter seam.

Tests (token_exchange_test): mints for a subject + verifies claims/aud, off-allowlist
403, the name-collision priv-esc regression guard (moved from the red-team file),
reserved-org needs the admin capability (and admits with it), invalid subject_token →
invalid_grant, public client 401, audit emitted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:23:52 -07:00
zeekayandhanzo-dev e81371df4d feat(iam2): RFC 7662 introspection + RFC 7009 revocation + RFC 8414 AS metadata
Standard token-management endpoints on the OAuth surface, per HIP-0111 (RFC-only):
- POST /v1/iam/oauth/introspect (RFC 7662): client-authenticated (confidential,
  constant-time). Active iff the grant row still exists (revocation-aware, the same
  liveness check userinfo makes) AND the JWT verifies; returns the standard claims
  (active/scope/client_id/sub/aud/exp/iat/nbf/iss/jti + owner/organization). An
  inactive/absent token returns {active:false} only.
- POST /v1/iam/oauth/revoke (RFC 7009): a confidential client revokes a token issued
  to IT — an access token deletes that grant row, a refresh token revokes the whole
  rotation family. Unknown/other-client tokens are a silent 200 (no oracle, §2.2).
- GET /.well-known/oauth-authorization-server (RFC 8414), root + v1: the discovery
  document at the OAuth well-known path (superset). discovery now advertises
  introspection_endpoint + revocation_endpoint.
All three added to authz's public allowlist (they self-authenticate the client, not
a bearer). Tests: active→claims, revoked→inactive + bearer dies at userinfo, unknown
token→200, confidential-auth required.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-16 11:24:16 -07:00
zeekayandClaude Opus 4.8 b46c578445 feat(iam2): RFC 7662 introspection + RFC 7009 revocation + RFC 8414 AS metadata
Standard token-management endpoints on the OAuth surface, per HIP-0111 (RFC-only):
- POST /v1/iam/oauth/introspect (RFC 7662): client-authenticated (confidential,
  constant-time). Active iff the grant row still exists (revocation-aware, the same
  liveness check userinfo makes) AND the JWT verifies; returns the standard claims
  (active/scope/client_id/sub/aud/exp/iat/nbf/iss/jti + owner/organization). An
  inactive/absent token returns {active:false} only.
- POST /v1/iam/oauth/revoke (RFC 7009): a confidential client revokes a token issued
  to IT — an access token deletes that grant row, a refresh token revokes the whole
  rotation family. Unknown/other-client tokens are a silent 200 (no oracle, §2.2).
- GET /.well-known/oauth-authorization-server (RFC 8414), root + v1: the discovery
  document at the OAuth well-known path (superset). discovery now advertises
  introspection_endpoint + revocation_endpoint.
All three added to authz's public allowlist (they self-authenticate the client, not
a bearer). Tests: active→claims, revoked→inactive + bearer dies at userinfo, unknown
token→200, confidential-auth required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:24:16 -07:00
z 218fd3d81c fix(iam2): feature test nopStore implements GetCert (unbreak bfdc1fb) 2026-07-16 11:09:10 -07:00
z d067b74969 fix(iam2): feature test nopStore implements GetCert (unbreak 52e1c77) 2026-07-16 11:09:10 -07:00
z bfdc1fbc00 feat(iam2): add GetCert to the feature.Store seam (SAML metadata signing / LDAP) 2026-07-16 10:58:24 -07:00
z 52e1c77eb4 feat(iam2): add GetCert to the feature.Store seam (SAML metadata signing / LDAP) 2026-07-16 10:58:24 -07:00
z ba38443648 feat(iam2): the feature seam — enterprise modules plug in via Store, core stays clean
The plug point for the hanzoiam/* enterprise modules (SCIM/SAML/LDAP), so the
proprietary clean-room core never carries Casdoor's Apache-2.0 code:
- pkg/model: PUBLIC identity DTOs (User/Application/Organization) as ALIASES of
  the internal schema — a module shares ONE type with the core, no mapping, and
  never imports internal/.
- feature: the Store interface (the union of object.* calls the copied Casdoor
  code makes) + Feature{Name,Mount} + Register/MountAll. Dependency flows one way
  (module → feature); the core never imports a module.
- internal/featurestore: Store impl over the orm store, so a module reads/writes
  the SAME identity data as the core (one store, no second copy).
- server.Mount now feature.MountAll(app, featurestore.New(db)) — no-op until a
  host registers a module, fail-fast if a registered one can't mount.

Test: a registered feature mounts + is listed; the store is injected. Full suite green.
2026-07-16 10:57:01 -07:00
z 82be9ade58 feat(iam2): the feature seam — enterprise modules plug in via Store, core stays clean
The plug point for the hanzoiam/* enterprise modules (SCIM/SAML/LDAP), so the
proprietary clean-room core never carries Casdoor's Apache-2.0 code:
- pkg/model: PUBLIC identity DTOs (User/Application/Organization) as ALIASES of
  the internal schema — a module shares ONE type with the core, no mapping, and
  never imports internal/.
- feature: the Store interface (the union of object.* calls the copied Casdoor
  code makes) + Feature{Name,Mount} + Register/MountAll. Dependency flows one way
  (module → feature); the core never imports a module.
- internal/featurestore: Store impl over the orm store, so a module reads/writes
  the SAME identity data as the core (one store, no second copy).
- server.Mount now feature.MountAll(app, featurestore.New(db)) — no-op until a
  host registers a module, fail-fast if a registered one can't mount.

Test: a registered feature mounts + is listed; the store is injected. Full suite green.
2026-07-16 10:57:01 -07:00
hanzo-dev f59ae5666a feat(iam2): close the front-door API gap — signin/whoami/onboard/update-preferences/linked-accounts + 4 casdoor write-aliases
The 5 session/identity front-door routes + 4 Casdoor write verbs the console/
gateway call but iam2 didn't serve. Composed from existing pieces, one authz seam,
no logic duplicated.

Front door (oidc.MountFrontDoor, public + self-resolving via callerOf):
- POST /v1/iam/signin — the code→session exchange: redeem+burn the authorization
  code (store), sessions.Set, return the get-account envelope (shared helper
  accountEnvelopeFor). The console's post<Account>('iam/signin',{code,state}).
- GET  /v1/iam/whoami — lightweight caller identity (owner/name/id/isAdmin).
- POST /v1/iam/onboard — first-run org: organizations.Create + in-place user
  org-move to admin; returns the console's {org}/{error} contract.
- POST /v1/iam/update-preferences — self, shallow-merge into
  Properties["hanzo.preferences"] (v1 me_preferences contract).
- GET  /v1/iam/linked-accounts — the caller's non-empty connector columns.

Casdoor write-aliases (compat/writes.go, typed ops → the ONE Authorize seam):
- add-organization → organizations.Create, add-user/update-user → users
  Create/Update, update-application → applications.Update (now exported). Casibase
  {status,data:<masked entity>} envelope; authz identical to the REST twins.

authz: the whole front-door surface (incl. the already-registered-but-gated
get-account/signup/send-verification-code) is added to publicPaths — each handler
resolves+self-scopes the caller, so they are reachable with a session cookie yet
never act on anyone else. Without this they 401'd at the Guard through routes.Mount.

Tests: oidc/frontdoor_e2e_test.go (signin→session→get-account, replay refused,
whoami, preferences round-trip, onboard create+move, linked-accounts) and
compat/writes_test.go (write-alias authz super/org-admin/cross-tenant + no-leak,
front-door public-through-Guard). gofmt clean, go build + go test ./... green.
2026-07-16 10:51:04 -07:00
hanzo-dev 248d72a5cb feat(iam2): close the front-door API gap — signin/whoami/onboard/update-preferences/linked-accounts + 4 casdoor write-aliases
The 5 session/identity front-door routes + 4 Casdoor write verbs the console/
gateway call but iam2 didn't serve. Composed from existing pieces, one authz seam,
no logic duplicated.

Front door (oidc.MountFrontDoor, public + self-resolving via callerOf):
- POST /v1/iam/signin — the code→session exchange: redeem+burn the authorization
  code (store), sessions.Set, return the get-account envelope (shared helper
  accountEnvelopeFor). The console's post<Account>('iam/signin',{code,state}).
- GET  /v1/iam/whoami — lightweight caller identity (owner/name/id/isAdmin).
- POST /v1/iam/onboard — first-run org: organizations.Create + in-place user
  org-move to admin; returns the console's {org}/{error} contract.
- POST /v1/iam/update-preferences — self, shallow-merge into
  Properties["hanzo.preferences"] (v1 me_preferences contract).
- GET  /v1/iam/linked-accounts — the caller's non-empty connector columns.

Casdoor write-aliases (compat/writes.go, typed ops → the ONE Authorize seam):
- add-organization → organizations.Create, add-user/update-user → users
  Create/Update, update-application → applications.Update (now exported). Casibase
  {status,data:<masked entity>} envelope; authz identical to the REST twins.

authz: the whole front-door surface (incl. the already-registered-but-gated
get-account/signup/send-verification-code) is added to publicPaths — each handler
resolves+self-scopes the caller, so they are reachable with a session cookie yet
never act on anyone else. Without this they 401'd at the Guard through routes.Mount.

Tests: oidc/frontdoor_e2e_test.go (signin→session→get-account, replay refused,
whoami, preferences round-trip, onboard create+move, linked-accounts) and
compat/writes_test.go (write-alias authz super/org-admin/cross-tenant + no-leak,
front-door public-through-Guard). gofmt clean, go build + go test ./... green.
2026-07-16 10:51:04 -07:00
zeekayandhanzo-dev 4105e1adad feat(iam2): grant_type=password + IAM_ISSUER pin; ONE token endpoint (no access_token alias)
- Password grant (RFC 6749 §4.3) on the standard /v1/iam/oauth/token — the durable
  first-party console session. Confidential clients only (a public client + password
  grant is a phishing footgun), app must have password login enabled, credentials
  verified through the SAME algorithm-aware per-row path the login form uses
  (argon2id every live v1 row, bcrypt new); one opaque invalid_grant for both
  unknown-user and bad-password (no enumeration oracle); mints via the ONE shared
  issueTokens path (access + id_token on openid + rotating refresh on offline_access).
- IAM_ISSUER pin: tokenIssuer honors the IAM_ISSUER env (e.g. https://hanzo.id) so a
  deployment serving both hanzo.id and iam.hanzo.ai emits ONE stable `iss` the embedded
  KMS + every resource server validate against — also closes the red INFO finding that
  X-Forwarded-Host could steer `iss`. Unset → host-relative (dev).
- ONE token endpoint: /v1/iam/oauth/token (the RFC / discovery token_endpoint). The
  Casdoor `access_token` alias is NOT served — no backwards-compat duplicate spelling;
  the clients are fixed to the standard path (console session.ts + gateway admin-guard
  + waitlist-guard), not the backend shimmed. discovery advertises `password`.

Tests: password grant mints a verifiable user token (offline_access→refresh,
openid→id_token), wrong/unknown → opaque invalid_grant, public client → 401.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-16 09:05:11 -07:00
zeekayandClaude Opus 4.8 9a8335a486 feat(iam2): grant_type=password + IAM_ISSUER pin; ONE token endpoint (no access_token alias)
- Password grant (RFC 6749 §4.3) on the standard /v1/iam/oauth/token — the durable
  first-party console session. Confidential clients only (a public client + password
  grant is a phishing footgun), app must have password login enabled, credentials
  verified through the SAME algorithm-aware per-row path the login form uses
  (argon2id every live v1 row, bcrypt new); one opaque invalid_grant for both
  unknown-user and bad-password (no enumeration oracle); mints via the ONE shared
  issueTokens path (access + id_token on openid + rotating refresh on offline_access).
- IAM_ISSUER pin: tokenIssuer honors the IAM_ISSUER env (e.g. https://hanzo.id) so a
  deployment serving both hanzo.id and iam.hanzo.ai emits ONE stable `iss` the embedded
  KMS + every resource server validate against — also closes the red INFO finding that
  X-Forwarded-Host could steer `iss`. Unset → host-relative (dev).
- ONE token endpoint: /v1/iam/oauth/token (the RFC / discovery token_endpoint). The
  Casdoor `access_token` alias is NOT served — no backwards-compat duplicate spelling;
  the clients are fixed to the standard path (console session.ts + gateway admin-guard
  + waitlist-guard), not the backend shimmed. discovery advertises `password`.

Tests: password grant mints a verifiable user token (offline_access→refresh,
openid→id_token), wrong/unknown → opaque invalid_grant, public client → 401.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:05:11 -07:00
zeekayandhanzo-dev 2e7838c405 fix(iam2): close red-team CRITICAL in the mint primitives — allow-list by clientId only
Red review found a proven privilege escalation: mintAllowed matched the app's
per-owner-unique Name as well as its global clientId, so a tenant org-admin could
register an app named `hanzo-console` in their OWN org (chosen secret + the platform
cert name read from the public JWKS) and mint a fully-valid owner="admin" SuperAdmin
token — collapsing the entire tenant boundary. Fixes (iam2 is pre-prod → fixes forward):

- CRITICAL: mintAllowed matches the GLOBALLY-unique clientId ONLY (dropped the
  app.Name match). The red-team PoC is kept as a permanent regression guard, flipped
  to assert the collision is now refused (403, no token).
- MEDIUM (defense-in-depth): a RESERVED-org (admin/built-in) target now requires a
  SEPARATE capability (IAM_ADMIN_MINT_ALLOWED_APPS), so even a valid general minter
  can't reach a SuperAdmin identity — a leaked general-minter secret is contained. The
  console, which legitimately drives admin.hanzo.ai, holds both capabilities (proven by
  a test: unset admin list → 403, set → 200).
- LOW: emit a best-effort AuditLog on every issue/mint/revoke (who minted for whom) —
  the escalation was previously invisible.
- LOW: issue-user-token is POST-only now (was GET+POST; a mint/bearer must never ride
  a cacheable GET where client_secret could reach logs).

Verified-safe by red (unchanged): the read-modify-write preserves PasswordHash +
isAdmin; forbidden/deleted targets refused pre-mint; constant-time secret compare;
public-client rejection; GetSigningCert's reserved-owner kid restriction.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-15 23:10:47 -07:00
zeekayandClaude Opus 4.8 a364a60d0c fix(iam2): close red-team CRITICAL in the mint primitives — allow-list by clientId only
Red review found a proven privilege escalation: mintAllowed matched the app's
per-owner-unique Name as well as its global clientId, so a tenant org-admin could
register an app named `hanzo-console` in their OWN org (chosen secret + the platform
cert name read from the public JWKS) and mint a fully-valid owner="admin" SuperAdmin
token — collapsing the entire tenant boundary. Fixes (iam2 is pre-prod → fixes forward):

- CRITICAL: mintAllowed matches the GLOBALLY-unique clientId ONLY (dropped the
  app.Name match). The red-team PoC is kept as a permanent regression guard, flipped
  to assert the collision is now refused (403, no token).
- MEDIUM (defense-in-depth): a RESERVED-org (admin/built-in) target now requires a
  SEPARATE capability (IAM_ADMIN_MINT_ALLOWED_APPS), so even a valid general minter
  can't reach a SuperAdmin identity — a leaked general-minter secret is contained. The
  console, which legitimately drives admin.hanzo.ai, holds both capabilities (proven by
  a test: unset admin list → 403, set → 200).
- LOW: emit a best-effort AuditLog on every issue/mint/revoke (who minted for whom) —
  the escalation was previously invisible.
- LOW: issue-user-token is POST-only now (was GET+POST; a mint/bearer must never ride
  a cacheable GET where client_secret could reach logs).

Verified-safe by red (unchanged): the read-modify-write preserves PasswordHash +
isAdmin; forbidden/deleted targets refused pre-mint; constant-time secret compare;
public-client rejection; GetSigningCert's reserved-owner kid restriction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:10:47 -07:00
zandhanzo-dev da5ead75f5 docs+comments: retire stale 'lands later' residual — §4 front-door is complete
The front-door + session layer are wired, so the comments that said the
credential/session paths 'land with a later increment' were stale (AI-residual).
Rewrote frontdoor.go + getaccount.go headers to state what IS, and closed §4 in
MIGRATION.md with the durable-session mechanism. No code change.
2026-07-15 22:57:50 -07:00
zandhanzo-dev 50d4f7028d docs+comments: retire stale 'lands later' residual — §4 front-door is complete
The front-door + session layer are wired, so the comments that said the
credential/session paths 'land with a later increment' were stale (AI-residual).
Rewrote frontdoor.go + getaccount.go headers to state what IS, and closed §4 in
MIGRATION.md with the durable-session mechanism. No code change.
2026-07-15 22:57:50 -07:00
zandhanzo-dev edcacd7ad9 oidc/sessions: wire the portal session layer — §4 front-door residual CLOSED
login (type=login) now issues a signed session cookie; get-account resolves the
caller by cookie FIRST (the portal + gateway-admin-guard path) then bearer (the
API path) — two credentials, one identity via callerOf. Revocable: the cookie's
sid is registered in the Session row and checked on every resolve.

- sessions.Set/Resolve over the cookie primitives; key derived from the platform
  signing cert (store.PlatformSigningCert) — no new secret to provision.
- registerSID/sidActive mirror the Sessions.Create persist path (one way to
  write a session).

Fix caught by the e2e: Set passed ttl=0 to Issue, expiring the payload on mint;
now bounds both the signed expiry and cookie MaxAge by one sessionTTL (14d).

Tests: login→cookie→get-account resolves alice (redacted, no bearer); a FORGED
cookie stays anonymous. Full suite green (authz/compat/cred/oidc/schema/seed/
sessions). §4 residual (get-account+signup+send-verification-code+session) done —
iam2 is Phase-4 shadow-embed ready.
2026-07-15 22:57:50 -07:00
zandhanzo-dev d21aecd8ec oidc/sessions: wire the portal session layer — §4 front-door residual CLOSED
login (type=login) now issues a signed session cookie; get-account resolves the
caller by cookie FIRST (the portal + gateway-admin-guard path) then bearer (the
API path) — two credentials, one identity via callerOf. Revocable: the cookie's
sid is registered in the Session row and checked on every resolve.

- sessions.Set/Resolve over the cookie primitives; key derived from the platform
  signing cert (store.PlatformSigningCert) — no new secret to provision.
- registerSID/sidActive mirror the Sessions.Create persist path (one way to
  write a session).

Fix caught by the e2e: Set passed ttl=0 to Issue, expiring the payload on mint;
now bounds both the signed expiry and cookie MaxAge by one sessionTTL (14d).

Tests: login→cookie→get-account resolves alice (redacted, no bearer); a FORGED
cookie stays anonymous. Full suite green (authz/compat/cred/oidc/schema/seed/
sessions). §4 residual (get-account+signup+send-verification-code+session) done —
iam2 is Phase-4 shadow-embed ready.
2026-07-15 22:57:50 -07:00
zeekayandhanzo-dev b184e7be65 feat(iam2): mint-user-keys + revoke-user-keys — complete the confidential-primitives family
Extends issue-user-token with the console API-keys page's two primitives, over the
SAME confidential-client seam (one authorizeMinter: client_secret_basic/_post +
constant-time verify + the fail-closed IAM_KEY_MINT_ALLOWED_APPS allow-list) and
the same ?id=<owner>/<name> target resolution — so all three primitives share one
auth path, one target path, one error envelope (no divergence).

- POST /v1/iam/mint-user-keys → {status:ok, data:{accessKey}}: (re)generates the
  target user's durable `hk-` Cloud API key (schema.User.AccessKey), persisted via a
  read-modify-write that preserves every other field; the console's getUserKey reads
  it back from get-user.
- POST /v1/iam/revoke-user-keys → {status:ok, data:{affected:true}}: clears the key
  (AccessKey + AccessSecret + hash).
- Both public-path (self-authenticate the client, not a bearer) + fail-closed.

Tests: mint generates a persisted hk- key, revoke clears it, off-allowlist 403.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-15 22:50:24 -07:00
zeekayandClaude Opus 4.8 437e4916a9 feat(iam2): mint-user-keys + revoke-user-keys — complete the confidential-primitives family
Extends issue-user-token with the console API-keys page's two primitives, over the
SAME confidential-client seam (one authorizeMinter: client_secret_basic/_post +
constant-time verify + the fail-closed IAM_KEY_MINT_ALLOWED_APPS allow-list) and
the same ?id=<owner>/<name> target resolution — so all three primitives share one
auth path, one target path, one error envelope (no divergence).

- POST /v1/iam/mint-user-keys → {status:ok, data:{accessKey}}: (re)generates the
  target user's durable `hk-` Cloud API key (schema.User.AccessKey), persisted via a
  read-modify-write that preserves every other field; the console's getUserKey reads
  it back from get-user.
- POST /v1/iam/revoke-user-keys → {status:ok, data:{affected:true}}: clears the key
  (AccessKey + AccessSecret + hash).
- Both public-path (self-authenticate the client, not a bearer) + fail-closed.

Tests: mint generates a persisted hk- key, revoke clears it, off-allowlist 403.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:50:24 -07:00
zeekayandhanzo-dev e10cfa7779 feat(iam2): issue-user-token — confidential-client on-behalf-of-user mint (THE console blocker)
Every console admin call (IAM + KMS proxies) and the keyless-AI proxy mint their
upstream bearer at POST /v1/iam/issue-user-token; absent, /admin/* and /ai 502
before any verb. Implemented to the contract verified against the authoritative
consumer (console src/lib/server/identity.ts):

- Confidential-client auth (client_secret_basic or _post, constant-time) + a
  capability allow-list (IAM_KEY_MINT_ALLOWED_APPS, FAIL-CLOSED — an unset list
  permits nothing; this hands out a user's full authority).
- Acts on-behalf-of the ?id=<owner>/<name> target user: the minted access token's
  subject AND owner claim are the TARGET USER's (not the client's), so a resource
  server that scopes on the validated owner claim (cloud SanitizeIdentity) scopes
  to the user's tenant. azp records the minting client. Signed under the client's
  trusted signing cert + canonical issuer, so the same JWKS verifies it — the token
  is indistinguishable from one the user obtained directly.
- Audience (RFC 8707): ?aud= resource wins (the admin path pins <brand>-cloud so a
  reserved-admin operator's token is accepted); default = the target user's own app.
- Envelope {status:"ok", data:{accessToken, expiresIn}} — the exact camelCase shape
  identity.ts consumes. Persists the token by hash (revocable, userinfo-resolvable).
- Not Bearer-gated (authenticates the CLIENT, not an end-user) → listed in authz's
  public allowlist; the handler does its own, tighter auth.

New jwt.Signer.SignUserToken (decoupled from schema — the handler passes the values
it authorized). Tests (9): mints the target user's authority + verifies under JWKS,
aud override, default aud, wrong secret 401, off-allowlist 403, empty-allowlist
fail-closed, no-auth 401, unknown user error, forbidden user 403.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-15 22:44:51 -07:00
zeekayandClaude Opus 4.8 80e313229c feat(iam2): issue-user-token — confidential-client on-behalf-of-user mint (THE console blocker)
Every console admin call (IAM + KMS proxies) and the keyless-AI proxy mint their
upstream bearer at POST /v1/iam/issue-user-token; absent, /admin/* and /ai 502
before any verb. Implemented to the contract verified against the authoritative
consumer (console src/lib/server/identity.ts):

- Confidential-client auth (client_secret_basic or _post, constant-time) + a
  capability allow-list (IAM_KEY_MINT_ALLOWED_APPS, FAIL-CLOSED — an unset list
  permits nothing; this hands out a user's full authority).
- Acts on-behalf-of the ?id=<owner>/<name> target user: the minted access token's
  subject AND owner claim are the TARGET USER's (not the client's), so a resource
  server that scopes on the validated owner claim (cloud SanitizeIdentity) scopes
  to the user's tenant. azp records the minting client. Signed under the client's
  trusted signing cert + canonical issuer, so the same JWKS verifies it — the token
  is indistinguishable from one the user obtained directly.
- Audience (RFC 8707): ?aud= resource wins (the admin path pins <brand>-cloud so a
  reserved-admin operator's token is accepted); default = the target user's own app.
- Envelope {status:"ok", data:{accessToken, expiresIn}} — the exact camelCase shape
  identity.ts consumes. Persists the token by hash (revocable, userinfo-resolvable).
- Not Bearer-gated (authenticates the CLIENT, not an end-user) → listed in authz's
  public allowlist; the handler does its own, tighter auth.

New jwt.Signer.SignUserToken (decoupled from schema — the handler passes the values
it authorized). Tests (9): mints the target user's authority + verifies under JWKS,
aud override, default aud, wrong secret 401, off-allowlist 403, empty-allowlist
fail-closed, no-auth 401, unknown user error, forbidden user 403.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:44:51 -07:00
hanzo-dev 1754f39e78 oidc: implement /v1/iam/signup + /v1/iam/send-verification-code — close §4 front-door residual
The last two native front-door endpoints HIP-0111 §4 gates cutover on. Both
return the casibase {status,msg,data} envelope and mount in MountFrontDoor next
to get-account.

signup (POST /v1/iam/signup, JSON): mirrors v1 controllers/account.go Signup —
resolve app (clientId or name) → enforce EnableSignUp + EnablePassword + tenant
isolation → username policy (object/check.go CheckUserSignup) → uniqueness →
org PasswordOptions complexity (object/check_password_complexity.go) → create.
Creation goes through the ONE canonical path (users.New(db).Create): bcrypt-hash
once, PasswordType=bcrypt (what internal/cred verifies for new rows), return the
row REDACTED. No plaintext ever stored — proven by test reading the row back.

send-verification-code (POST /v1/iam/send-verification-code, multipart/form-data
per §4, read via fiber FormValue): validates dest+type+applicationId
(form.VerificationForm.CheckParameter), mints an unbiased crypto/rand 6-digit
OTP, persists it as the new `verifications` entity, and reports ok honestly.
Email/SMS delivery is hanzoai/notify's concern (not wired into iam2 yet) — a
documented seam, never a faked "sent". CheckVerificationCode completes the
constant-time, expiry-gated validation surface.

New entity: schema.VerificationRecord (kind `verifications`) — v1's
`verification` table, the 14th identity kind; store.AddVerificationRecord +
GetLatestVerificationRecord.

Deliberate seams vs v1 (missing iam2 deps, not shortcuts): signup lands the user
in the app's existing org (v1's TenantOrgForSignup founder-org mint needs an
org-create helper + Org.Parent, unmodeled); no captcha verification (no captcha
provider modeled); phone E.164 normalization not ported.

gofmt clean, go build/vet green, go test ./... green (incl -race on the new
handlers). MIGRATION.md §4/§6 updated to reality.
2026-07-15 22:35:53 -07:00
hanzo-dev 02bdcddcf5 oidc: implement /v1/iam/signup + /v1/iam/send-verification-code — close §4 front-door residual
The last two native front-door endpoints HIP-0111 §4 gates cutover on. Both
return the casibase {status,msg,data} envelope and mount in MountFrontDoor next
to get-account.

signup (POST /v1/iam/signup, JSON): mirrors v1 controllers/account.go Signup —
resolve app (clientId or name) → enforce EnableSignUp + EnablePassword + tenant
isolation → username policy (object/check.go CheckUserSignup) → uniqueness →
org PasswordOptions complexity (object/check_password_complexity.go) → create.
Creation goes through the ONE canonical path (users.New(db).Create): bcrypt-hash
once, PasswordType=bcrypt (what internal/cred verifies for new rows), return the
row REDACTED. No plaintext ever stored — proven by test reading the row back.

send-verification-code (POST /v1/iam/send-verification-code, multipart/form-data
per §4, read via fiber FormValue): validates dest+type+applicationId
(form.VerificationForm.CheckParameter), mints an unbiased crypto/rand 6-digit
OTP, persists it as the new `verifications` entity, and reports ok honestly.
Email/SMS delivery is hanzoai/notify's concern (not wired into iam2 yet) — a
documented seam, never a faked "sent". CheckVerificationCode completes the
constant-time, expiry-gated validation surface.

New entity: schema.VerificationRecord (kind `verifications`) — v1's
`verification` table, the 14th identity kind; store.AddVerificationRecord +
GetLatestVerificationRecord.

Deliberate seams vs v1 (missing iam2 deps, not shortcuts): signup lands the user
in the app's existing org (v1's TenantOrgForSignup founder-org mint needs an
org-create helper + Org.Parent, unmodeled); no captcha verification (no captcha
provider modeled); phone E.164 normalization not ported.

gofmt clean, go build/vet green, go test ./... green (incl -race on the new
handlers). MIGRATION.md §4/§6 updated to reality.
2026-07-15 22:35:53 -07:00
zeekayandhanzo-dev 94502e2b54 feat(iam2): Casdoor verb-alias read layer + one-contract schema.Mask redaction
The #1 cutover blocker: every live console/gateway/portal client hard-codes the
Casdoor verb spellings (get-users, get-organizations, get-user?id=…) in the v1
{status,data,data2} envelope, but iam2's native surface is REST — so a backend
swap 404s every console IAM page. internal/compat serves those verbs as READ
aliases over the SAME orm store, redaction, and authz as the REST handlers
(generic listHandler[T]/getHandler[T]; paginate only when both p+pageSize;
data2=total).

Redaction is consolidated to ONE contract: schema.Mask() per entity, returning a
masked COPY (never mutates the receiver — the login-verify path must never see a
blanked hash). users.redact/organizations.masked deleted; every read handler +
compat call .Mask(). This CLOSES latent leaks (red review):
applications/providers returned clientSecret raw on get/list; Application's
enriched joins (OrganizationObj, Providers[].Provider, CertObj) carried a linked
entity's own secret past the top-level mask; User.Mask now also blanks the live
VerificationCode.

Guard read-authorization understands the Casdoor ?id=<owner>/<name> shape
(authz.ReadTarget, exported) so non-super org-admins aren't 403'd on id-reads;
explicit owner/name still win and the handler re-scopes every query owner through
authz.Scope, so a ?owner=x&id=y/z split cannot cross tenants.

Tests: schema/mask_test (per-entity no-leak + no-mutation, enriched joins),
compat/aliases_test (v1 envelope, pagination, no-secret-leak through the REAL
router, owner-scoping, cross-tenant + regular-user denial, ?id= fallback).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-15 22:31:00 -07:00
zeekayandClaude Opus 4.8 a4718e75c5 feat(iam2): Casdoor verb-alias read layer + one-contract schema.Mask redaction
The #1 cutover blocker: every live console/gateway/portal client hard-codes the
Casdoor verb spellings (get-users, get-organizations, get-user?id=…) in the v1
{status,data,data2} envelope, but iam2's native surface is REST — so a backend
swap 404s every console IAM page. internal/compat serves those verbs as READ
aliases over the SAME orm store, redaction, and authz as the REST handlers
(generic listHandler[T]/getHandler[T]; paginate only when both p+pageSize;
data2=total).

Redaction is consolidated to ONE contract: schema.Mask() per entity, returning a
masked COPY (never mutates the receiver — the login-verify path must never see a
blanked hash). users.redact/organizations.masked deleted; every read handler +
compat call .Mask(). This CLOSES latent leaks (red review):
applications/providers returned clientSecret raw on get/list; Application's
enriched joins (OrganizationObj, Providers[].Provider, CertObj) carried a linked
entity's own secret past the top-level mask; User.Mask now also blanks the live
VerificationCode.

Guard read-authorization understands the Casdoor ?id=<owner>/<name> shape
(authz.ReadTarget, exported) so non-super org-admins aren't 403'd on id-reads;
explicit owner/name still win and the handler re-scopes every query owner through
authz.Scope, so a ?owner=x&id=y/z split cannot cross tenants.

Tests: schema/mask_test (per-entity no-leak + no-mutation, enriched joins),
compat/aliases_test (v1 envelope, pagination, no-secret-leak through the REAL
router, owner-scoping, cross-tenant + regular-user denial, ?id= fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:31:00 -07:00
z 8008161e3e sessions: tamper-evident session-cookie primitives (front-door §4 session layer)
The portal session the native front-door sets on a bare (type=login) sign-in and
that get-account resolves the caller from — the connective piece so get-account
serves the portal + gateway-admin-guard SESSION path, not just the bearer/API
path. Signed base64url(payload).base64url(HMAC-SHA256) carrying {owner, name,
application, sid, exp}; the signature is what makes `owner` (the admin-guard's
global-admin input) unforgeable. HMAC key derived from the platform signing
cert (domain-separated) — no new secret to provision, survives restarts. SID is
a 256-bit random id for Session-row revocation once wired into login SET.

Tests: round-trip, FORGED owner=admin rejected (the security property), wrong
key, expiry, malformed, SID uniqueness/size. Pure — no db, unit-testable.
2026-07-15 22:23:45 -07:00
z 841ce94358 sessions: tamper-evident session-cookie primitives (front-door §4 session layer)
The portal session the native front-door sets on a bare (type=login) sign-in and
that get-account resolves the caller from — the connective piece so get-account
serves the portal + gateway-admin-guard SESSION path, not just the bearer/API
path. Signed base64url(payload).base64url(HMAC-SHA256) carrying {owner, name,
application, sid, exp}; the signature is what makes `owner` (the admin-guard's
global-admin input) unforgeable. HMAC key derived from the platform signing
cert (domain-separated) — no new secret to provision, survives restarts. SID is
a 256-bit random id for Session-row revocation once wired into login SET.

Tests: round-trip, FORGED owner=admin rejected (the security property), wrong
key, expiry, malformed, SID uniqueness/size. Pure — no db, unit-testable.
2026-07-15 22:23:45 -07:00
z 5b0977509f oidc: implement /v1/iam/get-account (bearer path) — front-door residual §4
get-account is a SECURITY contract: the gateway admin-guard derives the
global-admin (SuperAdmin) predicate from the `owner` it returns. Implemented
to match v1's envelope exactly — {status, sub, name, data:<user>, data2:<org>} —
resolving the caller from the bearer access token (shared with userinfo's
verifyToken), redacting every secret, and returning {status:"error"} for
anonymous/invalid callers (200, casibase convention → admin-guard reads
error → not-admin, fail-closed).

DRY: promotes the secret-strip to canonical schema.User.Redact() +
schema.Organization.Redact(); the existing users.redact / organizations.masked
now delegate to them (one definition each). Wired into MountFrontDoor.

Tests: bearer → redacted account (owner correct, 6 secret fields stripped);
anonymous/garbage → error + no data leak; Redact keeps owner+isAdmin, strips
secrets in place. Full iam2 suite green.

The portal session-cookie resolution path plugs into the same handler with no
shape change once the session layer lands (remaining §4 residual: session
issuance, signup, send-verification-code).
2026-07-15 22:06:08 -07:00
z d6d4efcf79 oidc: implement /v1/iam/get-account (bearer path) — front-door residual §4
get-account is a SECURITY contract: the gateway admin-guard derives the
global-admin (SuperAdmin) predicate from the `owner` it returns. Implemented
to match v1's envelope exactly — {status, sub, name, data:<user>, data2:<org>} —
resolving the caller from the bearer access token (shared with userinfo's
verifyToken), redacting every secret, and returning {status:"error"} for
anonymous/invalid callers (200, casibase convention → admin-guard reads
error → not-admin, fail-closed).

DRY: promotes the secret-strip to canonical schema.User.Redact() +
schema.Organization.Redact(); the existing users.redact / organizations.masked
now delegate to them (one definition each). Wired into MountFrontDoor.

Tests: bearer → redacted account (owner correct, 6 secret fields stripped);
anonymous/garbage → error + no data leak; Redact keeps owner+isAdmin, strips
secrets in place. Full iam2 suite green.

The portal session-cookie resolution path plugs into the same handler with no
shape change once the session layer lands (remaining §4 residual: session
issuance, signup, send-verification-code).
2026-07-15 22:06:08 -07:00
zeekayandhanzo-dev 1a5e6d6a0e docs(iam2): MIGRATION.md to reality — orm not base, drift-gate dropped, argon2id RESOLVED
Corrects stale scaffold framing: storage is orm/hanzoai/sqlite (not base),
inter-service is zap-proto (not luxfi/zap), the drift gate is DROPPED (parity =
tests + golden vectors + parity audit + shadow deploy), and the argon2id
credential landmine is marked RESOLVED with the golden-vector proof. Preserves
the front-door residual + the three v1 security contracts. Adds the native
git.hanzo.ai+GitOps build/deploy section.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-15 13:12:02 -07:00
zeekayandClaude Opus 4.8 fa2dbcda26 docs(iam2): MIGRATION.md to reality — orm not base, drift-gate dropped, argon2id RESOLVED
Corrects stale scaffold framing: storage is orm/hanzoai/sqlite (not base),
inter-service is zap-proto (not luxfi/zap), the drift gate is DROPPED (parity =
tests + golden vectors + parity audit + shadow deploy), and the argon2id
credential landmine is marked RESOLVED with the golden-vector proof. Preserves
the front-door residual + the three v1 security contracts. Adds the native
git.hanzo.ai+GitOps build/deploy section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:12:02 -07:00
zeekayandhanzo-dev 0458406ce5 test(iam2): golden vector — iam2 verifies v1's OWN argon2id digest (cutover parity)
The parity proof that matters: a PHC digest produced by hanzoai/iam's actual
Argon2idCredManager (GetHashedPassword, DefaultParams), captured verbatim and
asserted to verify under iam2's cred.Verify. Not a digest iam2 generated itself —
the exact bytes v1 writes.

Pins a REAL cross-version risk found while doing this: v1 resolves
argon2id v0.0.0-20211130144151 while iam2 pins v1.0.0. The PHC string is
self-describing (m=65536,t=1,p=N + salt + key), so either version verifies the
other's digest — this test proves it and fails loudly if a future bump breaks it.

Also asserts the shape (a v1 param change is caught here, not in prod), the full
row→algorithm path (user type wins over org fallback), and that the shipped bug
stays dead: the v1 argon2id digest must NOT verify under bcrypt.

Test passwords only — no live user's digest is ever committed (a real hash is an
offline-attackable secret).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-15 13:02:00 -07:00
zeekayandClaude Opus 4.8 9184529b37 test(iam2): golden vector — iam2 verifies v1's OWN argon2id digest (cutover parity)
The parity proof that matters: a PHC digest produced by hanzoai/iam's actual
Argon2idCredManager (GetHashedPassword, DefaultParams), captured verbatim and
asserted to verify under iam2's cred.Verify. Not a digest iam2 generated itself —
the exact bytes v1 writes.

Pins a REAL cross-version risk found while doing this: v1 resolves
argon2id v0.0.0-20211130144151 while iam2 pins v1.0.0. The PHC string is
self-describing (m=65536,t=1,p=N + salt + key), so either version verifies the
other's digest — this test proves it and fails loudly if a future bump breaks it.

Also asserts the shape (a v1 param change is caught here, not in prod), the full
row→algorithm path (user type wins over org fallback), and that the shipped bug
stays dead: the v1 argon2id digest must NOT verify under bcrypt.

Test passwords only — no live user's digest is ever committed (a real hash is an
offline-attackable secret).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:02:00 -07:00
zeekayandhanzo-dev 71d9142a9b fix(iam2): resolve the password algorithm per row — argon2id, not bcrypt-only
Closes the cutover blocker recorded in 60b69d1: users.VerifyPassword called
bcrypt unconditionally and is the only path credential login takes, but EVERY
live v1 row is argon2id (org PasswordType is rewritten to argon2id on
create/update; UpdateUserPassword stamps it per user). bcrypt handed an argon2id
PHC digest returns ErrHashTooShort — so at cutover 100% of credential logins
would fail, for every existing user, immediately.

Ports v1's contract (object/check.go): the hash algorithm is a property of the
STORED ROW — user.PasswordType, falling back to organization.PasswordType — never
a constant.

- internal/cred: pure Verify(passwordType, plaintext, hashed) dispatching
  argon2id (github.com/alexedwards/argon2id — the same lib + PHC format v1's
  Argon2idCredManager writes) and bcrypt. Resolve(userType, orgType) implements
  the row→org fallback. Verify-ONLY (never hashes; upgrade-on-login stays a
  separate deliberate decision). Fails CLOSED on unknown/empty/malformed —
  including the legacy salt schemes and `plain` — because a silent pass on an
  unrecognized scheme is an auth bypass.
- users.VerifyPassword(u, plaintext, orgPasswordType) is now algorithm-aware.
- oidc/login.go resolves the org's PasswordType (store.GetOrganizationByName)
  and passes it, so the fallback is live on the real login path.

Tests (6/6): a REAL argon2id PHC digest verifies + wrong password rejected;
bcrypt unregressed; cross-scheme (argon2id digest under bcrypt and vice versa)
fails closed — the actual bypass shape; garbage/unknown/empty fail closed;
per-row-then-org resolution. Full suite green (authz, cred, oidc, seed).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-15 12:41:30 -07:00
zeekayandClaude Opus 4.8 2167405fa6 fix(iam2): resolve the password algorithm per row — argon2id, not bcrypt-only
Closes the cutover blocker recorded in beb808b: users.VerifyPassword called
bcrypt unconditionally and is the only path credential login takes, but EVERY
live v1 row is argon2id (org PasswordType is rewritten to argon2id on
create/update; UpdateUserPassword stamps it per user). bcrypt handed an argon2id
PHC digest returns ErrHashTooShort — so at cutover 100% of credential logins
would fail, for every existing user, immediately.

Ports v1's contract (object/check.go): the hash algorithm is a property of the
STORED ROW — user.PasswordType, falling back to organization.PasswordType — never
a constant.

- internal/cred: pure Verify(passwordType, plaintext, hashed) dispatching
  argon2id (github.com/alexedwards/argon2id — the same lib + PHC format v1's
  Argon2idCredManager writes) and bcrypt. Resolve(userType, orgType) implements
  the row→org fallback. Verify-ONLY (never hashes; upgrade-on-login stays a
  separate deliberate decision). Fails CLOSED on unknown/empty/malformed —
  including the legacy salt schemes and `plain` — because a silent pass on an
  unrecognized scheme is an auth bypass.
- users.VerifyPassword(u, plaintext, orgPasswordType) is now algorithm-aware.
- oidc/login.go resolves the org's PasswordType (store.GetOrganizationByName)
  and passes it, so the fallback is live on the real login path.

Tests (6/6): a REAL argon2id PHC digest verifies + wrong password rejected;
bcrypt unregressed; cross-scheme (argon2id digest under bcrypt and vice versa)
fails closed — the actual bypass shape; garbage/unknown/empty fail closed;
per-row-then-org resolution. Full suite green (authz, cred, oidc, seed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 12:41:30 -07:00
hanzo-dev 60b69d1e7d iam2: record the argon2id blocker — bcrypt-only verify fails every live login
users.VerifyPassword calls bcrypt unconditionally and is the only path login
takes. Every live v1 row is argon2id (sanitizeOrgPasswordType rewrites
""/bcrypt/plain -> argon2id on create AND update; CreatePersonalOrganization
inserts it; init_data.json declares it). bcrypt handed an argon2id PHC string
returns ErrHashTooShort, so on cutover every existing user's login fails.

v1 resolves the algorithm per row (check.go: user.PasswordType, else
organization.PasswordType, then cred.GetCredManager). The algorithm is a
property of the stored row, never a constant.

Also records what the front-door port must honour: get-account backs the
gateway's SuperAdmin + waitlist predicates, send-verification-code is
multipart, and native userinfo/logout are aliases of the oauth handlers.
2026-07-15 12:35:34 -07:00
hanzo-dev beb808bf23 iam2: record the argon2id blocker — bcrypt-only verify fails every live login
users.VerifyPassword calls bcrypt unconditionally and is the only path login
takes. Every live v1 row is argon2id (sanitizeOrgPasswordType rewrites
""/bcrypt/plain -> argon2id on create AND update; CreatePersonalOrganization
inserts it; init_data.json declares it). bcrypt handed an argon2id PHC string
returns ErrHashTooShort, so on cutover every existing user's login fails.

v1 resolves the algorithm per row (check.go: user.PasswordType, else
organization.PasswordType, then cred.GetCredManager). The algorithm is a
property of the stored row, never a constant.

Also records what the front-door port must honour: get-account backs the
gateway's SuperAdmin + waitlist predicates, send-verification-code is
multipart, and native userinfo/logout are aliases of the oauth handlers.
2026-07-15 12:35:34 -07:00
hanzo-dev ddfa543e40 iam2: record the front-door residual that gates cutover (HIP-0111 §6)
The OIDC surface is complete; the portal's native front door is not. signup,
send-verification-code, get-account, userinfo, logout are absent — a backend
swap without them takes hanzo.id's signup, verification, account and sign-out
with it. Gated on them regardless of drift.
2026-07-15 12:17:25 -07:00
hanzo-dev 7906187d25 iam2: record the front-door residual that gates cutover (HIP-0111 §6)
The OIDC surface is complete; the portal's native front door is not. signup,
send-verification-code, get-account, userinfo, logout are absent — a backend
swap without them takes hanzo.id's signup, verification, account and sign-out
with it. Gated on them regardless of drift.
2026-07-15 12:17:25 -07:00
hanzo-dev 4d451079db iam2: close the cert private-key leak — scope reads by bearer, mask secrets
GET /v1/iam/certs?owner=<own-org> returned every tenant's certs with
privateKey serialized: a GET binds no query, so certs.List saw in.Owner==""
and listed all, and the response carried the admin signing key — any org admin
could forge tokens for any account. The read twin of the write divergence:
authorize one value, execute another.

One way for each concern: authz.Scope resolves a listing's owner from the
VERIFIED bearer (never a request parameter), and schema.Cert.Mask is the one
place a cert sheds secrets before it crosses the API — the signing key lives in
the store and signs in process; relying parties read the public half from the
JWKS (RFC 7517).

Also serve the JWKS at the root well-known path (RFC 8414) alongside the
/v1/iam one, matching live v1 — the gateway defaults to root.

Tests assert the response BODY, and are proven non-vacuous: neutering Mask or
Scope each fails a distinct case.
2026-07-15 12:15:01 -07:00
hanzo-dev 8b153bd18f iam2: close the cert private-key leak — scope reads by bearer, mask secrets
GET /v1/iam/certs?owner=<own-org> returned every tenant's certs with
privateKey serialized: a GET binds no query, so certs.List saw in.Owner==""
and listed all, and the response carried the admin signing key — any org admin
could forge tokens for any account. The read twin of the write divergence:
authorize one value, execute another.

One way for each concern: authz.Scope resolves a listing's owner from the
VERIFIED bearer (never a request parameter), and schema.Cert.Mask is the one
place a cert sheds secrets before it crosses the API — the signing key lives in
the store and signs in process; relying parties read the public half from the
JWKS (RFC 7517).

Also serve the JWKS at the root well-known path (RFC 8414) alongside the
/v1/iam one, matching live v1 — the gateway defaults to root.

Tests assert the response BODY, and are proven non-vacuous: neutering Mask or
Scope each fails a distinct case.
2026-07-15 12:15:01 -07:00
hanzo-dev 763432f535 Merge fix/authz-op-seam-divergence: authorize decoded op input (close users-owner + MCP target divergence) 2026-07-15 11:45:14 -07:00
hanzo-dev 3a339a0d79 Merge fix/authz-op-seam-divergence: authorize decoded op input (close users-owner + MCP target divergence) 2026-07-15 11:45:14 -07:00
Hanzo CI 1fd936a656 iam2 authz: authorize the decoded op input, not a re-parsed body
The Phase-3 guard re-parsed the authorization target from the raw request
body in middleware, divergently from where each handler binds it. For the
users entity — the one input that nests its record under `user` — the guard
read the top-level owner while the handler bound user.owner, so an org admin
could mask {owner:<own-org>, user:{owner:admin, isAdmin:true}} and write a
platform SuperAdmin (owner=="admin" IS the predicate). The same divergence let
an MCP tools/call mask the target in params.arguments.

Decomplect into two orthogonal seams:
  - Guard (app.Use) authenticates every request and authorizes reads, whose
    target rides in the query string.
  - Authorize (app.Authorize) authorizes writes at the framework's op-invoke
    seam, on the DECODED typed input — the exact value the handler binds — so
    REST and MCP authorize what actually executes, by construction. There is no
    second parse of the body to diverge from.

The one nested-owner input declares its target via an owned interface
(users.CreateInput/UpdateInput.AuthzTarget); the handler binds through the
same method, so the value authorized is the value written in one code path.
Every other entity files its owner at the top level, read reflectively, so an
attacker-supplied nested sub-struct is never mistaken for the target.

Requires zap-proto/zip v1.8.3 (the op-invoke Authorizer hook + App.Prepare).

Tests: the deferred /mcp and /openapi routes are installed for real, the real
op ids are used (post_v1_iam_certs, post_v1_iam_users), and both mask envelopes
(REST users owner-mask + MCP arguments-mask) assert 403/isError AND zero
admin-owned rows persisted — querying the store, not just the status code.
2026-07-15 11:45:14 -07:00
Hanzo CI fccb0d3b87 iam2 authz: authorize the decoded op input, not a re-parsed body
The Phase-3 guard re-parsed the authorization target from the raw request
body in middleware, divergently from where each handler binds it. For the
users entity — the one input that nests its record under `user` — the guard
read the top-level owner while the handler bound user.owner, so an org admin
could mask {owner:<own-org>, user:{owner:admin, isAdmin:true}} and write a
platform SuperAdmin (owner=="admin" IS the predicate). The same divergence let
an MCP tools/call mask the target in params.arguments.

Decomplect into two orthogonal seams:
  - Guard (app.Use) authenticates every request and authorizes reads, whose
    target rides in the query string.
  - Authorize (app.Authorize) authorizes writes at the framework's op-invoke
    seam, on the DECODED typed input — the exact value the handler binds — so
    REST and MCP authorize what actually executes, by construction. There is no
    second parse of the body to diverge from.

The one nested-owner input declares its target via an owned interface
(users.CreateInput/UpdateInput.AuthzTarget); the handler binds through the
same method, so the value authorized is the value written in one code path.
Every other entity files its owner at the top level, read reflectively, so an
attacker-supplied nested sub-struct is never mistaken for the target.

Requires zap-proto/zip v1.8.3 (the op-invoke Authorizer hook + App.Prepare).

Tests: the deferred /mcp and /openapi routes are installed for real, the real
op ids are used (post_v1_iam_certs, post_v1_iam_users), and both mask envelopes
(REST users owner-mask + MCP arguments-mask) assert 403/isError AND zero
admin-owned rows persisted — querying the store, not just the status code.
2026-07-15 11:45:14 -07:00
hanzo-dev ff35dddf73 Merge Phase 3: authz gate over the entity CRUD 2026-07-15 10:35:09 -07:00
hanzo-dev 5f9224e6c6 Merge Phase 3: authz gate over the entity CRUD 2026-07-15 10:35:09 -07:00
hanzo-dev 69825a2c32 iam2 Phase 3: authz gate over the entity CRUD
One authz middleware (internal/authz), mounted first via app.Use in
routes.Mount, verifies the bearer and enforces tenant scoping before any CRUD
handler runs. Closes the Phase-1 gap where the users/certs/applications/... CRUD
was unauthenticated: an unverified caller could overwrite an admin-owned signing
cert and forge any token.

Policy — three scopes, never conflated:
  - SuperAdmin (owner == "admin", a live admin-org user): the only cross-tenant
    scope; required to write any admin/built-in-owned resource. This one rule is
    the signing-cert poisoning gate, admin app/provider registration, and the
    built-in-org gap at once.
  - Org admin (IsAdmin): manages only its own org's resources.
  - Regular user: reads only its own user record.

The principal's org is taken from the token SUBJECT (the user's own owner/name),
never the owner/organization claim (the app's org, which diverges for a shared
app) — so a tenant user signing in through a shared admin-org app is not
SuperAdmin. Bearer verification reuses oidc.VerifyToken (exported): the same
algorithm allowlist, trusted signing-cert kid resolution, and expiry checks
every OIDC route uses. The reserved-owner set reuses store.IsSigningCertOwner.
Fail closed: only the OIDC/OAuth + front-door allowlist is public; the framework
/mcp and /openapi projections are gated too, and MCP is disabled on the
standalone binary.

Tests (internal/authz): the full policy matrix as a unit table, plus end-to-end
through the real mounted router — unauthenticated 401, cross-org 403, the
poisoning gate 403 across every cert-write verb, SuperAdmin cert rotation +
cross-org 2xx, org-admin own-org 2xx / foreign 403, regular self-read admitted /
writes and others 403, public routes open, bad bearers
(expired/HMAC/none/forged-kid/wrong-key/revoked) 401, owner-claim non-escalation,
phantom-admin subject no authority, /mcp + /openapi gated.

GOWORK=off go build ./... && go vet ./... && go test -race ./... all green.
2026-07-15 10:34:49 -07:00
hanzo-dev 0cfd72096a iam2 Phase 3: authz gate over the entity CRUD
One authz middleware (internal/authz), mounted first via app.Use in
routes.Mount, verifies the bearer and enforces tenant scoping before any CRUD
handler runs. Closes the Phase-1 gap where the users/certs/applications/... CRUD
was unauthenticated: an unverified caller could overwrite an admin-owned signing
cert and forge any token.

Policy — three scopes, never conflated:
  - SuperAdmin (owner == "admin", a live admin-org user): the only cross-tenant
    scope; required to write any admin/built-in-owned resource. This one rule is
    the signing-cert poisoning gate, admin app/provider registration, and the
    built-in-org gap at once.
  - Org admin (IsAdmin): manages only its own org's resources.
  - Regular user: reads only its own user record.

The principal's org is taken from the token SUBJECT (the user's own owner/name),
never the owner/organization claim (the app's org, which diverges for a shared
app) — so a tenant user signing in through a shared admin-org app is not
SuperAdmin. Bearer verification reuses oidc.VerifyToken (exported): the same
algorithm allowlist, trusted signing-cert kid resolution, and expiry checks
every OIDC route uses. The reserved-owner set reuses store.IsSigningCertOwner.
Fail closed: only the OIDC/OAuth + front-door allowlist is public; the framework
/mcp and /openapi projections are gated too, and MCP is disabled on the
standalone binary.

Tests (internal/authz): the full policy matrix as a unit table, plus end-to-end
through the real mounted router — unauthenticated 401, cross-org 403, the
poisoning gate 403 across every cert-write verb, SuperAdmin cert rotation +
cross-org 2xx, org-admin own-org 2xx / foreign 403, regular self-read admitted /
writes and others 403, public routes open, bad bearers
(expired/HMAC/none/forged-kid/wrong-key/revoked) 401, owner-claim non-escalation,
phantom-admin subject no authority, /mcp + /openapi gated.

GOWORK=off go build ./... && go vet ./... && go test -race ./... all green.
2026-07-15 10:34:49 -07:00
zeekayandhanzo-dev 67ae8351a5 ci: native git.hanzo.ai (Gitea) portable build — self-contained, off GitHub reusable
Per the directive: canonical build/deploy is git.hanzo.ai (Gitea) + Hanzo GitOps;
GitHub is mirror-only. Replace the GitHub-specific reusable-workflow caller
(uses: hanzoai/.github/...@main — which caused startup_failure on this new
private repo, and is a GitHub-ism Gitea can't resolve) with a SELF-CONTAINED
docker buildx build+push that runs on a Gitea act_runner natively — and equally
on any standard runner. Same portable file at .gitea/workflows/build.yaml (native)
and .github/workflows/build.yml (mirror).

Registry + creds come from Actions secrets (REGISTRY_USER/REGISTRY_TOKEN,
KMS-provisioned), ghcr.io fallback during the mirror transition. Tags: v* → the
tag; branch → sha-<7>. amd64, pure-Go, GOEXPERIMENT=jsonv2.

Still ops-gated to actually run: native act_runner registered to git.hanzo.ai +
Hanzo GitOps deployed/pointed at it + git.hanzo.ai push creds (all cluster/gitea
admin). This makes iam2 READY for the native pipeline the moment those exist.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-15 10:02:13 -07:00
zeekayandClaude Opus 4.8 010375c2d1 ci: native git.hanzo.ai (Gitea) portable build — self-contained, off GitHub reusable
Per the directive: canonical build/deploy is git.hanzo.ai (Gitea) + Hanzo GitOps;
GitHub is mirror-only. Replace the GitHub-specific reusable-workflow caller
(uses: hanzoai/.github/...@main — which caused startup_failure on this new
private repo, and is a GitHub-ism Gitea can't resolve) with a SELF-CONTAINED
docker buildx build+push that runs on a Gitea act_runner natively — and equally
on any standard runner. Same portable file at .gitea/workflows/build.yaml (native)
and .github/workflows/build.yml (mirror).

Registry + creds come from Actions secrets (REGISTRY_USER/REGISTRY_TOKEN,
KMS-provisioned), ghcr.io fallback during the mirror transition. Tags: v* → the
tag; branch → sha-<7>. amd64, pure-Go, GOEXPERIMENT=jsonv2.

Still ops-gated to actually run: native act_runner registered to git.hanzo.ai +
Hanzo GitOps deployed/pointed at it + git.hanzo.ai push creds (all cluster/gitea
admin). This makes iam2 READY for the native pipeline the moment those exist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 10:02:13 -07:00
hanzo-dev 80f30914a9 iam2 Phase 2: OIDC/OAuth2 server, reconciled onto CI-pinned main
feat/oidc-server @45b3729: discovery, JWKS, authorize, token (auth-code+PKCE/
refresh/client_credentials), userinfo, logout, login; RS256/ES + ML-DSA-65;
token-forgery + cross-tenant defenses. Crypto via luxfi/crypto/pq/mldsa/mldsa65
(circl indirect); zip v1.8.2 (Redirect); published deps, no replaces.
2026-07-15 09:49:46 -07:00
hanzo-dev 7de0457f3d iam2 Phase 2: OIDC/OAuth2 server, reconciled onto CI-pinned main
feat/oidc-server @fa7893f: discovery, JWKS, authorize, token (auth-code+PKCE/
refresh/client_credentials), userinfo, logout, login; RS256/ES + ML-DSA-65;
token-forgery + cross-tenant defenses. Crypto via luxfi/crypto/pq/mldsa/mldsa65
(circl indirect); zip v1.8.2 (Redirect); published deps, no replaces.
2026-07-15 09:49:46 -07:00
hanzo-dev f598506d13 iam2 Phase 2: in-tree OIDC/OAuth2 server (discovery, JWKS, authorize, token x3, userinfo, logout, login; RS256/ES/ML-DSA-65; forgery + cross-tenant defenses) 2026-07-15 09:43:14 -07:00
hanzo-dev b78ba9dbe5 iam2 Phase 2: in-tree OIDC/OAuth2 server (discovery, JWKS, authorize, token x3, userinfo, logout, login; RS256/ES/ML-DSA-65; forgery + cross-tenant defenses) 2026-07-15 09:43:14 -07:00
zeekayandhanzo-dev 6ed1a6ded0 ci: match visor's exact build.yml (isolate startup_failure) + fix stale README
The extra platforms input was the only delta from the working visor caller;
drop it to match exactly. Also correct README status (orm not base, OAuth core
live not Phase-0, zap-proto not luxfi/zap) — the rebase had pulled in the old
scaffold docs.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-15 09:19:03 -07:00
zeekayandClaude Opus 4.8 1b6c45daeb ci: match visor's exact build.yml (isolate startup_failure) + fix stale README
The extra platforms input was the only delta from the working visor caller;
drop it to match exactly. Also correct README status (orm not base, OAuth core
live not Phase-0, zap-proto not luxfi/zap) — the rebase had pulled in the old
scaffold docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:19:03 -07:00
zeekayandhanzo-dev 83404846cd ci: Hanzo CI — Dockerfile + build workflow, pin published deps (buildable in CI)
iam2 can now build on the Hanzo self-hosted ARC runners (hanzo-build-linux-amd64),
the same pipeline every hanzo service uses — the path to a real
ghcr.io/hanzoai/iam2 image and production deploy.

- go.mod: dropped the local replace directives (=> ../orm, ../../zap-proto/zip)
  and pinned the PUBLISHED versions (orm v0.6.1, zip v1.6.0). The local replaces
  were dev-only during the migration and don't exist in a CI container; verified
  iam2 builds + all tests pass against the published deps (no luxfi rot).
- Dockerfile: multi-stage golang:1.26.4 → alpine, CGO_ENABLED=0 (pure-Go;
  hanzoai/sqlite's modernc engine needs no cgo), GOEXPERIMENT=jsonv2 per
  SCALE_STANDARD, version via ldflags, non-root uid 1000, /data volume, serves
  ZAP :9653 + HTTP :8080; CMD bootstraps from --init-data.
- .github/workflows/build.yml: calls the shared hanzoai/.github docker-build.yml,
  image ghcr.io/hanzoai/iam2, linux/amd64, hanzo runners only (never cross-org).
- .dockerignore: clean build context.

Verified: the exact Dockerfile go build (CGO_ENABLED=0 GOEXPERIMENT=jsonv2
-trimpath -ldflags) compiles a working binary; oidc+seed suites green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-15 09:16:31 -07:00
zeekayandClaude Opus 4.8 c44f5aac39 ci: Hanzo CI — Dockerfile + build workflow, pin published deps (buildable in CI)
iam2 can now build on the Hanzo self-hosted ARC runners (hanzo-build-linux-amd64),
the same pipeline every hanzo service uses — the path to a real
ghcr.io/hanzoai/iam2 image and production deploy.

- go.mod: dropped the local replace directives (=> ../orm, ../../zap-proto/zip)
  and pinned the PUBLISHED versions (orm v0.6.1, zip v1.6.0). The local replaces
  were dev-only during the migration and don't exist in a CI container; verified
  iam2 builds + all tests pass against the published deps (no luxfi rot).
- Dockerfile: multi-stage golang:1.26.4 → alpine, CGO_ENABLED=0 (pure-Go;
  hanzoai/sqlite's modernc engine needs no cgo), GOEXPERIMENT=jsonv2 per
  SCALE_STANDARD, version via ldflags, non-root uid 1000, /data volume, serves
  ZAP :9653 + HTTP :8080; CMD bootstraps from --init-data.
- .github/workflows/build.yml: calls the shared hanzoai/.github docker-build.yml,
  image ghcr.io/hanzoai/iam2, linux/amd64, hanzo runners only (never cross-org).
- .dockerignore: clean build context.

Verified: the exact Dockerfile go build (CGO_ENABLED=0 GOEXPERIMENT=jsonv2
-trimpath -ldflags) compiles a working binary; oidc+seed suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:16:31 -07:00
hanzo-dev 45b372991f fix(iam2): enforce tenant scope on credential login (cross-tenant token issuance)
POST /v1/iam/login authenticated the user in the caller-supplied organization but
resolved the app by an independent clientId, with no check that the user's org may
use that app. Because a token's owner/organization claim is set to the app's org,
a user with valid credentials in org B could obtain a token whose organization
claim names org A — cross-tenant issuance any relying party that authorizes on the
organization claim would honor.

Require the authenticated user's org to match the application's org, unless the app
is shared (IsShared) or lets users choose their org (OrgChoiceMode) — the standard
org-gated sign-in rule. Shadow-first + the drift-gated cutover make an
over-restriction observable in parity testing before it can reach production.

Tests: a valid org-B user is refused a code for a single-tenant org-A app; a shared
app still accepts a cross-org user. Full suite green + race-clean.
2026-07-15 03:51:27 -07:00
hanzo-dev fa7893f429 fix(iam2): enforce tenant scope on credential login (cross-tenant token issuance)
POST /v1/iam/login authenticated the user in the caller-supplied organization but
resolved the app by an independent clientId, with no check that the user's org may
use that app. Because a token's owner/organization claim is set to the app's org,
a user with valid credentials in org B could obtain a token whose organization
claim names org A — cross-tenant issuance any relying party that authorizes on the
organization claim would honor.

Require the authenticated user's org to match the application's org, unless the app
is shared (IsShared) or lets users choose their org (OrgChoiceMode) — the standard
org-gated sign-in rule. Shadow-first + the drift-gated cutover make an
over-restriction observable in parity testing before it can reach production.

Tests: a valid org-B user is refused a code for a single-tenant org-A app; a shared
app still accepts a cross-org user. Full suite green + race-clean.
2026-07-15 03:51:27 -07:00
hanzo-dev eecac4583b fix(iam2): trust signing certs only under reserved platform owners (token-forgery defense)
Signing-cert resolution (JWKS publish, token signing, and bearer verification) now
trusts a cert only when it is owned by a reserved platform org (admin/built-in),
not any cert that happens to carry the kid's name.

Without this, because a cert is resolved by name across all owners, a caller who
can create a cert row (the entity CRUD is not yet authz-gated — Phase 3) could
register a cert named e.g. cert-hanzo under their own org with their own key,
then mint a JWT with kid=cert-hanzo and forged claims; verification (and the
published JWKS) could resolve THEIR key and accept the forgery platform-wide.

- store.GetSigningCert resolves a kid only among signingCertOwners; FindCertByName
  (global-by-name) removed.
- JWKS excludes any cert not owned by a platform signing owner.
- signerFor resolves the app's cert through the same trusted path, so signing,
  JWKS, and verification stay consistent.

Tests: a tenant cert with a colliding name neither verifies a forged token nor
appears in the JWKS; a non-platform cert is never trusted even as the sole
name-holder. Full suite green + race-clean.
2026-07-15 03:42:53 -07:00
hanzo-dev 52c447a72a fix(iam2): trust signing certs only under reserved platform owners (token-forgery defense)
Signing-cert resolution (JWKS publish, token signing, and bearer verification) now
trusts a cert only when it is owned by a reserved platform org (admin/built-in),
not any cert that happens to carry the kid's name.

Without this, because a cert is resolved by name across all owners, a caller who
can create a cert row (the entity CRUD is not yet authz-gated — Phase 3) could
register a cert named e.g. cert-hanzo under their own org with their own key,
then mint a JWT with kid=cert-hanzo and forged claims; verification (and the
published JWKS) could resolve THEIR key and accept the forgery platform-wide.

- store.GetSigningCert resolves a kid only among signingCertOwners; FindCertByName
  (global-by-name) removed.
- JWKS excludes any cert not owned by a platform signing owner.
- signerFor resolves the app's cert through the same trusted path, so signing,
  JWKS, and verification stay consistent.

Tests: a tenant cert with a colliding name neither verifies a forged token nor
appears in the JWKS; a non-platform cert is never trusted even as the sole
name-holder. Full suite green + race-clean.
2026-07-15 03:42:53 -07:00
hanzo-dev c5957d7259 test(iam2): logout redirect-safety — no open redirect without a verified id_token_hint
Covers the end-session endpoint's redirect guard: no redirect without a
post_logout_redirect_uri; refuse to redirect when the id_token_hint is
absent/unverified or the URI is not registered by the hint's client; honor +
echo state only for a signature-verified hint whose client registered the URI.
2026-07-15 03:34:25 -07:00
hanzo-dev 02722f8af6 test(iam2): logout redirect-safety — no open redirect without a verified id_token_hint
Covers the end-session endpoint's redirect guard: no redirect without a
post_logout_redirect_uri; refuse to redirect when the id_token_hint is
absent/unverified or the URI is not registered by the hint's client; honor +
echo state only for a signature-verified hint whose client registered the URI.
2026-07-15 03:34:25 -07:00
hanzo-dev 73b7ef63ea feat(iam2): Phase 2 — in-tree OIDC/OAuth2 server (authorize, token, userinfo, JWKS)
Complete the OIDC/OAuth2 identity core at the canonical /v1/iam/* paths, matching
the live hanzo.id surface so existing clients verify tokens and run the flows
unchanged. Additive to Phase 0-1; v1 stays authoritative until cutover.

- Discovery at /.well-known and /v1/iam/.well-known/openid-configuration; issuer
  host-relative and consistent with the tokens' iss.
- JWKS publishes every signing cert's public key — RSA/RS256 (the interop path
  every existing verifier reads), EC ES256/384/512, and post-quantum ML-DSA-65
  behind the same seam — keyed by kid with x5c, ETag + 60s cache. Fixes the empty
  JWKS that left RS256 verifiers unable to resolve a key.
- authorize validates client_id + EXACT redirect_uri before any redirect
  (open-redirect defense), normalizes/enforces S256 PKCE, then delegates to the
  hosted login which mints the PKCE-bound code.
- token: authorization_code (single-use, redirect+nonce bound, PKCE-for-public
  enforced), refresh_token (opaque, rotation + reuse detection + family
  revocation), client_credentials; client_secret_basic/post; RFC 6749 error
  taxonomy (invalid_client 401 + WWW-Authenticate, else 400); no-store.
- userinfo authenticates the bearer by hash lookup (revocation) AND signature,
  returns scope-gated claims. id_token minted on openid, nonce echoed.
- Tokens persisted as SHA-256 hashes only; ML-DSA-65 is a real circl-backed
  jwt.SigningMethod, inert unless a cert selects it.

TDD: 85 tests/subtests green — discovery shape, JWKS (RSA/ML-DSA/ETag/dedup/TLS
exclusion), ES256 + full ML-DSA-65 round-trip, authorize validation, code/refresh/
client_credentials flows, PKCE tamper, reuse detection, error taxonomy, tenant
isolation. go vet clean.
2026-07-15 03:28:45 -07:00
hanzo-dev 365a313656 feat(iam2): Phase 2 — in-tree OIDC/OAuth2 server (authorize, token, userinfo, JWKS)
Complete the OIDC/OAuth2 identity core at the canonical /v1/iam/* paths, matching
the live hanzo.id surface so existing clients verify tokens and run the flows
unchanged. Additive to Phase 0-1; v1 stays authoritative until cutover.

- Discovery at /.well-known and /v1/iam/.well-known/openid-configuration; issuer
  host-relative and consistent with the tokens' iss.
- JWKS publishes every signing cert's public key — RSA/RS256 (the interop path
  every existing verifier reads), EC ES256/384/512, and post-quantum ML-DSA-65
  behind the same seam — keyed by kid with x5c, ETag + 60s cache. Fixes the empty
  JWKS that left RS256 verifiers unable to resolve a key.
- authorize validates client_id + EXACT redirect_uri before any redirect
  (open-redirect defense), normalizes/enforces S256 PKCE, then delegates to the
  hosted login which mints the PKCE-bound code.
- token: authorization_code (single-use, redirect+nonce bound, PKCE-for-public
  enforced), refresh_token (opaque, rotation + reuse detection + family
  revocation), client_credentials; client_secret_basic/post; RFC 6749 error
  taxonomy (invalid_client 401 + WWW-Authenticate, else 400); no-store.
- userinfo authenticates the bearer by hash lookup (revocation) AND signature,
  returns scope-gated claims. id_token minted on openid, nonce echoed.
- Tokens persisted as SHA-256 hashes only; ML-DSA-65 is a real circl-backed
  jwt.SigningMethod, inert unless a cert selects it.

TDD: 85 tests/subtests green — discovery shape, JWKS (RSA/ML-DSA/ETag/dedup/TLS
exclusion), ES256 + full ML-DSA-65 round-trip, authorize validation, code/refresh/
client_credentials flows, PKCE tamper, reuse detection, error taxonomy, tenant
isolation. go vet clean.
2026-07-15 03:28:45 -07:00
hanzo-dev 50c1614976 iam2: health probe → /healthz (root, unversioned — orthogonal to the API, matches k8s + operator) 2026-07-15 02:38:01 -07:00
hanzo-dev 1118b62ffe iam2: health probe → /healthz (root, unversioned — orthogonal to the API, matches k8s + operator) 2026-07-15 02:38:01 -07:00
hanzo-dev e736891ffd iam2: rip nested /v1/iam/v2 → canonical /v1/iam (no version nesting in API paths) 2026-07-15 02:35:08 -07:00
hanzo-dev 84d40dc01c iam2: rip nested /v1/iam/v2 → canonical /v1/iam (no version nesting in API paths) 2026-07-15 02:35:08 -07:00
fb2f53e997 fix(iam2): persist credential hashes — json:"-" silently dropped them (login broke) (#9)
CRITICAL: orm serializes every entity to its JSON data column via json.Marshal,
so a field tagged json:"-" is never STORED (not just hidden from responses). The
Casdoor-ported schema used json:"-" on PasswordHash/PasswordSalt/AccessSecretHash
to keep them off API responses (xorm stored via DB columns) — but under orm's
JSON storage that meant they were never persisted. Every retrieved user had an
empty hash → VerifyPassword always failed → login could NEVER succeed.

Fix: give the persisted-credential fields real json tags (passwordHash,
passwordSalt, accessSecretHash) so orm stores them; the users API redact()
already strips every secret from responses (defense the security relies on now).
Verified: login by email → status ok; wrong password → rejected; user CRUD
response still carries NO hash.

Regression test TestPasswordHashPersists proves the hash survives a store
round-trip AND verifies (and wrong-pw fails). 26 oidc tests green.

Follow-up: the nested MFA Secret/SecretKey fields (inside sub-structs) have the
same json:"-" issue — persist them when MFA lands. Root cause is orm conflating
storage-serialization with wire-serialization; orm should honor a storage tag so
this class can't recur (tracked separately).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-15 02:11:52 -07:00
c512732eb0 fix(iam2): persist credential hashes — json:"-" silently dropped them (login broke) (#9)
CRITICAL: orm serializes every entity to its JSON data column via json.Marshal,
so a field tagged json:"-" is never STORED (not just hidden from responses). The
Casdoor-ported schema used json:"-" on PasswordHash/PasswordSalt/AccessSecretHash
to keep them off API responses (xorm stored via DB columns) — but under orm's
JSON storage that meant they were never persisted. Every retrieved user had an
empty hash → VerifyPassword always failed → login could NEVER succeed.

Fix: give the persisted-credential fields real json tags (passwordHash,
passwordSalt, accessSecretHash) so orm stores them; the users API redact()
already strips every secret from responses (defense the security relies on now).
Verified: login by email → status ok; wrong password → rejected; user CRUD
response still carries NO hash.

Regression test TestPasswordHashPersists proves the hash survives a store
round-trip AND verifies (and wrong-pw fails). 26 oidc tests green.

Follow-up: the nested MFA Secret/SecretKey fields (inside sub-structs) have the
same json:"-" issue — persist them when MFA lands. Root cause is orm conflating
storage-serialization with wire-serialization; orm should honor a storage tag so
this class can't recur (tracked separately).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:11:52 -07:00
7d0b957809 feat(iam2): public server package — embeddable in hanzoai/cloud (shadow-first) (#8)
The one call a host binary makes to embed iam2: server.Mount(app, db) registers
the full IAM v2 surface (OIDC discovery/JWKS, get-app-login, auth/methods, token,
login, entity CRUD) onto the host's zip app over its orm.DB. Plus server.OpenSQLite
+ server.Seed(initDataPath) for boot bootstrap.

This is how iam2 goes live embedded in cloud (the deployed multi-mode zip binary
that already embeds Casdoor via iamserver.Run) — zip-native, lean, no separate
pod. SHADOW-FIRST by contract: the host chooses the mount prefix; mounted under a
shadow prefix iam2 runs ALONGSIDE the live Casdoor /v1/iam/* with zero impact,
and is flipped onto the canonical paths only after verification. Never
blind-replace live auth.

Build + full suite green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 22:34:17 -07:00
0459c1d2e5 feat(iam2): public server package — embeddable in hanzoai/cloud (shadow-first) (#8)
The one call a host binary makes to embed iam2: server.Mount(app, db) registers
the full IAM v2 surface (OIDC discovery/JWKS, get-app-login, auth/methods, token,
login, entity CRUD) onto the host's zip app over its orm.DB. Plus server.OpenSQLite
+ server.Seed(initDataPath) for boot bootstrap.

This is how iam2 goes live embedded in cloud (the deployed multi-mode zip binary
that already embeds Casdoor via iamserver.Run) — zip-native, lean, no separate
pod. SHADOW-FIRST by contract: the host chooses the mount prefix; mounted under a
shadow prefix iam2 runs ALONGSIDE the live Casdoor /v1/iam/* with zero impact,
and is flipped onto the canonical paths only after verification. Never
blind-replace live auth.

Build + full suite green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:34:17 -07:00
de2d449bba feat(iam2): Phase 2 increment 5 — credential login (POST /v1/iam/login) (#7)
The interactive-flow counterpart to the token endpoint: login verifies the
password (bcrypt, constant-time via users.VerifyPassword) and mints a PKCE-bound
authorization code the SDK exchanges at /token. Login by EMAIL or USERNAME,
org-scoped (tenant isolation). 25/25 oidc tests.

- internal/oidc/login.go: POST /v1/iam/login. Resolves user by email (contains
  "@") or username within the org; one opaque "username or password is incorrect"
  for both no-user and bad-password (no account-existence oracle); type=code →
  MintCode (PKCE, S256-only) → persist → return the code in the Response
  envelope; type=login → success + userId (session issuance is the next layer).
  The password hash never crosses a response.
- internal/store: GetUserByName + GetUserByEmail (org-scoped).

Tests: TestLoginToTokenFlow proves login(by email)→bcrypt-verify→mint code→
redeem at /token→correct user binding; tenant-isolation (wrong org → not found)
+ by-username + by-email lookup. Live: login/token error paths correct
(opaque failure, invalid_grant, single-use replay rejected).

Known follow-up: the happy-path over HTTP needs the users-CRUD create shape and
the login email-lookup reconciled (unit tests seed the user via orm directly and
pass — the logic is proven; the CRUD-seeding integration is a data-shape detail).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 21:45:41 -07:00
15d9faa71f feat(iam2): Phase 2 increment 5 — credential login (POST /v1/iam/login) (#7)
The interactive-flow counterpart to the token endpoint: login verifies the
password (bcrypt, constant-time via users.VerifyPassword) and mints a PKCE-bound
authorization code the SDK exchanges at /token. Login by EMAIL or USERNAME,
org-scoped (tenant isolation). 25/25 oidc tests.

- internal/oidc/login.go: POST /v1/iam/login. Resolves user by email (contains
  "@") or username within the org; one opaque "username or password is incorrect"
  for both no-user and bad-password (no account-existence oracle); type=code →
  MintCode (PKCE, S256-only) → persist → return the code in the Response
  envelope; type=login → success + userId (session issuance is the next layer).
  The password hash never crosses a response.
- internal/store: GetUserByName + GetUserByEmail (org-scoped).

Tests: TestLoginToTokenFlow proves login(by email)→bcrypt-verify→mint code→
redeem at /token→correct user binding; tenant-isolation (wrong org → not found)
+ by-username + by-email lookup. Live: login/token error paths correct
(opaque failure, invalid_grant, single-use replay rejected).

Known follow-up: the happy-path over HTTP needs the users-CRUD create shape and
the login email-lookup reconciled (unit tests seed the user via orm directly and
pass — the logic is proven; the CRUD-seeding integration is a data-shape detail).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:45:41 -07:00
6b1abd310a feat(iam2): port Casdoor init_data bootstrap — seed the real config on boot (#6)
Ports old-iam's InitFromFile: iam2 self-seeds orgs/apps/providers/certs from
the SAME init_data.json Casdoor uses, so a fresh store (embedded in cloud or
standalone) comes up with the real config instead of empty. New-only +
idempotent; ${VAR} substituted from env (KMS-synced secret injection, same as
Casdoor).

- internal/seed: FromInitData(ctx, db, path) + Apply — upsert via orm.GetOrCreate
  (Get→skip-if-exists, else create), generic-safe field copy through a JSON
  round-trip that preserves the wired Model.
- main.go serve --init-data <path>: seed on boot, log the counts.

Verified against the REAL universe init_data.json: seeds 9 orgs, 79 apps, 7
providers, 6 certs; then get-app-login(hanzo-console) → status ok, org hanzo,
4 providers; auth/methods → github+google oauth; lux-id → org lux. So the
config layer "just works" — the path to embedding iam2 in cloud.

Tests: seed round-trip + env-substitution + new-only idempotency, all green.
Remaining for full prod parity: users/password hashes (sensitive — import
separately), the authorize/login endpoints, ML-DSA JWT + JWKS.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 20:29:14 -07:00
5958c545d6 feat(iam2): port Casdoor init_data bootstrap — seed the real config on boot (#6)
Ports old-iam's InitFromFile: iam2 self-seeds orgs/apps/providers/certs from
the SAME init_data.json Casdoor uses, so a fresh store (embedded in cloud or
standalone) comes up with the real config instead of empty. New-only +
idempotent; ${VAR} substituted from env (KMS-synced secret injection, same as
Casdoor).

- internal/seed: FromInitData(ctx, db, path) + Apply — upsert via orm.GetOrCreate
  (Get→skip-if-exists, else create), generic-safe field copy through a JSON
  round-trip that preserves the wired Model.
- main.go serve --init-data <path>: seed on boot, log the counts.

Verified against the REAL universe init_data.json: seeds 9 orgs, 79 apps, 7
providers, 6 certs; then get-app-login(hanzo-console) → status ok, org hanzo,
4 providers; auth/methods → github+google oauth; lux-id → org lux. So the
config layer "just works" — the path to embedding iam2 in cloud.

Tests: seed round-trip + env-substitution + new-only idempotency, all green.
Remaining for full prod parity: users/password hashes (sensitive — import
separately), the authorize/login endpoints, ML-DSA JWT + JWKS.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:29:14 -07:00
492f165435 feat(iam2): Phase 2 increment 4 — token endpoint + RS256 JWT signing (#5)
Wires the authorization_code grant end to end. 23/23 oidc tests (PKCE 7,
code 10, JWT 5, e2e 1); token error paths verified live.

- internal/oidc/jwt.go — RS256 signer from the Cert entity PEM (PKCS#1/#8);
  Claims = registered + scope + owner + email; kid header from cert name;
  aud = clientId (validators fail closed on aud). ML-DSA-65 rides the same
  Signer later via luxfi/crypto.
- internal/oidc/token.go — POST /v1/iam/oauth/token: authorization_code only
  (refresh/client_credentials → unsupported; implicit permanently off). Reads
  params query→form→basic; client_id match + confidential-secret check (both
  constant-time; public/PKCE client allowed no secret); RedeemCode guard
  (replay/expiry/client/PKCE) → IssueAccessToken → RS256 JWT → SaveToken.
  Unknown code answers a generic invalid_grant (no oracle).
- internal/store — GetTokenByCode, GetCert, PersistToken, SaveToken.

E2E test proves mint→persist→get-by-code→redeem→sign→save→verify-claims and
that replay after persist fails with ErrCodeUsed. Served locally: token error
paths match RFC 6749 §5.2; discovery advertises the endpoint.

iam2 not in prod — verified by the adversarial suite + live smoke, the exact
logic a red pass scrutinizes. Next: authorize + login/session feed the mint
side; ML-DSA-65 method; real JWKS from the Cert public keys.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 20:07:52 -07:00
ca5e8e79f8 feat(iam2): Phase 2 increment 4 — token endpoint + RS256 JWT signing (#5)
Wires the authorization_code grant end to end. 23/23 oidc tests (PKCE 7,
code 10, JWT 5, e2e 1); token error paths verified live.

- internal/oidc/jwt.go — RS256 signer from the Cert entity PEM (PKCS#1/#8);
  Claims = registered + scope + owner + email; kid header from cert name;
  aud = clientId (validators fail closed on aud). ML-DSA-65 rides the same
  Signer later via luxfi/crypto.
- internal/oidc/token.go — POST /v1/iam/oauth/token: authorization_code only
  (refresh/client_credentials → unsupported; implicit permanently off). Reads
  params query→form→basic; client_id match + confidential-secret check (both
  constant-time; public/PKCE client allowed no secret); RedeemCode guard
  (replay/expiry/client/PKCE) → IssueAccessToken → RS256 JWT → SaveToken.
  Unknown code answers a generic invalid_grant (no oracle).
- internal/store — GetTokenByCode, GetCert, PersistToken, SaveToken.

E2E test proves mint→persist→get-by-code→redeem→sign→save→verify-claims and
that replay after persist fails with ErrCodeUsed. Served locally: token error
paths match RFC 6749 §5.2; discovery advertises the endpoint.

iam2 not in prod — verified by the adversarial suite + live smoke, the exact
logic a red pass scrutinizes. Next: authorize + login/session feed the mint
side; ML-DSA-65 method; real JWKS from the Cert public keys.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:07:52 -07:00
4c84c91ef0 feat(iam2): Phase 2 increment 3 — PKCE S256 + authorization-code lifecycle (#4)
The security-critical heart of the OAuth2 code flow, built with an adversarial
test suite (17/17 pass). iam2 is not in prod, so this pre-prod code is verified
by tests + constant-time construction, the exact logic a red pass scrutinizes.

- internal/oidc/pkce.go — RFC 7636 S256 only. ComputeS256Challenge (matches the
  RFC Appendix B vector) + VerifyPKCE: constant-time (subtle.ConstantTimeCompare),
  "plain" permanently refused (incl. empty method), verifier-with-no-challenge
  fails closed, missing-verifier rejected.
- internal/oidc/code.go — the authorization-code lifecycle over the Token entity:
  MintCode (256-bit crypto/rand code, binds app+user+PKCE+scope+resource, 5-min
  TTL, refuses to store plain), RedeemCode (fail-closed: unknown → used(replay) →
  expired → client-mismatch(constant-time) → PKCE), IssueAccessToken (mints the
  access token + marks the code one-shot used).

Threat surface proven: RFC vector, replay (ErrCodeUsed), expiry, client mismatch,
wrong verifier, plain downgrade, public-client-must-present-verifier, verifier-
without-challenge. 17/17 tests green; go build clean.

Next: wire RedeemCode into POST /v1/iam/oauth/token inside a store transaction
(mark-used atomic with issue), + JWT signing (RS256/ML-DSA-65 from the Cert
entity) for the access-token body. authorize/login/session feed the mint side.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 18:31:48 -07:00
7f2ac6b6d8 feat(iam2): Phase 2 increment 3 — PKCE S256 + authorization-code lifecycle (#4)
The security-critical heart of the OAuth2 code flow, built with an adversarial
test suite (17/17 pass). iam2 is not in prod, so this pre-prod code is verified
by tests + constant-time construction, the exact logic a red pass scrutinizes.

- internal/oidc/pkce.go — RFC 7636 S256 only. ComputeS256Challenge (matches the
  RFC Appendix B vector) + VerifyPKCE: constant-time (subtle.ConstantTimeCompare),
  "plain" permanently refused (incl. empty method), verifier-with-no-challenge
  fails closed, missing-verifier rejected.
- internal/oidc/code.go — the authorization-code lifecycle over the Token entity:
  MintCode (256-bit crypto/rand code, binds app+user+PKCE+scope+resource, 5-min
  TTL, refuses to store plain), RedeemCode (fail-closed: unknown → used(replay) →
  expired → client-mismatch(constant-time) → PKCE), IssueAccessToken (mints the
  access token + marks the code one-shot used).

Threat surface proven: RFC vector, replay (ErrCodeUsed), expiry, client mismatch,
wrong verifier, plain downgrade, public-client-must-present-verifier, verifier-
without-challenge. 17/17 tests green; go build clean.

Next: wire RedeemCode into POST /v1/iam/oauth/token inside a store transaction
(mark-used atomic with issue), + JWT signing (RS256/ML-DSA-65 from the Cert
entity) for the access-token body. authorize/login/session feed the mint side.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:31:48 -07:00
8a12b98df9 feat(iam2): Phase 2 increment 2 — store layer + get-app-login + auth/methods (#3)
The read-only front door the @hanzo/iam <Login> calls to self-configure —
pure orm reads, no crypto.

- internal/store: the object layer over orm.TypedQuery (replaces v1 xorm
  ormer.Engine). GetApplicationByClientId / GetApplicationByName / GetProvider
  / EnrichProviders. Uses the PascalCase-no-space filter convention
  (Filter("ClientId=", v)) matching the Phase-1 CRUD.
- internal/oidc/frontdoor.go:
  - GET /v1/iam/get-app-login — resolve app by clientId, MASK client secrets
    (browser-facing), enrich each provider link with its shared record.
  - GET /v1/iam/auth/methods — the SDK self-config endpoint (does NOT exist in
    v1): {password, code, webauthn, web3, signup, oauth[]}. A provider shows
    only when configured (real clientId; web3 is native so always on) — the
    guard that keeps an unconfigured button from dead-ending.

Verified end-to-end (served locally, seeded app+providers): get-app-login
status=ok with clientSecret masked to ""; auth/methods returns
oauth=[{provider-github,GitHub,logo}] + web3=true + password/code/webauthn/signup.
Found+fixed an orm filter bug (trailing space "clientId =" → never matched;
correct is "ClientId=").

Next (auth-critical, blue/red): session (cookie↔sessions), token endpoint
(PKCE single-use+replay), ML-DSA-65 JWT signing, real JWKS, login/signup,
social hop, web3 SIWx via hanzoai/wallet over ZAP.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 17:21:03 -07:00
81cb2d8e15 feat(iam2): Phase 2 increment 2 — store layer + get-app-login + auth/methods (#3)
The read-only front door the @hanzo/iam <Login> calls to self-configure —
pure orm reads, no crypto.

- internal/store: the object layer over orm.TypedQuery (replaces v1 xorm
  ormer.Engine). GetApplicationByClientId / GetApplicationByName / GetProvider
  / EnrichProviders. Uses the PascalCase-no-space filter convention
  (Filter("ClientId=", v)) matching the Phase-1 CRUD.
- internal/oidc/frontdoor.go:
  - GET /v1/iam/get-app-login — resolve app by clientId, MASK client secrets
    (browser-facing), enrich each provider link with its shared record.
  - GET /v1/iam/auth/methods — the SDK self-config endpoint (does NOT exist in
    v1): {password, code, webauthn, web3, signup, oauth[]}. A provider shows
    only when configured (real clientId; web3 is native so always on) — the
    guard that keeps an unconfigured button from dead-ending.

Verified end-to-end (served locally, seeded app+providers): get-app-login
status=ok with clientSecret masked to ""; auth/methods returns
oauth=[{provider-github,GitHub,logo}] + web3=true + password/code/webauthn/signup.
Found+fixed an orm filter bug (trailing space "clientId =" → never matched;
correct is "ClientId=").

Next (auth-critical, blue/red): session (cookie↔sessions), token endpoint
(PKCE single-use+replay), ML-DSA-65 JWT signing, real JWKS, login/signup,
social hop, web3 SIWx via hanzoai/wallet over ZAP.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:21:03 -07:00
7ba6776647 feat(iam2): Phase 2 increment 1 — OIDC discovery + JWKS foundations (#2)
Starts the OIDC/OAuth2 server on zip + orm (the unified Go auth backend).
Raw zip handlers (query/header/cookie/form/redirect reach), not typed
generics — the auth surface needs them.

- internal/httpx: the Casdoor-compatible Response envelope (status/msg/data…)
  the @hanzo/iam SDK + hanzo.id portal consume unchanged, + Bearer/EffectiveHost.
- internal/oidc: /.well-known/openid-configuration (host-relative issuer,
  advertises only canonical /v1/iam/oauth/*, PKCE S256, owner claim, no implicit)
  + /v1/iam/.well-known/jwks (well-formed empty set; certs wire in at token
  signing). Canonical paths, single source of truth.

Verified: go build + vet clean; served locally → discovery HTTP 200 valid
OIDC doc (issuer iam.hanzo.ai), JWKS 200 {"keys":[]}.

Next increments (auth-critical, blue/red): token endpoint (PKCE authorization_code
single-use + replay guard), ML-DSA-65 hybrid JWT signing, authorize/userinfo/logout,
front-door login/get-app-login/auth-methods, social hop, web3 SIWx, CORS.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 13:07:38 -07:00
830fdc2539 feat(iam2): Phase 2 increment 1 — OIDC discovery + JWKS foundations (#2)
Starts the OIDC/OAuth2 server on zip + orm (the unified Go auth backend).
Raw zip handlers (query/header/cookie/form/redirect reach), not typed
generics — the auth surface needs them.

- internal/httpx: the Casdoor-compatible Response envelope (status/msg/data…)
  the @hanzo/iam SDK + hanzo.id portal consume unchanged, + Bearer/EffectiveHost.
- internal/oidc: /.well-known/openid-configuration (host-relative issuer,
  advertises only canonical /v1/iam/oauth/*, PKCE S256, owner claim, no implicit)
  + /v1/iam/.well-known/jwks (well-formed empty set; certs wire in at token
  signing). Canonical paths, single source of truth.

Verified: go build + vet clean; served locally → discovery HTTP 200 valid
OIDC doc (issuer iam.hanzo.ai), JWKS 200 {"keys":[]}.

Next increments (auth-critical, blue/red): token endpoint (PKCE authorization_code
single-use + replay guard), ML-DSA-65 hybrid JWT signing, authorize/userinfo/logout,
front-door login/get-app-login/auth-methods, social hop, web3 SIWx, CORS.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:07:38 -07:00
ae18ba9873 feat(iam2): Phase 1 — full entity fields + typed CRUD handlers (#1)
Replace the Phase-0 13-struct stubs with the field-complete v2 forms of
every Casdoor-fork identity entity, re-expressed on hanzoai/orm, and add
one typed zip CRUD package per entity wired through routes.Mount(app, db).

Schema (internal/schema): full fields for users, organizations,
applications, providers, roles, permissions, certs, keys,
webauthn_credentials, sessions, tokens, audit_logs, invitations. Shared
value types (ThemeData, MfaItem) are declared once. orm.Register stays
centralized in schema.go (one place; a second call panics on duplicate
kind) alongside Kinds(). Permission's Casbin-model column is carried as
AuthzModel (json:"model") because the embedded orm.Model[Permission]
mixin owns the Go identifier Model.

Handlers (internal/<entity>): one owner-scoped CRUD package per entity,
each exposing a uniform Mount(app, db). Reads project to REST GET + MCP,
writes to POST/PUT/DELETE. Users hashes the plaintext password with
bcrypt exactly once and redacts all secret material on read; every
handler resolves rows by the (owner, name) natural key.

x/crypto promoted to a direct require for bcrypt. orm+zip foundation
(relative replaces, no base) is unchanged.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-13 08:45:38 -07:00
7d5a989715 feat(iam2): Phase 1 — full entity fields + typed CRUD handlers (#1)
Replace the Phase-0 13-struct stubs with the field-complete v2 forms of
every Casdoor-fork identity entity, re-expressed on hanzoai/orm, and add
one typed zip CRUD package per entity wired through routes.Mount(app, db).

Schema (internal/schema): full fields for users, organizations,
applications, providers, roles, permissions, certs, keys,
webauthn_credentials, sessions, tokens, audit_logs, invitations. Shared
value types (ThemeData, MfaItem) are declared once. orm.Register stays
centralized in schema.go (one place; a second call panics on duplicate
kind) alongside Kinds(). Permission's Casbin-model column is carried as
AuthzModel (json:"model") because the embedded orm.Model[Permission]
mixin owns the Go identifier Model.

Handlers (internal/<entity>): one owner-scoped CRUD package per entity,
each exposing a uniform Mount(app, db). Reads project to REST GET + MCP,
writes to POST/PUT/DELETE. Users hashes the plaintext password with
bcrypt exactly once and redacts all secret material on read; every
handler resolves rows by the (owner, name) natural key.

x/crypto promoted to a direct require for bcrypt. orm+zip foundation
(relative replaces, no base) is unchanged.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 08:45:38 -07:00
zeekayandhanzo-dev 62582c8ae2 iam2: proprietary IAM v2 foundation on zip + orm (no Casdoor, no base)
Clean-room greenfield identity service replacing the Casdoor fork
(hanzoai/iam, Apache-2.0). Founded on hanzoai/orm over SQLite +
zap-proto/zip, deliberately NOT on hanzoai/base — sheds the
luxfi/consensus braid (168 pkgs), minio, and pgx-in-serving-path.
Compile surface 347 pkgs (vs 698 on base); only luxfi/log remains.

- Phase 0: 13 identity entities registered on orm; /v1/iam/v2/health
  on zip; ctx-first cobra (serve / compare / version).
- Storage backend-pluggable via one orm.DB: sqlite (default) | sql | datastore.
- Drift-gate `compare` reads v1 Casdoor read-only; the v1 Postgres/MySQL
  driver links only under `-tags migration`, keeping the serving binary
  SQLite/ZAP-only.

See MIGRATION.md for the phased, drift-gated cutover plan.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-13 03:40:46 -07:00
311 changed files with 57729 additions and 453 deletions
+6
View File
@@ -0,0 +1,6 @@
data/
*.db
*.db-*
.git/
.claude/
node_modules/
+20 -4
View File
@@ -1,8 +1,19 @@
# binaries
/iam2
# binaries — anchored to the repo root, where `go build` drops them.
# Unanchored (`iam`) would match every path component named iam at any depth,
# which silently hid pkg/iam/ from git.
/iam
/iam-v2
iam2
iam-v2
/iamd
# signing key material — keys live in KMS, never in the tree
object/token_jwt_key.key
object/token_jwt_key.pem
*.pem
*.key
# frontend deps + build output
node_modules/
web/build/
# base / sqlite data
/data
@@ -10,9 +21,14 @@ iam-v2
*.db
*.db-shm
*.db-wal
# committed test fixtures (encrypted-source migrator canvas vector)
!cmd/migrate-v1/testdata/*.db
# env / local
.env
.env.*
*.local
.DS_Store
# agent scratch — never committed
.claude/
+15
View File
@@ -0,0 +1,15 @@
# Canonical caller — every knob lives in /hanzo.yml, none here.
# Runs on the git-runner fleet at git.hanzo.ai, the only pool serving these
# labels. github.com resolves only .github/workflows and has no runner for
# them, so a caller placed there is a gate that cannot be scheduled.
name: CI/CD
on:
push:
branches: [main, master]
tags: ['v*']
pull_request:
workflow_dispatch:
jobs:
cicd:
uses: hanzoai/ci/.hanzo/workflows/build.yml@v1
secrets: inherit
+200
View File
@@ -0,0 +1,200 @@
name: image
# THE builder for ghcr.io/hanzoai/iam from main, on Hanzo's own forge.
#
# git.hanzo.ai push (from GitHub via sync-from-github.yml) → act_runner
# → buildx → ghcr.io/hanzoai/iam:sha-<7>
#
# WHY THIS FILE IS NOT CALLED build.yml. Gitea collects workflows from the FIRST
# of WORKFLOW_DIRS that exists in the pushed commit and ignores the rest
# (modules/actions/workflows.go, listWorkflowsInDirs — it breaks on the first
# hit), so on main this directory shadows .github/workflows entirely. The
# Casdoor-lineage branches carried no .hanzo/workflows, so when a v* tag on one
# of them synced to the mirror, Gitea collected its .github/workflows/build.yml
# instead — and that file logs in with hanzo-dev + GH_PAT, which exists as a
# git.hanzo.ai org secret. It would therefore SUCCEED, racing GitHub Actions to
# push the same immutable ghcr.io/hanzoai/iam:v<X.Y.Z> from the same commit: two
# digests behind one name, exactly the platform v4.4.5 incident. Gitea's disable
# list is keyed on the workflow FILENAME (services/actions/notifier_helper.go
# checks cfg.IsWorkflowDisabled(wf.EntryName)), so `build.yml` was disabled on
# this repo to block that legacy builder, and this file carries a distinct name
# so the block could not also silence it.
#
# That second lineage is now DELETED, not disabled: every .github/workflows
# builder (build/cicd/release/docker-deploy) was removed from all 133 branches
# that carried one, so no tree in this repo can collect a second builder on
# either forge. The release line has also converged — v1.33.20…v1.33.31 are all
# commits on main. This is the only file in the repo that builds an image.
#
# WHY THIS FILE EXISTS. `.github/workflows/build.yml` was neutralized to a
# dispatch-only echo on 2026-07-24 (f267b4ae8) in favour of a native pipeline
# that could never run, so every commit on main since has built nothing
# anywhere. Measured on 2026-07-25: main is `diverged` from the v1.33.x line
# that actually ships (136 ahead / 142 behind v1.33.8), the last image
# v1.33.8 came from a tag push on that OTHER line, and git.hanzo.ai had
# Actions disabled on this mirror — zero runs, ever. This is the repair.
#
# The four defects in the file this replaces, each measured, not guessed:
# 1. runs-on: hanzo-linux-amd64 — matches NO registered runner. The four
# online act_runners advertise exactly ubuntu-latest, ubuntu-22.04,
# ubuntu-24.04, hanzo-build-linux-amd64 (/api/v1/admin/actions/runners).
# A job asking for the old label queues forever instead of failing.
# 2. buildctl-daemonless.sh — absent from catthehacker/ubuntu:act-24.04, the
# image this pool actually serves for hanzo-build-linux-amd64.
# 3. secrets.GIT_CLONE_TOKEN — exists on neither the repo nor the org. The
# Dockerfile needs a token here: GOPRIVATE=github.com/hanzoai/* means
# `go mod download` cannot read hanzoai/orm + hanzoai/sqlite without one.
# 4. kubectl patch app iam — the App CR is ArgoCD-managed with selfHeal, so
# the patch is reverted on the next poll, and the runner has no
# kubeconfig. Rollout is a reviewed tag pin in hanzoai/universe. Not here.
#
# The image is tagged by COMMIT SHA only. A semver tag that gets re-pushed
# leaves two digests behind one name, and with imagePullPolicy: IfNotPresent a
# node keeps whichever it cached first — that is how platform's v4.4.5 and
# v4.4.6 each came to mean two different builds on 2026-07-25. A SHA cannot move.
on:
push:
branches: [main]
# A `v*` tag is a RELEASE and must produce an image named after it. Without
# this line the builder answered only to branch pushes, so every `git tag`
# published nothing: v1.33.32 through v1.33.37 were all cut and none of them
# has an image. Production ran `sha-ba43c54` — a commit newer than the last
# BUILT release (v1.33.31) and older than two tagged ones — so the estate's
# IdP was running code no version names.
tags: ['v*']
workflow_dispatch:
concurrency:
group: image-iam-${{ github.ref }}
cancel-in-progress: true
jobs:
# THE GATE. Not decoration: the GitHub builders this file replaces gated their
# push behind tests (docker-deploy.yml's `docker` job declared
# `needs: [go-tests, go-build, frontend-build]`), so deleting them without a
# gate here would have traded two builders for one UNGATED builder — a strictly
# worse posture than the duplication it fixes. `make test` is the repo's single
# declared gate (`go test ./... -race -count=1`); it is named here rather than
# inlined so a human and CI keep running the identical command.
test:
runs-on: [hanzo-build-linux-amd64]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false
# GOPRIVATE keeps hanzoai/orm + hanzoai/sqlite off the proxy/sumdb; the
# rewrite is what lets `go mod download` actually read them. Same token the
# image build mounts as GIT_AUTH_TOKEN — one credential, two consumers.
- name: Authenticate private module fetches
run: |
git config --global url."https://x-access-token:${{ secrets.GH_PAT }}@github.com/".insteadOf "https://github.com/"
- run: make test
env:
GOPRIVATE: github.com/hanzoai/*
build:
needs: [test]
runs-on: [hanzo-build-linux-amd64]
steps:
- uses: actions/checkout@v4
# ONLY a `v*` tag publishes. A branch push still BUILDS — that is the
# check that main compiles and the image assembles — but it pushes
# nothing.
#
# It used to publish an immutable `sha-<7>` alongside, on the reasoning
# that both are traceable and only the tag is deployable. Traceable is not
# the bar: a registry that accumulates a tag per commit makes "what is
# released" a question you answer by reading git rather than by reading
# the registry, and it is how production came to run `sha-ba43c54` — a
# commit newer than the last built release and older than two tagged ones,
# so the estate's IdP ran code no version named. A release is a version.
# Nothing else earns a name in the registry.
#
# Three outputs, each with ONE meaning, because the single `tag` output
# they replace had two: a bare `v1.34.6` on a tag push but a WHOLE image
# ref on a branch push. Every consumer then had to know which case it was
# in, and the verify step below did not — it prefixed the repo again and
# asked the registry for `ghcr.io/hanzoai/iam:ghcr.io/hanzoai/iam:
# unpublished`, which cannot resolve, so it burned its six retries and
# failed the job. Every push to main was red, on a builder that had in
# fact built the image correctly.
#
# version — what the binary reports (`/iam version`)
# image — the full destination ref
# push — whether this ref is published at all
- id: meta
run: |
case "$GITHUB_REF" in
refs/tags/v*)
version="${GITHUB_REF#refs/tags/}"
image="ghcr.io/hanzoai/iam:${version}"
push=true ;;
*)
# An unpublished build is honestly `dev` — an empty VERSION would
# override the Dockerfile's `ARG VERSION=dev` with nothing and
# link a blank version into the binary.
version=dev
image="ghcr.io/hanzoai/iam:unpublished"
push=false ;;
esac
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "image=$image" >> "$GITHUB_OUTPUT"
echo "push=$push" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
with:
driver: docker-container
driver-opts: network=host
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USER }}
password: ${{ secrets.GHCR_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: ${{ steps.meta.outputs.push }}
provenance: false
tags: ${{ steps.meta.outputs.image }}
# The Dockerfile already carries `-X main.version=${VERSION}`, but its
# `ARG VERSION=dev` default was never overridden here — so every image
# this builder shipped reported `iam dev` from `/iam version` and could
# not name its own lineage. Measured on the live pod, 2026-07-27. Pass
# the release the tag names, so `/iam version` and the image tag are
# the same string with no second identifier to drift.
build-args: |
VERSION=${{ steps.meta.outputs.version }}
# The Dockerfile mounts this to rewrite github.com to an authenticated
# fetch for the private hanzoai modules. Without it `go mod download`
# fails on hanzoai/orm.
secrets: |
GIT_AUTH_TOKEN=${{ secrets.GH_PAT }}
# build-push-action can exit 0 before the manifest is resolvable at the
# registry. Prove the image actually pulls, so a green run always means a
# usable image rather than a future ImagePullBackOff.
#
# Only when something was published: a branch build pushes nothing, so
# there is no manifest at the registry to resolve and asking for one
# fails a run that did exactly what it should.
- name: Verify the pushed image resolves
if: steps.meta.outputs.push == 'true'
run: |
set -euo pipefail
img="${{ steps.meta.outputs.image }}"
for i in 1 2 3 4 5 6; do
if docker manifest inspect "$img" >/dev/null 2>&1; then
echo "$img is pullable"; exit 0
fi
echo "manifest not visible yet (attempt $i/6) — retrying"; sleep 5
done
echo "::error::$img not pullable after push"; exit 1
+155
View File
@@ -0,0 +1,155 @@
name: Sync from GitHub
# git.hanzo.ai is the build plane (.hanzo/workflows/image.yml cuts the image)
# but development also lands on github.com/hanzoai/iam. This job is what carries
# commits between them, and it is the ONLY one — the GitHub-side push nudge
# (.github/workflows/sync.yaml) was deleted with it.
#
# WHY PULL, NOT PUSH. Four mechanisms could in principle sync this repo; three
# provably cannot:
# - org webhook -> git.hanzo.ai/v1/sync, and cron.update_mirrors: BOTH are
# mirror-sync. This repo is mirror:false, and the forge rejects it outright:
# POST /v1/repos/hanzoai/iam/mirror-sync -> 400 {"message":"Repository is
# not a mirror"}. Those paths cover the ~2,300 mirror repos, never this one.
# - GitHub Actions push: needs a forge-WRITE token (FORGE_TOKEN) inside
# GitHub's secret store. It was never set here, so every run since the
# workflow was tightened failed `FORGE_TOKEN is not set` — 8 red runs on
# 2026-08-02 alone — while main drifted 2 commits / ~3h behind GitHub.
#
# So this repo had ZERO working sync paths, and image.yml never saw a commit.
#
# The pull needs no new secret: GH_PAT is already a git.hanzo.ai ORG secret for
# hanzoai (created 2026-07-19), so it is in scope for every repo here. The only
# credential is READ-only against GitHub, held in-cluster; the forge write is
# done by the runner's own workflow token against the instance URL that
# actions/checkout already uses. Nothing needs a forge-write key in GitHub.
#
# Fast-forward only: a divergence fails LOUDLY rather than force-pushing either
# side.
on:
schedule:
- cron: '*/10 * * * *'
workflow_dispatch: {}
concurrency:
group: sync-from-github
cancel-in-progress: false
jobs:
ff-main:
runs-on: [hanzo-build-linux-amd64]
steps:
- name: Checkout forge main (full history for the ancestry check)
uses: actions/checkout@v4
with:
fetch-depth: 0
# Persist the token-auth remote so the push below reuses it.
persist-credentials: true
- name: Fast-forward main from github.com/hanzoai/iam
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
if [ -z "${GH_PAT:-}" ]; then
echo "::error::GH_PAT is not set — refusing to sync silently."
exit 1
fi
git fetch --quiet "https://x-access-token:${GH_PAT}@github.com/hanzoai/iam.git" main
LOCAL="$(git rev-parse HEAD)"
REMOTE="$(git rev-parse FETCH_HEAD)"
if [ "$LOCAL" = "$REMOTE" ]; then
echo "in sync at $LOCAL"
exit 0
fi
if git merge-base --is-ancestor "$LOCAL" "$REMOTE"; then
echo "fast-forwarding $LOCAL -> $REMOTE"
git push origin "$REMOTE:refs/heads/main"
# A push made with the workflow token does NOT trigger other
# workflows (loop prevention) — so synced commits would never cut
# an image. Dispatch the builder explicitly; a real ff means real
# commits arrived, so bypassing its paths judgement is correct.
curl -fsS --max-time 20 -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"${{ github.server_url }}/v1/repos/${{ github.repository }}/actions/workflows/image.yml/dispatches" \
-d '{"ref":"main"}' \
&& echo "image dispatched" || echo "image dispatch failed (non-fatal — next direct push will build)"
elif git merge-base --is-ancestor "$REMOTE" "$LOCAL"; then
echo "forge is AHEAD of GitHub ($REMOTE ancestor of $LOCAL) — nothing to pull."
echo "(GitHub catch-up is a separate concern; never force from here.)"
else
echo "::error::main DIVERGED between GitHub ($REMOTE) and forge ($LOCAL) — refusing to force. Reconcile manually."
exit 1
fi
# Carry release tags, not just main.
#
# image.yml publishes ONLY for refs/tags/v* — its meta step sets push=true
# there and push=false everywhere else, naming the result `unpublished`.
# This job fetched and pushed `main` alone and dispatched with ref: main, so
# a `v*` tag cut on GitHub reached neither the forge nor the builder, and the
# dispatch it DID make could never publish. That is why iam.yaml already
# recorded "v1.34.5 was tagged in git and never built", and why v1.33.32..37
# have no images either. A release that builds nothing looks exactly like one
# that shipped, which is what makes it expensive to notice.
#
# The workflow token deliberately does not trigger other workflows (loop
# prevention), so pushing the tag is not enough: the build is dispatched
# explicitly on the TAG ref, the only ref image.yml will publish.
- name: Carry release tags to the forge and build them
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
git fetch --quiet --tags "https://x-access-token:${GH_PAT}@github.com/hanzoai/iam.git" 'refs/tags/v*:refs/tags/v*' || true
# ONLY tags NEWER than the forge's highest — not every tag it lacks.
#
# "Every tag the forge lacks" is unreachable as an invariant and wedged
# this job for days. The forge repo was created without history's tags,
# so ~160 of them (v1.0.0 … v1.31.x) are permanently "unpushed"; the cap
# below saw 160, exited 1 on EVERY run, and the tag step never reached a
# real release. That is why v1.34.5 and v1.34.8 were tagged and never
# built — starved behind ancient tags nobody wanted rebuilt.
#
# Anchoring on the forge's own highest tag makes the set converge: it is
# empty in the steady state, and after a release it holds exactly the new
# ones. Backfilling the history is deliberately NOT done here — pushing
# those tags would fire image.yml once per tag, which is the tag storm the
# cap exists to prevent.
# ONE round trip for the forge's whole tag list, then compare locally.
# Asking `git ls-remote` per tag is ~170 network calls against this repo's
# tag count: it is what made the step take a minute-plus, and it is 170
# chances for one transient failure to kill the job under `set -e`.
git ls-remote --tags --refs origin 'refs/tags/v*' 2>/dev/null \
| sed 's#.*refs/tags/##' | sort -V > /tmp/forge-tags || true
high=$(tail -1 /tmp/forge-tags)
echo "forge holds $(wc -l < /tmp/forge-tags | tr -d ' ') release tags; highest: ${high:-<none>}"
new=""
for t in $(git tag --list 'v*' | sort -V); do
# `if !` rather than `cmd && continue`: a bare failing AND-list is
# itself a failed statement, which `set -e` turns into an exit.
if grep -qxF "$t" /tmp/forge-tags; then
continue # already on the forge
fi
if [ -n "$high" ] && [ "$(printf '%s\n%s\n' "$high" "$t" | sort -V | tail -1)" = "$high" ]; then
continue # older than the forge's highest — history, not a release
fi
new="$new $t"
done
new=$(echo $new)
if [ -z "$new" ]; then echo "no unpushed release tags"; exit 0; fi
count=$(echo "$new" | wc -w | tr -d ' ')
# A cap, stated out loud. A tag storm has starved this CI before, and a
# silent truncation would read as "everything built".
if [ "$count" -gt 5 ]; then
echo "::error::$count unpushed tags ($new) — refusing to dispatch that many builds at once. Push and build them deliberately."
exit 1
fi
for t in $new; do
echo "pushing and building $t"
git push origin "refs/tags/$t"
curl -fsS --max-time 20 -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"${{ github.server_url }}/v1/repos/${{ github.repository }}/actions/workflows/image.yml/dispatches" \
-d "{\"ref\":\"$t\"}" \
&& echo " dispatched $t" || echo "::warning::dispatch failed for $t — tag is on the forge; build it by hand"
done
+63
View File
@@ -0,0 +1,63 @@
# Hanzo IAM — identity service (zip + orm).
# Multi-stage Go build → distroless-style alpine. Pure-Go (CGO_ENABLED=0);
# hanzoai/sqlite uses the modernc engine so no cgo/musl toolchain is needed.
FROM golang:1.26.5@sha256:3aff6657219a4d9c14e27fb1d8976c49c29fddb70ba835014f477e1c70636647 AS build
WORKDIR /src
# Cache the module graph before copying the source. iam imports private hanzoai
# modules (hanzoai/orm, hanzoai/sqlite), so mark them private (direct fetch, no
# sumdb) and — when a GIT_AUTH_TOKEN is mounted — rewrite github.com to an
# authenticated fetch so `go mod download` can read them. Same pattern as
# hanzoai/cloud; without the token it is a no-op (a public-only build still works).
ENV GOPRIVATE=github.com/hanzoai/*
COPY go.mod go.sum ./
RUN --mount=type=secret,id=GIT_AUTH_TOKEN \
if [ -s /run/secrets/GIT_AUTH_TOKEN ]; then \
git config --global url."https://x-access-token:$(cat /run/secrets/GIT_AUTH_TOKEN)@github.com/".insteadOf "https://github.com/"; \
fi && \
go mod download
COPY . .
# Per SCALE_STANDARD.md §2 — every Go production Dockerfile that emits JSON to a
# client builds with GOEXPERIMENT=jsonv2 (zip's edge JSON path).
ARG GO_EXPERIMENT=jsonv2
ENV GOEXPERIMENT=${GO_EXPERIMENT}
ARG VERSION=dev
# One binary: the server (/out/iam), pure-Go (CGO_ENABLED=0 + GOEXPERIMENT).
#
# It used to build a second, /out/migrate-v1 — the Phase-5 cutover migrator. That
# command was deleted in 144db2add ("iam: one IAM — drop v1 and the iam2 name")
# and this stanza was not, so every image build since has failed at
# `stat /src/cmd/migrate-v1: directory not found`. The Dockerfile is the only
# consumer that still referenced it.
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \
-o /out/iam .
FROM alpine:latest@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS STANDARD
LABEL org.opencontainers.image.source="https://github.com/hanzoai/iam"
LABEL org.opencontainers.image.title="Hanzo IAM"
# sqlcipher is the C SQLCipher 4.x shell the migrator's --wal-inclusive path drives
# to checkpoint each shard's uncheckpointed -wal before extraction; alpine ships
# SQLCipher 4.x (4.5.6 on the stable branch, 4.6.x on edge), whose v4 on-disk
# format matches the production data and the pure-Go codec. The server never calls
# it — it rides along so this ONE image serves both the server and the migrator Job.
# alpine is digest-pinned: this runtime base is in the migrator's DEK trust path (it
# provides the sqlcipher the raw decryption key is piped to), so a floating :latest is
# not acceptable for a one-shot migration of irreplaceable auth data (RED, v1.32.6).
RUN apk add --no-cache ca-certificates sqlcipher && update-ca-certificates \
&& adduser -D -u 1000 hanzo \
&& mkdir -p /data && chown -R hanzo:hanzo /data
USER 1000
WORKDIR /
COPY --from=build --chown=hanzo:hanzo /out/iam /iam
# Serves the IAM API over ZAP (:9653) + the HTTP edge (:8080). Bootstrap the
# config with --init-data /etc/iam/init_data.json (mounted from the same
# init_data ConfigMap the legacy iam uses; ${VAR} creds from the KMS-synced env).
EXPOSE 8080 9653
ENTRYPOINT ["/iam"]
CMD ["serve", "--db", "/data/iam.db", "--http", "http://:8080", "--zap", ":9653"]
+18 -17
View File
@@ -1,22 +1,23 @@
Hanzo IAM v2 — Proprietary Software License
Hanzo IAM
Copyright 2026 Hanzo AI, Inc. All rights reserved.
Copyright (c) 2024-2026 Hanzo AI, Inc.
This software and its source code (the "Software") are the confidential and
proprietary property of Hanzo AI, Inc. ("Hanzo"). The Software is licensed,
not sold, and only under an express written agreement signed by Hanzo.
Licensed under either of
Except as granted by such an agreement, no license, right, or interest in the
Software is conveyed. You may not use, copy, modify, merge, publish, distribute,
sublicense, reverse engineer, or create derivative works of the Software, in
whole or in part, by any means.
* Apache License, Version 2.0 (LICENSE-APACHE or
http://www.apache.org/licenses/LICENSE-2.0)
* MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL HANZO BE LIABLE
FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT, OR OTHERWISE, ARISING FROM OR IN CONNECTION WITH THE SOFTWARE.
at your option.
This is a clean-room implementation. It contains no Casdoor, Apache-2.0, or
other third-party licensed source code. Third-party dependencies are consumed
under their own licenses as declared in go.mod.
SPDX-License-Identifier: MIT OR Apache-2.0
Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in the work by you, as defined in the Apache-2.0 license, shall be
dual licensed as above, without any additional terms or conditions.
Provenance: this tree is original work. It carries no Casdoor source and no
other third-party licensed source code. The retired Casdoor-derived fork is
github.com/hanzoai/iam-v1; its versions are retracted in go.mod (see
TestCasdoorLineageRetracted). Third-party dependencies are consumed under their
own licenses as declared in go.mod.
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024-2026 Hanzo AI, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+429
View File
@@ -0,0 +1,429 @@
# LLM.md — hanzoai/iam
Canonical **Hanzo IAM** service: identity & access for the Hanzo cloud —
OpenID Connect / OAuth2 with PKCE, JWKS, UserInfo, SCIM 2.0, MFA/WebAuthn,
social federation. The server behind the `@hanzo/iam` SDK. A clean-room
rewrite on the Hanzo stack (`zip` over `hanzoai/orm`) — no Casdoor,
Beego, or xorm. The retired Casdoor fork is `hanzoai/iam-v1` (archived, do not use);
its versions are retracted here — see `TestCasdoorLineageRetracted`.
## License — `MIT OR Apache-2.0`
Dual-licensed at the user's option: `LICENSE-MIT` + `LICENSE-APACHE` (canonical
texts, never edited), `LICENSE` declares the pair. HIP-0130 puts `iam` in the OSS
core tier, so the previous "confidential and proprietary / All rights reserved"
LICENSE contradicted both the HIP and the repo's own public visibility. Every Go
file carries `// SPDX-License-Identifier: MIT OR Apache-2.0` instead of the old
`All rights reserved` header; `go.mod` has no license field, and this repo ships
no Cargo/npm/PyPI manifest, so the SPDX headers plus the three files are the
whole declaration.
Relicensing was Hanzo's alone to do: the tree is original work, not a fork
(`fork: false`, its root commit is its own, and no `v1.*` Casdoor tag is an
ancestor of `main`). Note the Casdoor-lineage tags `v1.0.0``v1.31.37` are still
present on this remote even though `go.mod` says they "now live at
`hanzoai/iam-v1`" — anyone checking out one of those tags gets Apache-2.0
Casdoor code under this repo's name. The retraction covers module resolution,
not `git checkout`.
## Role in the model
This is a `hanzoai/<product>` service (impl lives here, DRY — one place). It is
NOT a language SDK. Clients authenticate via the `@hanzo/iam` SDK, never by
hand-rolling OAuth. Full SDK model: `~/work/hanzo/SDK-ARCHITECTURE.md`.
## Build & run
- `go build ./...`
- `go run . serve --init-data init_data.json` (SQLite default; `--store sqlite|sql|datastore`)
- `go run . compare --legacy postgres://…/iam` (needs `-tags migration`)
- Image: `ghcr.io/hanzoai/iam`. Go 1.26.
## Embedding — a host GRAFTS the app, it does not adapt a handler
Two entry points, and they are different verbs for different situations:
| call | what it does | when |
|---|---|---|
| `server.NewApp(db) *zip.App` | the whole IAM surface as a self-contained app | a host composing IAM in process: `app.Graft(iamserver.NewApp(db))` |
| `server.Route(app, db)` | registers IAM's routes ONTO the host's app | only when the host genuinely wants IAM's routes co-mingled with its own. It also brings IAM's root-level routes onto the host, which is what shadowed a host console once |
`server.Handler(db) http.Handler` is **deleted** (was: `adaptor.FiberApp(NewApp(db).Fiber())`).
It existed so a host could hang the whole surface on one wildcard —
`app.All("/v1/iam/*", zip.AdaptNetHTTP(iamserver.Handler(db)))` — and that
adapter is where IAM's knowledge died. `AdaptNetHTTP` takes an `http.Handler`
and returns a closure, so the App went in and a bare function came out, and
IAM's **94 typed ops** went with it. hanzoai/cloud published five wildcard path
keys and 35 placeholder operations where 78 real paths and 94 typed operations
were — no schema, no MCP tool, no CLI command, no SDK method for any of them.
`zip.Graft` (zip v1.18.16) is the composition that keeps them: the host's router
learns IAM's route patterns AND its op registry, while IAM's own router keeps
IAM's behaviour — its `Use(authz.Guard)` seam, its error handler, its config.
Serving is unchanged and strictly cheaper (no net/http round trip). IAM's
`Authorizer` still runs on IAM's ops; the host never re-authorizes them under
its own rules. Named types are published as `iam.<Type>`, because a composed
document carries more than one app's `Application`.
**Liveness is not IAM's.** `/healthz`, `/readyz` and `/metrics` are zip's ops
surface (HIP-0119 §1) — a SECOND listener the DEPLOYMENT brings up when it names
`OPS_PORT`, never the public one. IAM used to register `/healthz` on its public
group; that was hand-rolling a path the framework owns, on the wrong listener,
and it is also what made IAM un-composable: a host registers `/healthz` as the
HOST's, because it must answer while every subsystem is still cold. Two
claimants on one liveness address is what once served `{"binary":"iam2"}` out of
a shared binary.
## Endpoints (HIP-0111 — /v1 only, no /api, no vendor verbs)
`/.well-known/openid-configuration` · `/v1/iam/.well-known/jwks` ·
`/v1/iam/oauth/{authorize,token,introspect,revoke,userinfo,logout,callback}` ·
`/v1/iam/oauth/device` + `/v1/iam/oauth/device/info` (RFC 8628; `info` names the
client a pending `user_code` belongs to, session-gated, code in the BODY because
a request line reaches access logs) · `/v1/iam/scim/v2/Users`. PKCE `S256` always; `client_id` = `<org>-<app>`.
Brands set `serverUrl`: hanzo→iam.hanzo.ai, lux→lux.id, zoo→zoo.id,
bootnode→id.bootno.de, pars→pars.id (white-label by domain).
## A principal is `owner`/`name` — org and USERNAME, on every surface
**The rule.** `owner` is the org. `name` is the USERNAME (`<name>` of
`<owner>/<name>`). A display name never appears in `name`, in a token or in
UserInfo; it has its own claim, `displayName`. `preferred_username` is the
OIDC-standard spelling of the same username and is sourced from the same field,
so the two cannot drift.
**Why.** `hanzo auth login` files its credential under the token's own
`owner`/`name`, so those claims ARE the principal downstream believes it holds.
`userClaims` computed `name = DisplayName, else Name` — OIDC's display reading of
`name`, inherited from the v1 `Userinfo` struct and present since the in-tree
server was written (`73b7ef63e`). Measured on iam.hanzo.ai 2026-07-30: a login as
account `z` minted `name: "Zach Kelling"` and the CLI filed `hanzo/Zach Kelling`,
an account that does not exist. cloud's money path had already paid for the same
reading — it addresses a wallet `<org>/<username>`, addressed `hanzo/Zach Kelling`
and 402'd every completion while the balance sat in `hanzo/z`. `5c0ea823f`
answered that by ADDING `preferred_username` and deliberately leaving `name`
display-sourced, which gave the username a home without evicting the display name
from the claim consumers actually read; the CLI then hit the wall from the other
side. One address for a principal beats two spellings that disagree, so `name` is
the username and OIDC's display reading of it is the thing we diverge from.
**One resolution, one claim builder.** Three mint paths — the code/refresh/password
grant, the console's issue-user-token, and the RFC 8693 exchange — had each
SEPARATELY written the `DisplayName, else Name` fallback, so fixing one would have
left two. `identityOf` is now the only user→claims resolution and `Signer.claims`
the only place an `Identity` becomes a claim set. The values also stopped
travelling as six adjacent positional strings: two of them are human-readable and
were therefore swappable at the call site, they WERE swapped on all three paths,
and it type-checked (the wallet harness had lost a scope into the username slot
the same way). UserInfo answers identically — it and the token describe one
principal, and a client holding either must not get two names for it.
## Usernames — one rule, at the write
`schema.Username`: trim, lowercase, `^[a-z0-9][a-z0-9._-]{0,62}$`. Normalization
settles case and padding; everything else is REFUSED rather than rewritten,
because quietly turning what someone typed into a different principal is the
failure being avoided. One character is legal (the account this was written over
is `z`); a leading digit is legal (nothing resolves a principal numerically).
Ten entry points reach a user row and exactly ONE used to validate the name it
wrote. The rule now lives at `users.Create` — the choke point six of them share —
plus the three that write through orm directly (bootstrap's first-admin seed, the
wallet identity, the onboarding credential). `CreateInput.AuthzTarget` normalizes
too, so the pair AUTHORIZED is the pair STORED. Service accounts keep only what is
theirs: `<org>-` binding and segmentation.
**Social signup derives from the ADDRESS, never the profile.** `schema.Handle`
takes the email local part and refuses a string with no `@` or a local part with
whitespace — without both, "Zach Kelling" is a local part whose space gets dropped
and the profile name silently becomes the username `zachkelling`. Dedupe is a
numeric suffix (`z`, `z2`, `z3`), replacing a random 8-hex suffix on every name
that made collisions impossible by making every username unrecognisable.
**Case does not make a second person, and stored names are NOT rewritten**
renaming moves real principals. `store.GetUserByName` resolves exact, then folded,
then over the org for a legacy mixed-case row, and FAILS CLOSED on an ambiguous
fold (the rule `GetUserById` already applies to a duplicated subject), so whoever
registered "ALICE" alongside "Alice" is never resolved as the other. `users.lookup`
goes through it rather than repeating the query — restating it is how Create's
uniqueness check stayed case-SENSITIVE while the rule it guards is not.
## Org scope — HONOURED or REFUSED, never silently reinterpreted
**The rule.** A request that NAMES an organization gets that organization's data
or an error. It never gets a different organization's data. `authz.Scope` is the
one place it lives; all 17 org-scoped call sites resolve their owner there.
| principal | `?owner=` | result |
|---|---|---|
| SuperAdmin (org `admin`) | anything | honoured; empty = every tenant |
| anyone else | absent | own org (unstated ≠ reinterpreted) |
| anyone else | its own org | honoured |
| anyone else | **any other org** | **403, no rows** |
| anyone else, `p.Org == ""` | anything | **403** (no org ⇒ no scope; `""` used to mean *no filter* = every tenant) |
**Why.** `Scope` used to `return p.Org` for ANY owner. Measured in production
2026-07-28 with the `hanzo-console` credential (home org `hanzo`): `?owner=lux`,
`?owner=zoo` and `?owner=nonexistent-org-xyz` each answered `200 {"status":"ok"}`
with 262 **`hanzo`** accounts. Nothing in the code, the `status` field, the `msg`
or the count said the filter had been dropped, so a fabricated org was
indistinguishable from a real one *and* from your own. No rows escaped IAM, so it
was not a confidentiality breach here — it was **misattribution**, which is worse
in one specific way: you believe you hold tenant B while holding tenant A. It
nearly caused a production purge of the wrong tenant. Downstream it *was* a leak:
cloud's IAM edge (`cloud/iam_edge.go`) validates `?owner=` against the calling
tenant and then forwards it under ONE confidential client, so every tenant's team
page asked for its own org and was served the edge credential's org.
**Not an existence oracle — by construction, not by care.** The refusal is decided
from the verified principal alone and never touches the store, so `lux` (real),
`built-in` (reserved) and `nonexistent-org-xyz` (invented) are the same comparison
and the same bytes; the message names the CREDENTIAL's org, never the requested
one. Same collapse cloud's per-org KMS store makes: every spelling you may not
have routes to ONE existence-independent answer. It differs only in *which*
answer — KMS reads the org from the token, so absence is its only observable and
it answers 404; here the org is a stated parameter, so there is a decision to
report and reporting it is the point.
**Cross-tenant reach exists only where a grant says so**, and a grant HONOURS the
org it names (returning that org's real data, correctly attributed) — it never
substitutes:
- **SuperAdmin** — every entity. The only unrestricted cross-tenant scope.
- **`CapOrgAdmin`** — the organization REGISTRY only. Brand consoles create
customer orgs during onboarding and read `Organization.Founder` to resume a
partial one, so registry-wide reach is load-bearing, not incidental.
So `get-users` and `get-organization` now **agree on the only question carrying a
secret**: for every principal without a cross-tenant grant both refuse a foreign
org existence-independently, so neither is an oracle. For a `CapOrgAdmin` holder
org existence is *not* a secret — it can create orgs and read `Founder`, so hiding
reads from it would be theatre. What can no longer happen anywhere: **being handed
org A's rows in answer to a request that named org B.**
**A rewrite is not a safe answer, only an unsampled one.** The old SCIM guard
(`scim/read_scope_test.go`) proved foreign-exists and foreign-missing were both
404 and called the oracle closed. It was: the re-pin turned `/Users/orgb/bob` into
a lookup of `hanzo/bob`, absent. Seed a `hanzo/bob` — a name every tenant has —
and the same request returns **200 carrying hanzo's bob under orgb's URL**, and
`PATCH active:false` then deactivates a hanzo employee. Pinned by
`TestRed_scimGet_foreignIdNeverResolvesToASameNamedLocalUser`.
**Divergence still open (needs a decision, do not "fix" by widening).** The legacy
verb lister goes through `Scope`; the native noun lister (`organizations.List`)
filters `in.Owner` under the Guard's authorization. For a `CapOrgAdmin` app,
`get-organizations?owner=admin` is now a 403 while `/v1/iam/organizations?owner=admin`
returns every org row (masked). Before this change it was 403-vs-a-silently-EMPTY
list, so nothing regressed — but one policy still answers two ways on two
spellings. Unifying it changes a documented capability's blast radius: decide it
deliberately, in `authorize()`, not by opening the legacy lister.
## API keys — one entity, one plural noun, and the SCOPE is what differs
`internal/keys`, entity `keys`, routes `/v1/iam/keys{,/get,/update,/delete}`.
**Plural on every op** (like `users`): `authz.entityOf` reads the FIRST path segment
as the entity, so serving the list at `keys` and the writes at `key` made two entity
strings for one entity — and any capability keyed on it was dead on whichever half
you did not name. Same defect `entityNoun` fixes for the legacy verb spellings.
`Scope` is the ACCESS CLASS, fixed at create (an update that could flip it would
blank a secret and open the ingest door):
| scope | halves | resolves to | door |
|---|---|---|---|
| `""` (secret) | `pk-` + `sk-` | the USER | `get-user?accessKey=` (`CapKeyResolve`) |
| `publish` | `pk-` only, NO secret | just the ORG | `resolve-key` (`CapPublishableResolve`) |
**The publishable key had no producer until 2026-07-28.** The model, the resolver
(`store.PublishableKeyByAccessKey`) and the ingest door all existed and nothing
minted one. It is now a FIELD on the one mint:
`POST /v1/iam/mint-user-keys?id=<owner>/<name>&type=publishable|secret` (default
secret; unknown type → 400), same for `revoke-user-keys`. `keys.NameFor(scope)` maps
scope → row name (`cloud-api` / `publishable`), so the two are separate rows and
rotating a browser key does not revoke the API key.
**Every read is masked.** `schema.Key.Mask()` blanks `AccessSecret` and keeps
`AccessKey` (a `pk-` is public and its holder needs it). Before it, the key list
handed every reader every secret in the org, which made read AUTHORIZATION stand in
for redaction. The secret is revealed ONCE, by `create`. `capFor("keys")` =
`CapKeyMint`: the authority that already mints a credential may read the set it
manages.
`MintUserKey` writes a `schema.Key` ROW because that is the only thing the resolvers
read. Stamping it on `schema.User.AccessKey` authenticated nobody — nothing resolves
that field, and it is not a credential.
**Two key shapes, estate-wide.** `pk-` is publishable and `sk-` is secret; there is no
third. `store.UserByAccessKey` resolves a live `sk-` (pinned to the key row's own
tenant), refuses a `pk-` as `key_wrong_door` — a real credential at the wrong door —
and answers `key_unknown` for everything else, which is what renders the actionable
"mint a new one at cloud.hanzo.ai/keys". A value carrying any other prefix is not a
key, so it takes that same unknown path rather than a branch of its own.
## Refresh — confidential is a property of the GRANT, and a lifetime must be SAID
Two independent defects made `refresh_token` unusable for every client that signs
in through a browser, so a session died at the access token's expiry and the user
logged in again. Measured on `hanzo-cli` 2026-07-31: a live refresh answered
`401 invalid_client`, and the refresh token was already expired anyway.
**Client auth.** `authorizationCodeGrant` has a documented relaxation — a
registration that HOLDS a secret still serves a public PKCE surface (`hanzo-cli`,
and every `@hanzo/iam` SPA whose secret exists for a backend path), so a code
exchange that presents no secret is authenticated by PKCE instead.
`refreshTokenGrant` did not have it and demanded the secret unconditionally: the
client completed the exchange without one, cannot acquire one, and is refused the
moment it tries to renew. The fact is now recorded where it belongs — on the
GRANT, `schema.Token.PublicGrant`, set at establishment and carried across
rotation (drop it and only the FIRST refresh works). It never widens: a grant
established WITH the secret still needs it, and a presented secret is always
verified.
**Lifetime.** `refreshTTL` falls back to `appTTL` when `RefreshExpireInHours` is
unset — v1 parity, and dead on arrival: the refresh token expires at the same
instant as the token it renews. Nothing could say otherwise, because the upsert
body carried no lifetime field at all. `expireInHours` / `refreshExpireInHours`
now travel document → `provision.App` → upsert → model under ONE name, as
POINTERS on the wire so an omitted lifetime PRESERVES (a plain float would reset
every app on every converge). `provision.checkLifetimes` REFUSES a refresh
lifetime that does not outlive the access lifetime, measured against
`schema.DefaultExpireInHours` when the access lifetime is unstated — so the state
`hanzo-cli` shipped in cannot be declared again.
**Which half bit whom** (measured over all 286 live applications on hanzo.id).
Most first-party clients already carried `expireInHours: 168` +
`refreshExpireInHours: 720` from the v1 era, so for `hanzo-cloud`, `hanzo-chat`,
`hanzo-platform`, `hanzo-world` the LIFETIME was fine and only the CLIENT-AUTH
half was broken — they held a 30-day refresh token they could not spend. One fix
unblocks all of them: driven live after the change, each does code→token 200 then
refresh 200 with a new access token, presenting no secret at either step.
`hanzo-cli` was the rare client with BOTH lifetimes at 0, which is why it was the
one that hurt. Still at 0, and therefore still dead on arrival: `hanzo-mcp` (now
declared, same as the CLI), `hanzo-git`, `hanzo-zrok`, `hanzo-admin`, and every
auto-created per-signup `app-<email>` client. The fix is one line per app in that
org's provision document; it is deliberately NOT a changed global default,
because session lifetime is POLICY and this mechanism ships no policy.
## Device grant — a CLI holds NO secret, and ROPC must then refuse it
`hanzo auth login` died on `invalid_client: client authentication failed`
straight out of `POST /v1/iam/oauth/device`, for every client, so nobody could
sign in from a terminal.
**Cause.** `deviceHandler` requires the stored secret from any registration that
HAS one (RFC 8628 §3.1 → 6749 §3.2.1), and every Hanzo client was declared
`type: confidential` in the provision document — deliberately, because
`client_credentials` and the password grant authenticate with that secret and a
public upsert DELETES it (`bootstrap.resolveSecret`). So all 12 held one, and a
CLI can never present one. The code exchange survived the same registration
shape only because `authorizationCodeGrant` skips client auth when a PKCE
challenge is present; the device grant carries no challenge to skip on, so it
had no such escape. `invalid_client` distinguishes the two cases — `client_id is
invalid` means unknown, `client authentication failed` means known-and-holds-a-
secret — which is how the cause was read straight off the wire.
**Fix.** `hanzo-cli` is `type: cli` in the universe provision document: PUBLIC,
no stored secret, loopback redirects per RFC 8252 §7.3, with the device grant
declared through the additive `grants:` field rather than added to
`grantsByType[cli]` — same reason `redirects` is additive, a type default would
silently hand RFC 8628 to every future CLI client in every org. No image was
needed; `make iam-provision` converged it.
**The rule this forced.** Going public silently opened ROPC. `passwordGrant`
gates on the `enablePassword` FLAG, not on `grantTypes`, so removing `password`
from the document changed nothing — and the grant had a legacy-parity relaxation
that let a public client through, carried so console/chat would not 401 during
the cutover. With no stored secret and no PKCE challenge and no human approval
step, "public" there means anyone who knows the client_id can post a username and
password. `passwordGrant` now REFUSES a client with no stored secret. The
relaxation was dormant (every live registration is confidential and takes the
secret path), so nothing that worked broke. The rule lives on the GRANT, not in a
document, because registration shape must not be able to open a credential
surface — the same lesson as `Token.PublicGrant` above, in the other direction.
**One client id.** `hanzo-cli` is the id BOTH CLIs authenticate as — Rust
`hanzoai/cli` (`src/iam/oauth.rs` `CLIENT_ID`) and the Go control CLI
(`hanzoai/cloud` `cli/cli.go` `defaultClientID`). The Go one had been borrowing a
different first-party client per flow (`hanzo-app` for device, `hanzo-console`
for password and refresh); besides being unregistrable, that guaranteed renewal
could never work, because a device_code is redeemable only by the client it was
issued to and a refresh token was being presented under a different id.
## Key entry points
- `main.go` — cobra root (`serve` / `compare` / `version`); `server/server.go` route registration.
- `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).
## CORS — two questions, and the edge answers a third
`internal/cors` decides two things about an `Origin`, and conflating them is a
privilege escalation:
1. **May it read?** The DERIVED allowlist — any origin some application already
registered a `redirect_uri` on. A tenant admin can write into this set, so it
only ever grants reads of answers that carry no ambient authority.
2. **May it send the SSO cookie and read the answer?** `IAM_SESSION_ORIGINS`, a
comma-separated list of **exact** origins. Never a suffix, never derived from
(1). A malformed entry **panics at route registration**, which is the one
place both `iam serve` and the cloud binary that embeds IAM pass through.
The `[cookie]` paths are exactly the five sites `hanzoai/js-iam`
`src/browser.ts` sends `credentials: "include"` to — `POST /v1/iam/login`,
`GET /v1/iam/web3/nonce`, `POST /v1/iam/web3/verify`, `POST /v1/iam/oauth/revoke`,
`POST /v1/iam/oauth/logout`. A browser DISCARDS a credentialed response that
lacks `Access-Control-Allow-Credentials`, so withholding it on one of them
withholds no privilege — it breaks the call. Only `POST /v1/iam/login` actually
spends the cookie (the single-sign-on branch mints an authorization code from
it); revoke, logout and both wallet legs never read or clear it, so the SDK's
`credentials: "include"` there is inert and the SDK is where that gets fixed.
**`logout` not ending the portal session is a real open defect**, not a CORS one.
`IAM_TRUSTED_ORIGIN_SUFFIXES` is a DIFFERENT list, read nowhere in this repo.
Never wire it to question 2: the fleet serves `<slug>.hanzo.app` as
customer-published sites, so a suffix read of it would name every customer page
a first-party console.
**A proxy can override all of this.** Measured 2026-08-01: hitting the cluster
ingress directly with `Host: iam.hanzo.ai` returns `server: zip`, `Vary: Origin`
and no ACAO; the same request through Cloudflare returns
`Access-Control-Allow-Credentials: true` plus the reflected origin. The
`hanzo.ai` zone reflects a suffix set (`hanzo.ai`, `hanzo.app`, `hanzo.bot`,
`lux.network`, `zoo.ngo`, `zoo.network`, `pars.ai`, `bootno.de`, `ad.nexus`) and
the `hanzo.id` zone reflects **any** origin. `*.hanzo.ai` is SAME-SITE with
`iam.hanzo.ai`, so `SameSite=Lax` does not withhold `hanzo_session` — that is the
reachable path. No Go change closes it; the edge rule has to be narrowed, and
this package must answer correctly FIRST or the narrowing breaks every login.
## OPEN P0 — self-service signup enrolls strangers in the staff tenant
`hanzo-console` / `hanzo-cloud` / `hanzo-gitea` / `hanzo-bot` carry
`enableSignUp: true` with `organization: hanzo` (universe
`infra/k8s/iam/init_data.json`). `signupHandler` files the new user under
`f.Organization`, and `store.MemberOrgRefs` emits `user.Owner` as the HOME entry
of the `orgs` claim — so **anyone on the internet who signs up at hanzo.id is a
signed `member` of the `hanzo` tenant** until they onboard. Cloud reads that
claim as tenancy (correctly — it is the signed membership set), so a
60-second-old anonymous account gets, verified against production 2026-07-28:
- `/v1/projects`, `/v1/sites` — read **and** write **and** DELETE (a probe
project was created and deleted inside org `hanzo`);
- `/v1/git/repos`**121 private repos** listed (`cloud`, `universe`, `ci`,
`console`…), and `/v1/git/repos/<name>/tree` returns their file entries;
- `/v1/crm/contacts` — read + write (PII).
Refused: KMS, `/v1/admin/authors` (SuperAdmin), `/v1/iam/keys`; the billing
ledger is per-account so no money crosses.
The asymmetry is the whole bug, and this repo already states the rule that
closes it — `provision` (onboard.go) refuses an existing org the caller did not
found ("an existing one is refused by the create-conflict check"), while
`signup` happily joins one. Two doors to the same end state, one locked.
NOT fixed unilaterally: every candidate fix trades off badly without an owner
decision. Turning `enableSignUp` off on those apps closes it instantly but stops
customer signup; note also that `server.Seed` is **new-only**, so editing
`init_data.json` does NOT change a live app row — remediation must go through
`update-application`, which then needs a GitOps record. The durable fix is to
stop reading storage `Owner` as membership (`MemberOrgRefs`), with
`BackfillMemberships` already writing the explicit rows that would replace it.
Decide, then do it in ONE place.
## Brand rules (hard)
- Never call Hanzo an "LLM gateway"; never position vs LiteLLM. Full AI cloud, not a proxy.
- `/v1/` only, never `/api/`. Zen models are our own family — never name upstream models.
- White-label by domain; never the Hanzo mark on a Lux/Zoo surface.
-80
View File
@@ -1,80 +0,0 @@
# IAM v2 Migration
Casdoor fork (`hanzoai/iam`: Beego + xorm, Apache-2.0) → `hanzoai/iam2`:
clean-room, proprietary, on the native Hanzo stack. Phased and drift-gated —
the identity binary is never rewritten in one shot.
## §1 Why
`hanzoai/iam` is a fork of Casdoor. Every file carries `Portions Copyright The
Casdoor Authors` under Apache-2.0. It couples us to xorm's fluent API, Beego's
router, and an upstream we do not control. `iam2` is original expression on our
own framework — we own it, and it collapses to one way of doing each thing.
## §2 Stack contract
- **HTTP** — `github.com/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** — `github.com/hanzoai/orm` (typed Go records + KV cache) over
`github.com/hanzoai/base` (collections, realtime, replicate-to-S3). SQLite —
never Postgres for the local/default path.
- **Authz** — `github.com/hanzoai/authz`, one canonical policy engine, called
over ZAP RPC. No in-process copy.
- **OIDC/OAuth2** — in-tree port (no external OIDC library). ML-DSA-65 hybrid
JWT signing; JWKS cache.
- **Inter-service** — `github.com/luxfi/zap` binary RPC. HTTPS is the external
edge only; all service↔service is ZAP (platform law).
## §3 Phases
| Phase | Scope | Gate to exit |
|------:|-------|--------------|
| 0 | Scaffold: Base boots, v2 collection namespace claimed, `/v1/iam/v2/health`, `compare` CLI. | Binary builds and boots. |
| 1 | Entity schemas (fields + indexes) + CRUD handlers on `zip` + `orm`, per resource. | Per-entity field parity vs v1; handlers pass tests. |
| 2 | In-tree OIDC/OAuth2 server: `/v1/iam/oauth/*`, `/v1/iam/.well-known/*`, JWT (ML-DSA-65), JWKS. | Token/userinfo/authorize parity vs v1. |
| 3 | Authz via `hanzoai/authz` over ZAP RPC; retire in-process authz. | Policy decisions match v1. |
| 4 | Parity: run `iam2 compare` continuously against a v1 read replica. | **drift = 0** (or a known v1-only residual v2 does not model). |
| 5 | Cutover: import v1 data, promote `iam2` to the `iam` mount, archive the fork. | Green in prod; rollback path proven. |
Phases 04 are additive and non-destructive — v1 stays live and authoritative
until Phase 5. Routes carry a `/v1/iam/v2/*` prefix through the transition so
they are orthogonal to the live `/v1/iam/*` mount; the prefix collapses at §6.
## §4 Domain model (v1 xorm table → v2 Base collection)
Thirteen identity entities. Field-completeness is mandatory — a dropped column
is lost auth data.
| v1 table (xorm) | v2 collection (Base) | Base kind |
|-----------------------|------------------------|-----------|
| `user` | `users` | auth |
| `organization` | `organizations` | base |
| `application` | `applications` | base |
| `provider` | `providers` | base |
| `role` | `roles` | base |
| `permission` | `permissions` | base |
| `cert` | `certs` | base |
| `key` | `keys` | base |
| `webauthn_credential` | `webauthn_credentials` | base |
| `session` | `sessions` | base |
| `token` | `tokens` | base |
| `record` | `audit_logs` | base |
| `invitation` | `invitations` | base |
**Deliberately not modeled by iam2** (they belong to commerce/other services,
not identity): `payment`, `plan`, `product`, `subscription`, `pricing`,
`model`, `adapter`, `enforcer`, `syncer_*`.
## §5 Drift gate
`iam2 compare --legacy <v1-dsn>` opens the v1 database **read-only** (only
`SELECT COUNT(*)`), opens the v2 Base store read-only, and prints per-entity
row counts plus absolute drift. This is the gate that keeps cutover honest:
drift must be 0 before Phase 5 import goes live. No writes, no DDL, ever.
## §6 Cutover
At Phase 5, with drift proven 0: import v1 rows into v2 collections, drop the
`/v2` route prefix so `iam2` answers on `/v1/iam/*`, repoint the `iam` image /
operator CR / DNS to `iam2`, and archive `hanzoai/iam`. One identity binary,
one way, no Casdoor.
+34
View File
@@ -0,0 +1,34 @@
# The test gate. One command, run identically by a human and by CI.
#
# Not a bare `go test ./...`: that reuses cached PASS results, so a stale build
# can report green for code you just changed, and it runs without the race
# detector, which is where this repo's store and session defects actually show
# up. -count=1 defeats the cache; -race is the point.
.PHONY: test build fmt vet generate
# Prose reaches the document ONLY through this step. Go drops comments at compile
# time, so an operation's description cannot be read off the running binary: the
# doc comment on each typed handler is lifted here into the package's
# zipdoc_gen.go, which registers it with zip.Describe at init. That file is
# COMMITTED, because a consumer building this module does not run go generate.
#
# Skipping it does not fail loudly — it publishes an operationId and silence, in
# the OpenAPI document, the MCP tool list and every generated client and CLI. So
# `test` runs zipdoc -check first: a doc comment edited without regenerating is a
# red build, not a quietly stale artifact.
generate: ## Lift every typed handler's doc comment into its zipdoc_gen.go.
go generate -run zipdoc ./...
test: ## Run the full suite — the gate. Everything must be green to ship.
@set -e; for d in $$(grep -rl '^//go:generate go run github.com/zap-proto/zip/cmd/zipdoc' --include='*.go' . | xargs -n1 dirname | sort -u); do (cd $$d && go run github.com/zap-proto/zip/cmd/zipdoc -check) || { echo "$$d/zipdoc_gen.go is stale — run: make generate"; exit 1; }; done
go test ./... -race -count=1
build: ## Build every package.
go build ./...
fmt: ## Format.
go fmt ./...
vet: ## Vet.
go vet ./...
+106 -21
View File
@@ -1,38 +1,123 @@
# Hanzo IAM v2
# Hanzo IAM
Proprietary identity service for the Hanzo platform. A clean-room rewrite of the
identity layer on the native Hanzo stack — **no Casdoor, no Beego, no xorm**.
**Identity & access for the Hanzo cloud — OpenID Connect / OAuth2 with PKCE, standards only.**
The predecessor (`hanzoai/iam`) is a fork of Casdoor (Apache-2.0). `iam2` owns
its source outright: original expression on our own framework and ORM, so the
identity binary carries no upstream copyright or license obligations.
![Go 1.26](https://img.shields.io/badge/Go-1.26-00ADD8) ![Standards](https://img.shields.io/badge/standards-OIDC%20%C2%B7%20OAuth2%20%C2%B7%20SCIM%202.0-informational) ![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue)
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`](https://github.com/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`](https://github.com/zap-proto/zip) | Typed handlers (`zip.Get[In,Out]`) on the `zap-proto/fiber/v3` engine; specificity routing; OpenAPI 3.1 |
| Storage | [`hanzoai/orm`](https://github.com/hanzoai/orm) over [`hanzoai/base`](https://github.com/hanzoai/base) | Typed Go records + KV cache; collections + realtime + replicate-to-S3; SQLite (no Postgres) |
| Authorization | [`hanzoai/authz`](https://github.com/hanzoai/authz) | One canonical policy engine, called over ZAP RPC |
| OIDC / OAuth2 | in-tree | ML-DSA-65 hybrid JWT; no external OIDC library |
| Inter-service | `luxfi/zap` | Binary RPC. HTTPS is the external surface only |
| Concern | Component | Notes |
|---|---|---|
| HTTP | [`zap-proto/zip`](https://github.com/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`](https://github.com/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`](https://github.com/hanzoai/authz) | One canonical policy engine, called over ZAP RPC |
| Inter-service | [`zap-proto`](https://github.com/zap-proto) | Binary RPC service↔service. HTTPS is the external edge only |
## Status
## Endpoints — RFC / OIDC standard (no `/api/`, no vendor verbs)
Phase 0. The binary boots Base, registers the v2 collection schema, serves
`/v1/iam/v2/health`, and ships a read-only drift CLI. Cutover off `hanzoai/iam`
is gated on `iam2 compare` reading **drift = 0** against a v1 replica.
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`.
See [MIGRATION.md](./MIGRATION.md) for the full phased plan.
| 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/sql` over ZAP.
- `datastore``hanzoai/datastore` over ZAP (ZAP-native persistence +
snapshots, zero code change).
## Build & run
```sh
go build ./...
go run . serve # Base + v2 schema + /v1/iam/v2/health
go run . compare --legacy postgres://…/iam # read-only v1 ↔ v2 drift report
# 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](./MIGRATION.md) for the phased plan and the full RFC surface
table.
## License
Proprietary — see [LICENSE](./LICENSE). Confidential to Hanzo AI, Inc.
Dual-licensed under [MIT](./LICENSE-MIT) or [Apache-2.0](./LICENSE-APACHE) 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](https://hanzo.ai) · [docs.hanzo.ai](https://docs.hanzo.ai)
**SDKs in every language** — [Python](https://github.com/hanzoai/python-sdk) (flagship) · [TypeScript](https://github.com/hanzo-js/sdk) · [Go](https://github.com/hanzo-go/sdk) · [Rust](https://github.com/hanzo-rs/sdk) · [C++](https://github.com/hanzo-cpp/sdk) · [Swift](https://github.com/hanzo-swift/sdk) · [Kotlin](https://github.com/hanzo-kt/sdk) · [umbrella](https://github.com/hanzoai/sdk)
+97
View File
@@ -0,0 +1,97 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
// Package feature is the seam enterprise capabilities plug into. A module
// (hanzoiam/saml, hanzoiam/ldap, …) implements Feature and reads/writes the core's
// identity via the injected Store — so it shares ONE identity store with the core
// and never carries a second copy. The core NEVER imports a module; dependency
// flows one way (module → feature).
//
// What belongs OUT here is PROVENANCE, and only provenance: this tree is
// clean-room (see TestCasdoorLineageRetracted), so a capability whose
// implementation is Casdoor-derived stays in a hanzoiam/* module carrying its own
// Apache-2.0 attribution — SAML's IdP protocol code and LDAP's directory server
// both are. A capability written fresh belongs IN the core, where the Guard,
// authz.Scope and authz.Can cover it without a module having to reimplement them:
// SCIM is served there (internal/scim, at /v1/iam/scim/v2), never through this seam.
//
// A module gets NO authorization for free. IAM's Guard is anchored in IAM's own
// subtree (internal/routes.Route), so a module that registers anywhere else is
// unauthenticated — the one thing a Feature must get right on its own.
package feature
import (
"context"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/pkg/model"
)
// Store is the identity surface a feature needs — the union of the calls the
// copied the legacy surface code makes (object.* → store.*). The core implements it over its
// orm store (internal/featurestore). A feature ignores methods it doesn't use.
type Store interface {
GetUser(ctx context.Context, owner, name string) (*model.User, error)
// GetUserByID resolves a user by model.User.Id — the stable opaque UUID the
// OIDC `sub` carries, which AddUser mints server-side and UpdateUser carries
// forward. It is the id a module hands back to a client as the user's stable
// handle, so it must be THIS value and never the orm storage id, which differs
// per row and is mutable for migrated rows. Returns (nil, nil) when no user
// matches.
GetUserByID(ctx context.Context, id string) (*model.User, error)
GetGlobalUsers(ctx context.Context, offset, limit int) ([]*model.User, int, error)
AddUser(ctx context.Context, u *model.User) (bool, error)
UpdateUser(ctx context.Context, u *model.User) (bool, error)
DeleteUser(ctx context.Context, owner, name string) (bool, error)
GetApplication(ctx context.Context, id string) (*model.Application, error)
GetOrganization(ctx context.Context, name string) (*model.Organization, error)
// GetProvider resolves an identity provider by (owner, name) — the SP-inbound
// SAML/OAuth surface (a user signing in through a corporate IdP where Hanzo is
// the Service Provider). SAML SP-initiated login reads its IdP config from here.
GetProvider(ctx context.Context, owner, name string) (*model.Provider, error)
// GetCert resolves a signing cert by (owner, name) — SAML metadata signing, etc.
GetCert(ctx context.Context, owner, name string) (*model.Cert, error)
// SetPassword sets a user's password: the core hashes the plaintext exactly
// once and stores only the one-way digest (never the clear text). An empty
// plaintext leaves the digest untouched. Hashing lives in ONE place (the core) —
// a module never sees a hash, and never grows its own.
SetPassword(ctx context.Context, owner, name, plaintext string) (bool, error)
// VerifyPassword reports whether plaintext matches the user's stored digest
// (argon2id for migrated v1 rows, bcrypt for v2, per the org's password type).
// Used by LDAP bind — verification stays in the core, never in a module.
VerifyPassword(ctx context.Context, owner, name, plaintext string) (bool, error)
}
// Feature is one pluggable enterprise capability. Route activates it against the
// shared app, backed by store. Name is for diagnostics.
//
// Route is really "activate", and the name is honest for only one of the two
// modules: hanzoiam/saml registers HTTP routes on app, while hanzoiam/ldap takes
// app as `_` and binds its own TCP listeners — same hook, no routes. Rename it to
// Start when this seam first grows a composing binary; it has none today, so the
// rename is free then and a coordinated three-repo break now.
type Feature interface {
Name() string
Route(app *zip.App, store Store) error
}
var registry []Feature
// Register adds a feature to the set RouteAll registers. Called by the composing
// binary (cloud) or a module init — the core decides which enterprise features ship.
func Register(f Feature) { registry = append(registry, f) }
// Registered returns the registered features (diagnostics/tests).
func Registered() []Feature { return append([]Feature(nil), registry...) }
// RouteAll registers every registered feature on app with store, fail-fast: a
// registered-but-broken enterprise module surfaces loudly at boot, never a silent no-op.
func RouteAll(app *zip.App, store Store) error {
for _, f := range registry {
if err := f.Route(app, store); err != nil {
return err
}
}
return nil
}
+73
View File
@@ -0,0 +1,73 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package feature_test
import (
"context"
"testing"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/feature"
"github.com/hanzoai/iam/pkg/model"
)
// A registered feature is routed by RouteAll and reaches the app + store; a
// module that fails to register surfaces the error (fail-fast).
type fakeFeature struct {
name string
registered bool
err error
}
func (f *fakeFeature) Name() string { return f.name }
func (f *fakeFeature) Route(app *zip.App, store feature.Store) error {
f.registered = true
return f.err
}
type nopStore struct{}
func (nopStore) GetUser(context.Context, string, string) (*model.User, error) { return nil, nil }
func (nopStore) GetUserByID(context.Context, string) (*model.User, error) { return nil, nil }
func (nopStore) GetGlobalUsers(context.Context, int, int) ([]*model.User, int, error) {
return nil, 0, nil
}
func (nopStore) AddUser(context.Context, *model.User) (bool, error) { return true, nil }
func (nopStore) UpdateUser(context.Context, *model.User) (bool, error) { return true, nil }
func (nopStore) DeleteUser(context.Context, string, string) (bool, error) { return true, nil }
func (nopStore) GetApplication(context.Context, string) (*model.Application, error) { return nil, nil }
func (nopStore) GetOrganization(context.Context, string) (*model.Organization, error) {
return nil, nil
}
func (nopStore) GetCert(context.Context, string, string) (*model.Cert, error) { return nil, nil }
func (nopStore) GetProvider(context.Context, string, string) (*model.Provider, error) {
return nil, nil
}
func (nopStore) SetPassword(context.Context, string, string, string) (bool, error) {
return true, nil
}
func (nopStore) VerifyPassword(context.Context, string, string, string) (bool, error) {
return true, nil
}
func TestRouteAll_RegistersEveryFeature(t *testing.T) {
f := &fakeFeature{name: "fake"}
feature.Register(f)
app := zip.New(zip.Config{DisableStartupMessage: true})
if err := feature.RouteAll(app, nopStore{}); err != nil {
t.Fatalf("RouteAll: %v", err)
}
if !f.registered {
t.Fatal("registered feature never had Route called")
}
found := false
for _, r := range feature.Registered() {
if r.Name() == "fake" {
found = true
}
}
if !found {
t.Fatal("Registered() did not list the feature")
}
}
+75 -37
View File
@@ -1,77 +1,115 @@
module github.com/hanzoai/iam2
module github.com/hanzoai/iam
go 1.26.4
go 1.26.5
// Hanzo IAM v2 stack (MIGRATION.md §2) no base, no consensus engine:
// This path carries two histories. Everything below v1.32.0 published the
// Casdoor-derived tree (Beego/xorm, controllers/); v1.32.0 and above publish
// this one (zip/orm, internal/). Same import path, no signal which made
// `go get github.com/hanzoai/iam@v1.31.28` a lineage swap that still compiles.
//
// Those versions now live, byte-identical, at github.com/hanzoai/iam-v1.
// Deleting their tags here would not un-publish them: proxy.golang.org caches
// module versions immutably and already serves 506 of them. This retraction is
// therefore the only thing that reaches every resolver, proxied or direct.
retract [v1.0.0, v1.31.37] // Casdoor lineage; moved to github.com/hanzoai/iam-v1
// Hanzo IAM stack (MIGRATION.md §2) no base, no consensus engine:
// - github.com/zap-proto/zip typed HTTP handlers on the zap-proto/fiber v3 engine
// - github.com/hanzoai/orm typed Go records over SQLite / hanzoai/sql / hanzoai/datastore
require (
github.com/hanzoai/orm v0.6.1
github.com/hanzoai/orm v0.6.16
github.com/spf13/cobra v1.10.2
github.com/zap-proto/zip v1.6.0
github.com/zap-proto/zip v1.24.2
golang.org/x/crypto v0.54.0
)
// Migration-only: linked solely in `go build -tags migration` so `iam2 compare`
// can read the v1 Casdoor Postgres/MySQL database. The default (serving) build
// Migration-only: linked solely in `go build -tags migration` so `iam compare`
// can read the legacy v1 Postgres/MySQL database. The default (serving) build
// never links these it is SQLite/ZAP-only, no external SQL driver.
require (
github.com/go-sql-driver/mysql v1.9.3
github.com/jackc/pgx/v5 v5.9.2
)
require (
github.com/alexedwards/argon2id v1.0.0
github.com/goccy/go-yaml v1.19.2
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518
github.com/hanzoai/account v0.2.1
github.com/luxfi/crypto v1.20.2
github.com/luxwallet/connect/go v0.1.4
github.com/pquerna/otp v1.5.0
github.com/valyala/fasthttp v1.72.0
github.com/zap-proto/fiber/v3 v3.2.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/evanw/esbuild v0.28.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gofiber/schema v1.7.1 // indirect
github.com/gofiber/utils/v2 v2.0.4 // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hanzoai/dbx v1.16.0 // indirect
github.com/hanzoai/kv-go/v9 v9.18.0 // indirect
github.com/hanzoai/sqlite v0.2.1 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/gorilla/rpc v1.2.1 // indirect
github.com/grandcat/zeroconf v1.0.0 // indirect
github.com/hanzoai/builder v0.3.13 // indirect
github.com/hanzoai/csqlite v0.1.0 // indirect
github.com/hanzoai/dbx v1.17.2 // indirect
github.com/hanzoai/sqlcipher v0.1.1 // indirect
github.com/hanzoai/sqlite v0.5.0 // indirect
github.com/hanzoai/xorm v1.4.4 // indirect
github.com/hanzokv/go/v9 v9.22.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/luxfi/accel v1.2.4 // indirect
github.com/luxfi/cache v1.3.1 // indirect
github.com/luxfi/container v0.2.1 // indirect
github.com/luxfi/ids v1.3.2 // indirect
github.com/luxfi/log v1.4.3 // indirect
github.com/luxfi/math v1.5.1 // indirect
github.com/luxfi/math/big v0.1.0 // indirect
github.com/luxfi/mdns v0.1.1 // indirect
github.com/luxfi/metric v1.8.1 // indirect
github.com/luxfi/mock v0.1.1 // indirect
github.com/luxfi/zap v1.2.6 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-sqlite3 v1.14.47 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/mr-tron/base58 v1.3.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oasisprotocol/curve25519-voi v0.0.0-20251114093237-2ab5a27a1729 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.70.0 // indirect
github.com/zap-proto/fiber/v3 v3.2.1 // indirect
github.com/zap-proto/go v1.3.0 // indirect
github.com/zap-proto/http v0.2.0 // indirect
github.com/zap-proto/http v0.3.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
go.uber.org/mock v0.6.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.47.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.48.1 // indirect
)
// Local checkouts during the migration so iam2 stays in sync with patches
// landing in orm and zip. Switch to pinned vX.Y.Z once the v2 surface
// stabilises (Phase 1).
replace (
github.com/hanzoai/orm => ../orm
github.com/zap-proto/zip => ../../zap-proto/zip
modernc.org/sqlite v1.51.0 // indirect
)
+270 -59
View File
@@ -1,53 +1,107 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d h1:xbM5U2EvWKkHxzEQJ2DEn20FwolWZahuTnVHr6WL3Q4=
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/evanw/esbuild v0.28.1 h1:ds+yuRyUaZGx++GR56CrCeuXh8PVhVM4xq8v7PNELFc=
github.com/evanw/esbuild v0.28.1/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI=
github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI=
github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/utils/v2 v2.0.4 h1:WwAxUA7L4MW2DjdEHF234lfqvBqd2vYYuBtA9TJq2ec=
github.com/gofiber/utils/v2 v2.0.4/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hanzoai/dbx v1.16.0 h1:C8wsb9BIiit4nYnXizpcB4SyzVaepPkQFwq5i9fxAV0=
github.com/hanzoai/dbx v1.16.0/go.mod h1:ynP6HSiDDoFZ8M3DC+XvSglBPFRygfTd/gjTWabh4yA=
github.com/hanzoai/kv-go/v9 v9.18.0 h1:vO2SD8dV0+H9WWCVKV9KHaWZq4yeMsZruohrsZN9448=
github.com/hanzoai/kv-go/v9 v9.18.0/go.mod h1:S+Li20E6Bskpw6r+c8WWhfi4hCr8SVV32qPXO0wdl+E=
github.com/hanzoai/sqlite v0.2.1 h1:PqUty8+NhJsfwzT5K/U6vgFSIykM1vM0GMLeoH2KWio=
github.com/hanzoai/sqlite v0.2.1/go.mod h1:SVhzKrbEovivr/sEaL/Wgw81a7Xfy6gSoOMzuRCvt7s=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518 h1:UBg1xk+oAsIVbFuGg6hdfAm7EvCv3EL80vFxJNsslqw=
github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/rpc v1.2.1 h1:yC+LMV5esttgpVvNORL/xX4jvTTEUE30UZhZ5JF7K9k=
github.com/gorilla/rpc v1.2.1/go.mod h1:uNpOihAlF5xRFLuTYhfR0yfCTm0WTQSQttkMSptRfGk=
github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
github.com/hanzoai/account v0.2.1 h1:OpODtK/N+qcUy83yUj6br+yTTBoItJWneDtOZ3NlyFU=
github.com/hanzoai/account v0.2.1/go.mod h1:8OzIGRphAhlabOI74O4GoL3RM0y8mbUV0pQUKgXLjkw=
github.com/hanzoai/builder v0.3.13 h1:tAOJ+0Q0xrrovk7lkvaZxuKZ4lqENIB6tE0Rr9+6Bo8=
github.com/hanzoai/builder v0.3.13/go.mod h1:TWZaiP0Y9tCMwtLH2EvQqBAeT1f3aJI5Y0XPM8S0wcE=
github.com/hanzoai/csqlite v0.1.0 h1:suwC3dh0INlfP/U0Es6cDf6JNQ+2+GVLLATPWCUux6k=
github.com/hanzoai/csqlite v0.1.0/go.mod h1:H31a/O6VXuklR9UBkgY++bmAK5uzVfXPqU0F6P9Wsos=
github.com/hanzoai/dbx v1.17.2 h1:EBADhGuOMxCsc4eHj5cJmtE9c7tSKaviyl8URx31NOQ=
github.com/hanzoai/dbx v1.17.2/go.mod h1:u7f8kFoy1tS6YRzVNEurA/NlkRF9Uq9ZhDEqOchFtSM=
github.com/hanzoai/orm v0.6.16 h1:w3UXH65huahNJ8RgC88ffUeicAbHoUpQW8oLuDCojK8=
github.com/hanzoai/orm v0.6.16/go.mod h1:KpbP5UwQ8BBNGVM3tku9rgs7PADB+UG8fqh8Nol0X/s=
github.com/hanzoai/sqlcipher v0.1.1 h1:GARjSiUEa1lwhd1/f87XRaujZBG5s1ZwxrZW2Es/ADI=
github.com/hanzoai/sqlcipher v0.1.1/go.mod h1:F0soUYM1i4sawOZUpRvVnWoUayPbeGVlGq01VXy9Aqg=
github.com/hanzoai/sqlite v0.5.0 h1:1YydiyNAvL+WcXC1lUqZsUDaR4/7YkVb+wZm9qq9DSc=
github.com/hanzoai/sqlite v0.5.0/go.mod h1:7hlAtZspL0Ggx/j0cSo6npPFtUeikvIxnMDb7yTaJD0=
github.com/hanzoai/xorm v1.4.4 h1:2VRwh5BtOgbED+CAzHQ47sPZBgljBlKnJw1Ar6V6il0=
github.com/hanzoai/xorm v1.4.4/go.mod h1:fn6acg0hHm5FKGKlUxFvXOTdvP2IXXRS1+NEjYG8Raw=
github.com/hanzokv/go/v9 v9.22.0 h1:zD4fh0NLBuVa8njIrXUivJCijlratzS1Yf7Y/uD5T00=
github.com/hanzokv/go/v9 v9.22.0/go.mod h1:GV+nw+jX60sIrJ7LBkmOQw2WASXzkMstt52blcwNw6w=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -58,24 +112,76 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/luxfi/accel v1.2.4 h1:5VbIHyEvvfobn2zBiTFODxDw1CeqxCepZOLlvkuf9yQ=
github.com/luxfi/accel v1.2.4/go.mod h1:ISIwAX+ZfsL/S5nsP2JvfldXN6Nc+QzoWf6Jtaq+xsQ=
github.com/luxfi/cache v1.3.1 h1:grQhi/B5GKypG7avDMeY143QTgFbfEvQICKNIh1Cw6U=
github.com/luxfi/cache v1.3.1/go.mod h1:2MokdbeNUy/9O3mdREWkE6BiN7tRvePkXiKkcb+4M7g=
github.com/luxfi/container v0.2.1 h1:MTnfKXzS5+oxV5jKZerdOxSA6iMPaQI9/FWGufizzaw=
github.com/luxfi/container v0.2.1/go.mod h1:B+uM0wP0lGvt/SSK7QOEn/qBcsHzILVHlKikdCyzSgM=
github.com/luxfi/crypto v1.20.2 h1:L81WEsU/hs2A76F5PWBusG0yU74QqkDdUqqgexWUxh4=
github.com/luxfi/crypto v1.20.2/go.mod h1:qYHOM0lO4PRh7LEaObxFQUIMjmT1/paVm/WgZkobT1k=
github.com/luxfi/ids v1.3.2 h1:c6Rft5kZB4XqiCtWaGH47bfhaNFm3FGRfhEzI01GVeI=
github.com/luxfi/ids v1.3.2/go.mod h1:+5l8cYMbKpORJbQ2r98CYJo9TQATgUdnmzpYFZWMwwc=
github.com/luxfi/log v1.4.3 h1:xkUKRWvQ4ZwvlUC2e0/RTtHYZOYSMvSQ9W9lbjwBmiI=
github.com/luxfi/log v1.4.3/go.mod h1:myIkufyiQomSQH34K981kbz6cG4WUoerRUh7F4XhlQI=
github.com/luxfi/math v1.5.1 h1:FDOY75e4vn/Xra1ij99xOS/9XdxQGCPP6HONHRkCwfg=
github.com/luxfi/math v1.5.1/go.mod h1:3j9R24hVfPhrbvs45YSJP7jAyVNfwx/cj/+lAO8IGro=
github.com/luxfi/math/big v0.1.0 h1:Vz4c0RsZVPdIKPsHPgAJChH/R3p15WHRUz7LkLf+NIQ=
github.com/luxfi/math/big v0.1.0/go.mod h1:BuxSu22RbO93xBLk5Eam5nldFponoJ73xDFz4uJ3Huk=
github.com/luxfi/mdns v0.1.1 h1:g2eRr9AXcziPkkcd24M+Qu9ApEpoKKjfI79QSNqv0rQ=
github.com/luxfi/mdns v0.1.1/go.mod h1:dbp5f3h3aE7CGzwbaWzBM9cwdcekhmSrWhQevgYhhNA=
github.com/luxfi/metric v1.8.1 h1:v58GgPFAOLPVxSa/JiNLwqJQNEFHdWbXZV28piMXX4s=
github.com/luxfi/metric v1.8.1/go.mod h1:R1OPAIeW4UBW3osK7j2r3/XPmczfNRFTXg4bnlemTuE=
github.com/luxfi/mock v0.1.1 h1:0HEtIjg1J6CWz+IUyP6rsGqNWTcmxjFnSQIhaDuARwY=
github.com/luxfi/mock v0.1.1/go.mod h1:jo35akl3Vtd8LbzDts8VJ0jmSVycrd1/eBi6g6t5hKU=
github.com/luxfi/pq v1.1.0 h1:ADplfUSyirLymSxs3Ix0HeDTyl5oswCNUpXJt/5vLY8=
github.com/luxfi/pq v1.1.0/go.mod h1:KT5rG9ztpzIkT9QSnXK4WFqBBLzKCLjY7l1c/unBi8I=
github.com/luxfi/zap v1.2.6 h1:NBpbm9Gib41Oi/XAkAZKQ3hb+xCafo7JsrUjw+bKiAc=
github.com/luxfi/zap v1.2.6/go.mod h1:sTAe/AMMamoE85cVoe81+NbqHJkgvqS0LhY9ByHEmr0=
github.com/luxwallet/connect/go v0.1.4 h1:Gmyl+MkrDxGI9jUjSzRt2yL/CL32apcLxVUdvoJdD7A=
github.com/luxwallet/connect/go v0.1.4/go.mod h1:ReVK757g7VqTfcbUNg5SinpjBCzMgilEYm+Gux8tdmo=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI=
github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oasisprotocol/curve25519-voi v0.0.0-20251114093237-2ab5a27a1729 h1:yfQ2sO9WJXUAIUR+g7NUkxJSKCAFJcR5sUDu+ZmjTZI=
github.com/oasisprotocol/curve25519-voi v0.0.0-20251114093237-2ab5a27a1729/go.mod h1:hVoHR2EVESiICEMbg137etN/Lx+lSrHPTD39Z/uE+2s=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -87,55 +193,160 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d h1:vfofYNRScrDdvS342BElfbETmL1Aiz3i2t0zfRj16Hs=
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d/go.mod h1:RRCYJbIwD5jmqPI9XoAFR0OcDxqUctll6zUj/+B4S48=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M=
github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zap-proto/fiber/v3 v3.2.1 h1:k45oKyTwySPtGt8sPz2Ao8OUHc7pDEhai8Np2Ym6Jbg=
github.com/zap-proto/fiber/v3 v3.2.1/go.mod h1:eDm2z+ufJrkuE4MeX0Mea4oc/p7/HpXiZjVB+BXCKOA=
github.com/zap-proto/go v1.3.0 h1:S3rMoawwhH/BbSZ4G8zG05hJoQnMSMDPzIq75diCTqE=
github.com/zap-proto/go v1.3.0/go.mod h1:914SNGTH6Rv3Yu1MweWJBPEN8FZlo5C39QyhaB0C7Q0=
github.com/zap-proto/http v0.2.0 h1:WiTqJ7Wh0O2qA3DNhvyi0b9F4j2wX8ctZDlW46WMxWQ=
github.com/zap-proto/http v0.2.0/go.mod h1:UYfGhDDCetgxs65XSev8Lpf65COg5vKQK+cWwZGh4zQ=
github.com/zap-proto/http v0.3.1 h1:A2rCPWYCX866eAsdiWuns0dvWnBmViZtGm4pwX7jwlY=
github.com/zap-proto/http v0.3.1/go.mod h1:UYfGhDDCetgxs65XSev8Lpf65COg5vKQK+cWwZGh4zQ=
github.com/zap-proto/zip v1.23.0 h1:R2uZV7SJouchj0BtSYk1Z6nTh3tSGaKGwV/XjuZqaSU=
github.com/zap-proto/zip v1.23.0/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
github.com/zap-proto/zip v1.24.1 h1:HF3Tm30bRBfFaAMkH0nsdrVku1XTvbqVSqhqWrOtf9k=
github.com/zap-proto/zip v1.24.1/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
github.com/zap-proto/zip v1.24.2 h1:kWKQeMzMf53PHTfHQvF+HrC0mEItcd1a++Ynuy97tqs=
github.com/zap-proto/zip v1.24.2/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -144,18 +355,18 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA=
modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+30
View File
@@ -0,0 +1,30 @@
# Canonical CI/CD config for hanzoai/iam — the one file both the hanzoai/ci
# reusable (.hanzo/workflows/cicd.yml) and platform.hanzo.ai read.
#
# GATE ONLY. The image already has exactly one builder and it is deliberate:
# .hanzo/workflows/image.yml, which tags by COMMIT SHA (a semver tag that gets
# re-pushed leaves two digests behind one name, which is how platform's v4.4.5
# came to mean two builds) and mounts the token `go mod download` needs for the
# private hanzoai/orm + hanzoai/sqlite modules. That file's own header documents
# why it is the ONLY file in this repo that builds an image. Declaring `images:`
# here would make a second one, which is the exact failure it was written to end.
#
# The gate is the repo's own: `make test`. Two halves, both real —
# * zipdoc -check in every directory that generates one: a codegen-freshness
# refusal, so a handler doc comment that no longer matches its generated
# zipdoc_gen.go fails the build instead of drifting silently.
# * `go test ./... -race -count=1`: the whole suite (140 test files), under the
# race detector, with caching off so a green means it ran here and now.
# `go build ./...` runs first so a plain compile break fails in seconds rather
# than after the full race build.
#
# Note on what is NOT here: no `-tags skipCi`. Files guarded `//go:build !skipCi`
# vanish under that tag and `go test` then reports "[no tests to run]" and exits
# 0 — a green over zero tests. This tree carries no such guard and this gate
# passes no such tag; both halves of that have to stay true.
test:
- name: build
run: |
set -e
go build ./...
make test
+304
View File
@@ -0,0 +1,304 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
// Package applications is the Phase-1 typed CRUD surface for the `applications`
// entity. Every operation is a zip typed handler (decode In -> run -> encode
// Out) over hanzoai/orm and is owner-scoped by the (owner, name) natural key,
// materialized as the orm id "<owner>/<name>". The same In/Out types back both
// the REST route and the MCP tools/call projection zip derives from them, so
// identity arguments travel in the typed request, not in ad-hoc path parsing.
package applications
import (
"context"
"errors"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/authz"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
)
// authorizeOrganization gates the Organization an application will SERVE (the
// tenant a credential minted through it lands in), not just its registry Owner:
// the op-invoke authz hook authorizes the top-level Owner, but Organization is a
// separate field that a tenant admin could otherwise set to the reserved admin
// org (a SuperAdmin-minting app) or to a victim tenant. On a gated HTTP request
// the Guard attached a Principal; a non-super may point an app only at its OWN
// org. A server-internal call (bootstrap/seed) carries no Principal and is
// trusted, so an unauthenticated context is left to the surrounding trust
// boundary rather than blocked here.
func authorizeOrganization(ctx context.Context, in *schema.Application) error {
if in.Organization == "" {
return nil // an org-less app mints no cross-tenant/SuperAdmin identity
}
p, ok := authz.From(ctx)
if !ok {
return nil // server-internal (no principal) — trusted caller
}
if !authz.CanSetOrg(p, in.Organization) {
return zip.ErrForbidden("not authorized to set the application organization to " + in.Organization)
}
return nil
}
// ensureClientIdUnique rejects a create/update whose clientId is already held by a
// DIFFERENT application (any owner). clientId is the GLOBAL key the mint and Basic-auth
// resolvers authenticate against, so it must be unique across every owner, not merely
// within one — otherwise a tenant could register a row whose clientId collides with a
// platform console's and (on a backend whose duplicate-row order is unspecified) shadow
// it. A JSON-document store has no per-field column to carry a DB UNIQUE index, so the
// invariant is enforced here at the write, exactly as the (owner,name) natural key is.
// An empty clientId cannot collide (a public app authenticates no confidential grant);
// the self-row (same owner,name) is skipped so an update that keeps its own clientId is
// never a self-collision.
func ensureClientIdUnique(ctx context.Context, db orm.DB, clientId, owner, name string) error {
if clientId == "" {
return nil
}
existing, err := store.ListApplicationsByClientId(ctx, db, clientId)
if err != nil {
return zip.ErrInternal(err.Error())
}
for _, a := range existing {
if a.Owner != owner || a.Name != name {
return zip.ErrConflict("clientId already in use: " + clientId)
}
}
return nil
}
// appID is the owner-scoped natural key "<owner>/<name>" — the single source
// of an application's orm id. Every handler routes through it so reads and
// writes address the exact same row.
func appID(owner, name string) string { return owner + "/" + name }
// ApplicationRef identifies one application by its owner-scoped natural key.
// It is the input for the get and delete operations.
type ApplicationRef struct {
Owner string `json:"owner" validate:"required"`
Name string `json:"name" validate:"required"`
}
// ApplicationQuery filters applications by owner for the list operation.
type ApplicationQuery struct {
Owner string `json:"owner" validate:"required"`
}
// ApplicationListResult wraps the applications owned by one owner, newest
// first.
type ApplicationListResult struct {
Applications []*schema.Application `json:"applications"`
}
// DeleteResult reports the outcome of a delete operation.
type DeleteResult struct {
Deleted bool `json:"deleted"`
}
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
// Route registers the applications CRUD surface on app, closing over db.
//
// The kind is addressed in the PLURAL, like every other kind in this service —
// users, certs, roles, invitations, keys, projects, workspaces, permissions,
// providers, tokens, sessions, organizations, audit-logs,
// webauthn-credentials — with `/get`, `/update` and `/delete` under it. This was
// the only singular, so `/v1/iam/application` and `/v1/iam/applications` both
// answered and which spelling a reader wanted depended on the operation.
// Fourteen kinds against one is not a matter of taste; the odd one moved.
//
// The singular address stays reachable on the SAME typed handlers, tagged
// `compat` — which is what keeps it out of the published document and therefore
// out of every SDK, docs page and CLI command. It is deleted when the last
// pinned consumer moves.
func Route(app *zip.App, db orm.DB) {
zip.Get(app, "/v1/iam/applications", listApplications(db), zip.WithTags("applications"))
zip.Post(app, "/v1/iam/applications", Create(db), zip.WithTags("applications"))
zip.Get(app, "/v1/iam/applications/get", getApplication(db), zip.WithTags("applications"))
zip.Post(app, "/v1/iam/applications/update", Update(db), zip.WithTags("applications"))
zip.Post(app, "/v1/iam/applications/delete", deleteApplication(db), zip.WithTags("applications"))
zip.Get(app, "/v1/iam/application", getApplication(db), zip.WithTags("compat"))
zip.Post(app, "/v1/iam/application", Create(db), zip.WithTags("compat"))
zip.Put(app, "/v1/iam/application", Update(db), zip.WithTags("compat"))
zip.Delete(app, "/v1/iam/application", deleteApplication(db), zip.WithTags("compat"))
}
// listApplications returns the applications in one organization, newest first —
// each product or site your people sign in to, with the sign-in methods and
// redirect URIs it allows.
func listApplications(db orm.DB) zip.TypedHandler[ApplicationQuery, ApplicationListResult] {
return func(ctx context.Context, in *ApplicationQuery) (*ApplicationListResult, error) {
if in.Owner == "" {
return nil, zip.ErrBadRequest("owner is required")
}
apps, err := orm.TypedQuery[schema.Application](db).
Filter("Owner=", in.Owner).
Order("-CreatedTime").
GetAll(ctx)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
for i, app := range apps {
apps[i] = app.Mask() // never emit clientSecret in a list response
}
return &ApplicationListResult{Applications: apps}, nil
}
}
// getApplication returns one application: its sign-in methods, its allowed
// redirect URIs and the client credentials your integration authenticates with.
func getApplication(db orm.DB) zip.TypedHandler[ApplicationRef, schema.Application] {
return func(ctx context.Context, in *ApplicationRef) (*schema.Application, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
id := appID(in.Owner, in.Name)
app, err := orm.Get[schema.Application](db, id)
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("application not found: " + id)
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
return app.Mask(), nil
}
}
// Create registers an application in your organization — one product or site
// your people sign in to, with its own client credentials, sign-in methods and
// allowed redirect URIs. A name already used in the organization is refused
// rather than overwritten.
//
// Exported so the legacy add-application alias reuses this exact path — one
// create, two spellings.
func Create(db orm.DB) zip.TypedHandler[schema.Application, schema.Application] {
return func(ctx context.Context, in *schema.Application) (*schema.Application, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
if err := authorizeOrganization(ctx, in); err != nil {
return nil, err
}
id := appID(in.Owner, in.Name)
// Owner-scoped uniqueness: (owner, name) must be free.
if _, err := orm.Get[schema.Application](db, id); err == nil {
return nil, zip.ErrConflict("application already exists: " + id)
} else if !errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrInternal(err.Error())
}
// Global uniqueness: clientId is the mint/Basic-auth resolution key, so it must
// be free across ALL owners — the invariant the confidential-client gates rely on.
if err := ensureClientIdUnique(ctx, db, in.ClientId, in.Owner, in.Name); err != nil {
return nil, err
}
// Bind the decoded entity to db under its natural key and persist.
in.Init(db)
in.SetId(id)
if err := in.Create(); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return in.Mask(), nil
}
}
// Update changes an application's display, its sign-in methods and the redirect
// URIs it may return to — the call that makes login work from a new host. Which
// organization it belongs to and what it is named are fixed when it is created
// and are not editable here.
//
// Exported so the legacy update-application alias reuses this exact path — one
// update, two spellings.
func Update(db orm.DB) zip.TypedHandler[schema.Application, schema.Application] {
return func(ctx context.Context, in *schema.Application) (*schema.Application, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
if err := authorizeOrganization(ctx, in); err != nil {
return nil, err
}
id := appID(in.Owner, in.Name)
existing, err := orm.Get[schema.Application](db, id)
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("application not found: " + id)
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
// Global clientId uniqueness (see Create): an update may keep its own clientId
// but must never steal another app's.
if err := ensureClientIdUnique(ctx, db, in.ClientId, in.Owner, in.Name); err != nil {
return nil, err
}
// A write that says NOTHING about the credential must not destroy it.
//
// This verb is a full REPLACE, and every read of an application MASKS its
// client secret (Mask, and get-app-login before it) — so the natural admin
// round-trip, read the record, change one field, write it back, silently
// posted ClientSecret:"" and de-secreted the app. Measured on live IAM: the
// SuperAdmin read of hanzo-console, hanzo-app, hanzo-id and hanzo-cloud all
// return "" while a token-endpoint probe proves all four DO hold a secret.
// Any console "save" on an application page was one request away from turning
// a confidential client public — which the token endpoint then reads as "PKCE,
// demand no client auth", weakening every flow that app serves.
//
// So an OMITTED secret preserves what is stored. This is the same rule the
// operator upsert already settled in resolveSecret ("existing app -> preserve
// what it has"), stated once more here because this is the other door onto the
// same row; rotation stays possible, it just has to be DELIBERATE — send the
// new secret to change it.
//
// Clearing a secret on purpose (confidential -> public) is therefore no longer
// expressible as an accident. It goes through the operator upsert's explicit
// `public: true`, which is the one place that decision is named.
if in.ClientSecret == "" {
in.ClientSecret = existing.ClientSecret
}
in.Init(db)
in.SetId(id)
in.CreatedTime = existing.CreatedTime
in.CreatedAt = existing.CreatedAt
if err := in.Update(); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return in.Mask(), nil
}
}
// Delete exposes the same handler to the legacy delete-application alias — one
// delete path, wrapped in that surface's envelope.
func Delete(db orm.DB) zip.TypedHandler[ApplicationRef, DeleteResult] { return deleteApplication(db) }
// deleteApplication removes an application. Anyone mid-sign-in through it is
// turned away and its client credentials stop working, so retire the integration
// before deleting it.
func deleteApplication(db orm.DB) zip.TypedHandler[ApplicationRef, DeleteResult] {
return func(ctx context.Context, in *ApplicationRef) (*DeleteResult, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
id := appID(in.Owner, in.Name)
app, err := orm.Get[schema.Application](db, id)
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("application not found: " + id)
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
if err := app.Delete(); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &DeleteResult{Deleted: true}, nil
}
}
@@ -0,0 +1,91 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package applications
import (
"context"
"path/filepath"
"testing"
"github.com/hanzoai/orm"
ormdb "github.com/hanzoai/orm/db"
"github.com/hanzoai/iam/pkg/schema"
)
func memDB(t *testing.T) orm.DB {
t.Helper()
_ = schema.Kinds()
dir := t.TempDir()
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(dir, "apptest.db"),
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
// The clientId global-uniqueness guard on create: a create may not take a clientId
// already held by a DIFFERENT (owner,name), so a tenant can never register a row that
// collides with a platform console's confidential-client key. This is the store-layer
// enforcement of the invariant the mint/Basic-auth gates rely on (a JSON-document
// store has no column for a DB UNIQUE index). A background ctx carries no principal,
// so authorizeOrganization is the trusted server-internal path and the guard is
// exercised in isolation.
func TestCreate_RejectsDuplicateClientId(t *testing.T) {
db := memDB(t)
ctx := context.Background()
create := Create(db)
// The legit platform console.
if _, err := create(ctx, &schema.Application{Owner: "admin", Name: "hanzo-console", ClientId: "hanzo-console"}); err != nil {
t.Fatalf("seed console: %v", err)
}
// A tenant tries to register a DIFFERENT (owner,name) with the SAME clientId.
if _, err := create(ctx, &schema.Application{Owner: "evil", Name: "evil-console", ClientId: "hanzo-console"}); err == nil {
t.Fatal("HIGH REOPENED: a colliding clientId was accepted on create")
}
// A distinct clientId under a tenant is fine (no false positive).
if _, err := create(ctx, &schema.Application{Owner: "hanzo", Name: "hanzo-app", ClientId: "hanzo-app"}); err != nil {
t.Fatalf("a distinct clientId must be accepted: %v", err)
}
// A public app (no clientId) never collides with another public app.
if _, err := create(ctx, &schema.Application{Owner: "hanzo", Name: "pub-a"}); err != nil {
t.Fatalf("empty clientId #1: %v", err)
}
if _, err := create(ctx, &schema.Application{Owner: "hanzo", Name: "pub-b"}); err != nil {
t.Fatalf("empty clientId #2 must not collide with #1: %v", err)
}
}
// Update may keep its OWN clientId (the self-row is skipped, never a self-collision)
// but must not steal another app's.
func TestUpdate_ClientIdCollision(t *testing.T) {
db := memDB(t)
ctx := context.Background()
create, update := Create(db), Update(db)
if _, err := create(ctx, &schema.Application{Owner: "admin", Name: "hanzo-console", ClientId: "hanzo-console"}); err != nil {
t.Fatalf("seed console: %v", err)
}
if _, err := create(ctx, &schema.Application{Owner: "hanzo", Name: "hanzo-app", ClientId: "hanzo-app"}); err != nil {
t.Fatalf("seed tenant app: %v", err)
}
// hanzo-app keeps its own clientId on update — allowed (self-row skipped).
if _, err := update(ctx, &schema.Application{Owner: "hanzo", Name: "hanzo-app", ClientId: "hanzo-app", DisplayName: "renamed"}); err != nil {
t.Fatalf("keeping own clientId on update must be allowed: %v", err)
}
// hanzo-app tries to STEAL the console's clientId — rejected.
if _, err := update(ctx, &schema.Application{Owner: "hanzo", Name: "hanzo-app", ClientId: "hanzo-console"}); err == nil {
t.Fatal("HIGH REOPENED: an update stole another app's clientId")
}
}
@@ -0,0 +1,105 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package applications
import (
"context"
"testing"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/pkg/schema"
)
// THE ADMIN ROUND-TRIP MUST NOT DE-SECRET AN APP.
//
// update-application is a full REPLACE and every read MASKS the client secret, so
// "read the record, change one field, write it back" — the only shape an admin UI
// or an operator has — posted ClientSecret:"" and silently turned a confidential
// client public. Measured on live IAM: the SuperAdmin read of hanzo-console,
// hanzo-app, hanzo-id and hanzo-cloud all return "" while a token-endpoint probe
// proves all four hold a secret. The token endpoint reads a stored empty secret as
// "public client, demand no client auth", so the blast radius is every flow those
// apps serve.
func seedConfidential(t *testing.T, db orm.DB, name, secret string) *schema.Application {
t.Helper()
a := orm.New[schema.Application](db)
a.Owner, a.Name = "admin", name
a.ClientId, a.ClientSecret = name, secret
a.Organization = "hanzo"
a.SetId("admin/" + name)
if err := a.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed %s: %v", name, err)
}
return a
}
// The regression: a write echoing a MASKED read preserves the credential.
func TestUpdate_MaskedRoundTripPreservesTheSecret(t *testing.T) {
db := memDB(t)
ctx := context.Background()
seedConfidential(t, db, "hanzo-console", "s3cret-do-not-lose-me")
// Exactly what an admin round-trip carries: the record as READ (secret masked
// to ""), with one unrelated field changed.
echoed := &schema.Application{
Owner: "admin", Name: "hanzo-console", ClientId: "hanzo-console",
Organization: "hanzo", ClientSecret: "", EnableSignUp: false,
}
if _, err := Update(db)(ctx, echoed); err != nil {
t.Fatalf("update: %v", err)
}
got, err := orm.Get[schema.Application](db, "admin/hanzo-console")
if err != nil {
t.Fatalf("reload: %v", err)
}
if got.ClientSecret != "s3cret-do-not-lose-me" {
t.Fatalf("the admin round-trip DE-SECRETED the app: ClientSecret = %q, want it preserved.\n"+
"An empty secret is what the token endpoint reads as 'public client, no client auth'.",
got.ClientSecret)
}
if got.EnableSignUp {
t.Errorf("the field the caller actually meant to change did not land")
}
}
// Rotation stays possible — it just has to be deliberate.
func TestUpdate_ExplicitSecretStillRotates(t *testing.T) {
db := memDB(t)
ctx := context.Background()
seedConfidential(t, db, "rotate-me", "old-secret")
in := &schema.Application{
Owner: "admin", Name: "rotate-me", ClientId: "rotate-me",
Organization: "hanzo", ClientSecret: "brand-new-secret",
}
if _, err := Update(db)(ctx, in); err != nil {
t.Fatalf("update: %v", err)
}
got, _ := orm.Get[schema.Application](db, "admin/rotate-me")
if got.ClientSecret != "brand-new-secret" {
t.Fatalf("deliberate rotation was swallowed: %q", got.ClientSecret)
}
}
// An app that genuinely has no secret stays that way — preserving "" is not the
// same as minting one.
func TestUpdate_PublicClientStaysPublic(t *testing.T) {
db := memDB(t)
ctx := context.Background()
seedConfidential(t, db, "public-spa", "")
in := &schema.Application{
Owner: "admin", Name: "public-spa", ClientId: "public-spa",
Organization: "hanzo", ClientSecret: "",
}
if _, err := Update(db)(ctx, in); err != nil {
t.Fatalf("update: %v", err)
}
got, _ := orm.Get[schema.Application](db, "admin/public-spa")
if got.ClientSecret != "" {
t.Fatalf("a public client was handed a secret it never had: %q", got.ClientSecret)
}
}
+107
View File
@@ -0,0 +1,107 @@
// Code generated by zipdoc; DO NOT EDIT.
package applications
import (
"github.com/zap-proto/zip"
)
func init() {
zip.Describe("DELETE /v1/iam/application", zip.Doc{
Description: "Removes an application. Anyone mid-sign-in through it is\nturned away and its client credentials stop working, so retire the integration\nbefore deleting it.",
})
zip.Describe("GET /v1/iam/application", zip.Doc{
Description: "Returns one application: its sign-in methods, its allowed\nredirect URIs and the client credentials your integration authenticates with.",
Fields: map[string]string{
"Application.clientId": "ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
"Model[github.com/hanzoai/iam/pkg/schema.Application].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
},
})
zip.Describe("GET /v1/iam/applications", zip.Doc{
Description: "Returns the applications in one organization, newest first —\neach product or site your people sign in to, with the sign-in methods and\nredirect URIs it allows.",
Fields: map[string]string{
"Application.clientId": "ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
"Model[github.com/hanzoai/iam/pkg/schema.Application].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
},
})
zip.Describe("GET /v1/iam/applications/get", zip.Doc{
Description: "Returns one application: its sign-in methods, its allowed\nredirect URIs and the client credentials your integration authenticates with.",
Fields: map[string]string{
"Application.clientId": "ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
"Model[github.com/hanzoai/iam/pkg/schema.Application].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
},
})
zip.Describe("POST /v1/iam/application", zip.Doc{
Description: "Registers an application in your organization — one product or site\nyour people sign in to, with its own client credentials, sign-in methods and\nallowed redirect URIs. A name already used in the organization is refused\nrather than overwritten.\n\nExported so the legacy add-application alias reuses this exact path — one\ncreate, two spellings.",
Fields: map[string]string{
"Application.clientId": "ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
"Model[github.com/hanzoai/iam/pkg/schema.Application].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
},
})
zip.Describe("POST /v1/iam/applications", zip.Doc{
Description: "Registers an application in your organization — one product or site\nyour people sign in to, with its own client credentials, sign-in methods and\nallowed redirect URIs. A name already used in the organization is refused\nrather than overwritten.\n\nExported so the legacy add-application alias reuses this exact path — one\ncreate, two spellings.",
Fields: map[string]string{
"Application.clientId": "ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
"Model[github.com/hanzoai/iam/pkg/schema.Application].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
},
})
zip.Describe("POST /v1/iam/applications/delete", zip.Doc{
Description: "Removes an application. Anyone mid-sign-in through it is\nturned away and its client credentials stop working, so retire the integration\nbefore deleting it.",
})
zip.Describe("POST /v1/iam/applications/update", zip.Doc{
Description: "Changes an application's display, its sign-in methods and the redirect\nURIs it may return to — the call that makes login work from a new host. Which\norganization it belongs to and what it is named are fixed when it is created\nand are not editable here.\n\nExported so the legacy update-application alias reuses this exact path — one\nupdate, two spellings.",
Fields: map[string]string{
"Application.clientId": "ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
"Model[github.com/hanzoai/iam/pkg/schema.Application].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
},
})
zip.Describe("PUT /v1/iam/application", zip.Doc{
Description: "Changes an application's display, its sign-in methods and the redirect\nURIs it may return to — the call that makes login work from a new host. Which\norganization it belongs to and what it is named are fixed when it is created\nand are not editable here.\n\nExported so the legacy update-application alias reuses this exact path — one\nupdate, two spellings.",
Fields: map[string]string{
"Application.clientId": "ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
"Model[github.com/hanzoai/iam/pkg/schema.Application].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
},
})
}
+248
View File
@@ -0,0 +1,248 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
// Package auditlogs serves the IAM v2 CRUD surface for the `audit_logs` entity:
// an append-only action record owner-scoped by (owner, name). Every operation
// is a typed zip handler over hanzoai/orm; the orm string key is "owner/name".
// Reads scope to one owner (organization); writes address one log by its
// (owner, name) key. Rows are written once at request time — the update path
// exists only for administrative correction, never for normal operation.
package auditlogs
import (
"context"
"errors"
"github.com/hanzoai/iam/internal/authz"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/pkg/schema"
)
// Handler binds the audit-log operations to one orm store.
type Handler struct {
db orm.DB
}
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
// Route registers the audit-log CRUD routes on app against db.
func Route(app *zip.App, db orm.DB) {
h := &Handler{db: db}
zip.Get(app, "/v1/iam/audit-logs", h.List, zip.WithTags("audit-logs"))
zip.Post(app, "/v1/iam/audit-logs", h.Create, zip.WithTags("audit-logs"))
zip.Post(app, "/v1/iam/audit-logs/get", h.Get, zip.WithTags("audit-logs"))
zip.Post(app, "/v1/iam/audit-logs/update", h.Update, zip.WithTags("audit-logs"))
zip.Post(app, "/v1/iam/audit-logs/delete", h.Delete, zip.WithTags("audit-logs"))
}
// Ref addresses one audit log by its owner-scoped natural key.
type Ref struct {
Owner string `json:"owner"`
Name string `json:"name"`
}
// Input is the writable projection of an audit log (the v1 add/update-record
// body). It keeps the HTTP contract clean of the orm.Model bookkeeping fields
// and of the v1 integer surrogate id, which the orm string key supersedes.
type Input struct {
Owner string `json:"owner"`
Name string `json:"name"`
CreatedTime string `json:"createdTime"`
Organization string `json:"organization"`
ClientIp string `json:"clientIp"`
User string `json:"user"`
Method string `json:"method"`
RequestUri string `json:"requestUri"`
Action string `json:"action"`
Language string `json:"language"`
Object string `json:"object"`
Response string `json:"response"`
StatusCode int `json:"statusCode"`
IsTriggered bool `json:"isTriggered"`
}
// ListInput scopes a listing to one owner (organization).
type ListInput struct {
Owner string `json:"owner"`
}
// ListOutput is the owner-scoped page of audit logs, newest first.
type ListOutput struct {
AuditLogs []*schema.AuditLog `json:"auditLogs"`
Total int `json:"total"`
}
// DeleteOutput reports the delete result.
type DeleteOutput struct {
Deleted bool `json:"deleted"`
}
// key builds the orm string key from the (owner, name) natural key.
func key(owner, name string) string { return owner + "/" + name }
// apply copies the mutable domain fields of an Input onto an audit log. The
// identity fields (owner, name) and the created stamp are set only on Create,
// never overwritten by an update.
func apply(dst *schema.AuditLog, in *Input) {
dst.Organization = in.Organization
dst.ClientIp = in.ClientIp
dst.User = in.User
dst.Method = in.Method
dst.RequestUri = in.RequestUri
dst.Action = in.Action
dst.Language = in.Language
dst.Object = in.Object
dst.Response = in.Response
dst.StatusCode = in.StatusCode
dst.IsTriggered = in.IsTriggered
}
// List returns your organization's audit trail, newest first — who did
// what, when, and from where. It is the record you reach for during a security
// review or an incident.
//
// You see your own organization's audit trail and no one else's; which organization that
// is comes from your credentials, not from the request.
func (h *Handler) List(ctx context.Context, in *ListInput) (*ListOutput, error) {
// The owner is resolved by authz.Scope from the authenticated principal,
// never taken from the input: a tenant reads only its own org, a SuperAdmin
// reads the owner it asks for. Filtering on in.Owner instead was a confused
// deputy — the Guard authorizes on the query string, then a typed GET binds
// NOTHING from it (zip typed.go reads a body only for non-GET), so in.Owner
// arrived empty on every REST call and the "empty owner lists everything"
// branch returned every tenant.
owner, err := authz.Scope(ctx, in.Owner)
if err != nil {
return nil, err
}
q := orm.TypedQuery[schema.AuditLog](h.db)
if owner != "" {
q = q.Filter("owner", owner)
}
logs, err := q.Order("-createdTime").GetAll(ctx)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &ListOutput{AuditLogs: logs, Total: len(logs)}, nil
}
// Get returns one audit entry in full: the action, the person or key behind it,
// and the request it came in on.
func (h *Handler) Get(ctx context.Context, in *Ref) (*schema.AuditLog, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
log, err := orm.Get[schema.AuditLog](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
return log, nil
}
// Create records an audit entry, so activity from your own systems lands in the
// same trail as everything the Hanzo Cloud records for you.
func (h *Handler) Create(ctx context.Context, in *Input) (*schema.AuditLog, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
if err := refusePlatformAction(in.Action); err != nil {
return nil, err
}
switch _, err := orm.Get[schema.AuditLog](h.db, key(in.Owner, in.Name)); {
case err == nil:
return nil, zip.ErrConflict("audit log already exists")
case !errors.Is(err, orm.ErrNotFound):
return nil, zip.ErrInternal(err.Error())
}
log := orm.New[schema.AuditLog](h.db)
log.Owner = in.Owner
log.Name = in.Name
log.CreatedTime = in.CreatedTime
if log.CreatedTime == "" {
log.CreatedTime = time.Now().UTC().Format(time.RFC3339)
}
apply(log, in)
log.SetId(key(in.Owner, in.Name))
if err := log.CreateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return log, nil
}
// Update corrects an audit entry. The trail is append-only in normal operation
// and nothing in the Hanzo Cloud rewrites it — this exists for an administrator
// to correct an entry their own systems recorded wrongly.
func (h *Handler) Update(ctx context.Context, in *Input) (*schema.AuditLog, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
log, err := orm.Get[schema.AuditLog](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
// Neither the row you are correcting nor the correction may be a platform
// record: the first would rewrite evidence, the second would forge it by
// relabelling a row you own.
if err := refusePlatformAction(log.Action); err != nil {
return nil, err
}
if err := refusePlatformAction(in.Action); err != nil {
return nil, err
}
apply(log, in)
if err := log.UpdateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return log, nil
}
// Delete removes an audit entry. Retention policy is normally what should expire
// a trail; deleting by hand leaves a gap a reviewer will notice.
func (h *Handler) Delete(ctx context.Context, in *Ref) (*DeleteOutput, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
log, err := orm.Get[schema.AuditLog](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
if err := refusePlatformAction(log.Action); err != nil {
return nil, err
}
if err := log.DeleteCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &DeleteOutput{Deleted: true}, nil
}
// refusePlatformAction rejects an action the PLATFORM writes about itself.
//
// This surface exists so your own systems can file their activity in the same
// trail. It is not a way to author the platform's half of it. A consent grant, a
// credential issued: those rows are the evidence that a thing happened, and
// evidence anybody can write is not evidence — an org admin could mint a
// "consent-training" row granting permission nobody gave, or delete the one
// recording a refusal, and the trail would read exactly the same either way.
//
// So the platform's actions are reserved: not creatable here, and not alterable
// or removable here once written. Retention expires them; nothing else does.
func refusePlatformAction(action string) error {
if schema.PlatformWritten(action) {
return zip.ErrForbidden("the action " + action + " is written by the platform; " +
"audit rows recording it cannot be created, corrected or deleted through this surface")
}
return nil
}
// mapErr translates an orm lookup error into the matching HTTP status.
func mapErr(err error) error {
if errors.Is(err, orm.ErrNotFound) {
return zip.ErrNotFound("audit log not found")
}
return zip.ErrInternal(err.Error())
}
+184
View File
@@ -0,0 +1,184 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package auditlogs
import (
"context"
"path/filepath"
"testing"
"github.com/hanzoai/orm"
ormdb "github.com/hanzoai/orm/db"
"github.com/hanzoai/iam/pkg/schema"
)
// This surface exists so a customer's own systems can file activity in the same
// trail the platform writes to. Sharing one trail is the point — and it is also
// the risk: the platform's rows are EVIDENCE (a consent answer, a credential
// issued), and evidence anyone can author or erase is not evidence. So the
// platform's own actions are reserved, and these tests are the four ways in.
func auditTestDB(t *testing.T) orm.DB {
t.Helper()
_ = schema.Kinds()
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(t.TempDir(), "audittest.db"),
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
// seedPlatformRow writes a row the way the platform writes one — directly, not
// through this surface.
func seedPlatformRow(t *testing.T, db orm.DB, name, action string) {
t.Helper()
log := orm.New[schema.AuditLog](db)
log.Owner = "hanzo"
log.Name = name
log.Organization = "hanzo"
log.User = "hanzo/alice"
log.Action = action
log.Object = `{"from":{"insights":true,"training":""},"to":{"insights":true,"training":"granted"}}`
log.SetId(key("hanzo", name))
if err := log.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed %s: %v", name, err)
}
}
// Forging the grant. Without the gate an org admin posts a "consent-training"
// row saying a member agreed, and nothing downstream can tell it from the row
// the consent endpoint writes — same action, same shape, same trail.
func TestCreateRefusesAPlatformAction(t *testing.T) {
h := &Handler{db: auditTestDB(t)}
for _, action := range []string{
schema.ActionConsentTraining,
schema.ActionIssueUserToken,
schema.ActionMintUserKeys,
schema.ActionRevokeUserKeys,
schema.ActionTokenExchange,
} {
t.Run(action, func(t *testing.T) {
_, err := h.Create(context.Background(), &Input{
Owner: "hanzo", Name: "forged-" + action, Action: action,
})
if err == nil {
t.Fatalf("the audit CRUD minted a %q row", action)
}
if _, err := orm.Get[schema.AuditLog](h.db, key("hanzo", "forged-"+action)); err == nil {
t.Fatal("the row was written anyway")
}
})
}
}
// Erasing the refusal. A row recording that somebody declined is exactly the row
// an org with an interest in training on their data would want gone.
func TestDeleteRefusesAPlatformRow(t *testing.T) {
db := auditTestDB(t)
h := &Handler{db: db}
seedPlatformRow(t, db, "evidence", schema.ActionConsentTraining)
if _, err := h.Delete(context.Background(), &Ref{Owner: "hanzo", Name: "evidence"}); err == nil {
t.Fatal("a platform-written consent row was deleted through the audit CRUD")
}
if _, err := orm.Get[schema.AuditLog](db, key("hanzo", "evidence")); err != nil {
t.Fatalf("the row is gone: %v", err)
}
}
// Rewriting it, which is the quieter version of erasing it: flip the recorded
// answer and the trail still has a row, just not a true one.
func TestUpdateRefusesAPlatformRow(t *testing.T) {
db := auditTestDB(t)
h := &Handler{db: db}
seedPlatformRow(t, db, "evidence", schema.ActionConsentTraining)
_, err := h.Update(context.Background(), &Input{
Owner: "hanzo", Name: "evidence", Action: schema.ActionConsentTraining,
Object: `{"from":{"training":"granted"},"to":{"training":"granted"}}`,
})
if err == nil {
t.Fatal("a platform-written consent row was rewritten through the audit CRUD")
}
stored, err := orm.Get[schema.AuditLog](db, key("hanzo", "evidence"))
if err != nil {
t.Fatal(err)
}
if stored.Object != `{"from":{"insights":true,"training":""},"to":{"insights":true,"training":"granted"}}` {
t.Fatalf("the object was altered: %s", stored.Object)
}
}
// And the way in through the side door: write an ordinary row you are allowed to
// write, then RELABEL it with the platform's action.
func TestUpdateRefusesRelabellingIntoTheReservedNamespace(t *testing.T) {
db := auditTestDB(t)
h := &Handler{db: db}
if _, err := h.Create(context.Background(), &Input{
Owner: "hanzo", Name: "mine", Action: "my-own-thing",
}); err != nil {
t.Fatalf("an ordinary create was refused: %v", err)
}
_, err := h.Update(context.Background(), &Input{
Owner: "hanzo", Name: "mine", Action: schema.ActionConsentTraining,
Object: `{"to":{"training":"granted"}}`,
})
if err == nil {
t.Fatal("an ordinary row was relabelled into the platform's namespace")
}
stored, _ := orm.Get[schema.AuditLog](db, key("hanzo", "mine"))
if stored == nil || stored.Action != "my-own-thing" {
t.Fatalf("the action was changed: %+v", stored)
}
}
// The gate must not confiscate the surface: a customer's own trail keeps working
// end to end, including correction and deletion of their own rows.
func TestAnOrdinaryRowIsStillFullyWritable(t *testing.T) {
db := auditTestDB(t)
h := &Handler{db: db}
ctx := context.Background()
if _, err := h.Create(ctx, &Input{Owner: "hanzo", Name: "r1", Action: "deploy", Object: "a"}); err != nil {
t.Fatalf("create: %v", err)
}
if _, err := h.Update(ctx, &Input{Owner: "hanzo", Name: "r1", Action: "deploy", Object: "b"}); err != nil {
t.Fatalf("update: %v", err)
}
got, err := h.Get(ctx, &Ref{Owner: "hanzo", Name: "r1"})
if err != nil || got.Object != "b" {
t.Fatalf("get: %v %+v", err, got)
}
if _, err := h.Delete(ctx, &Ref{Owner: "hanzo", Name: "r1"}); err != nil {
t.Fatalf("delete: %v", err)
}
}
// An action that merely LOOKS like a platform one is ordinary. The reserved set
// is exact, so the gate neither over-reaches nor can be slipped past by a near
// miss that a later reader would mistake for the real thing.
func TestTheReservedSetIsExact(t *testing.T) {
for _, near := range []string{
"consent", "consent-Training", "CONSENT-TRAINING", "consent-training ",
" consent-training", "consent-training-x", "x-consent-training", "",
} {
if schema.PlatformWritten(near) {
t.Fatalf("PlatformWritten(%q) = true — the gate over-reaches into customer actions", near)
}
}
for _, exact := range []string{
schema.ActionConsentTraining, schema.ActionIssueUserToken,
schema.ActionMintUserKeys, schema.ActionRevokeUserKeys, schema.ActionTokenExchange,
} {
if !schema.PlatformWritten(exact) {
t.Fatalf("PlatformWritten(%q) = false — a platform action is not reserved", exact)
}
}
}
+37
View File
@@ -0,0 +1,37 @@
// Code generated by zipdoc; DO NOT EDIT.
package auditlogs
import (
"github.com/zap-proto/zip"
)
func init() {
zip.Describe("GET /v1/iam/audit-logs", zip.Doc{
Description: "Returns your organization's audit trail, newest first — who did\nwhat, when, and from where. It is the record you reach for during a security\nreview or an incident.\n\nYou see your own organization's audit trail and no one else's; which organization that\nis comes from your credentials, not from the request.",
Fields: map[string]string{
"Model[github.com/hanzoai/iam/pkg/schema.AuditLog].id": "Persisted fields",
},
})
zip.Describe("POST /v1/iam/audit-logs", zip.Doc{
Description: "Records an audit entry, so activity from your own systems lands in the\nsame trail as everything the Hanzo Cloud records for you.",
Fields: map[string]string{
"Model[github.com/hanzoai/iam/pkg/schema.AuditLog].id": "Persisted fields",
},
})
zip.Describe("POST /v1/iam/audit-logs/delete", zip.Doc{
Description: "Removes an audit entry. Retention policy is normally what should expire\na trail; deleting by hand leaves a gap a reviewer will notice.",
})
zip.Describe("POST /v1/iam/audit-logs/get", zip.Doc{
Description: "Returns one audit entry in full: the action, the person or key behind it,\nand the request it came in on.",
Fields: map[string]string{
"Model[github.com/hanzoai/iam/pkg/schema.AuditLog].id": "Persisted fields",
},
})
zip.Describe("POST /v1/iam/audit-logs/update", zip.Doc{
Description: "Corrects an audit entry. The trail is append-only in normal operation\nand nothing in the Hanzo Cloud rewrites it — this exists for an administrator\nto correct an entry their own systems recorded wrongly.",
Fields: map[string]string{
"Model[github.com/hanzoai/iam/pkg/schema.AuditLog].id": "Persisted fields",
},
})
}
+882
View File
@@ -0,0 +1,882 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
// Package authz is the IAM v2 authorization seam in front of the Phase-1 entity
// CRUD, which is otherwise unauthenticated — the door an attacker would walk
// through to overwrite an admin-owned signing cert and forge tokens. It is two
// orthogonal decisions, never braided:
//
// - AUTHENTICATION — the Guard middleware, registered ONCE via app.Use, AFTER the
// public group and BEFORE the authed routes. Public (pre-authentication)
// routes are registered first, so a matched one terminates fiber's middleware
// walk and the Guard never runs on it — public vs gated is structural (which
// group a route is on), not an allow-list. Every request the Guard wraps must
// carry a verified bearer; the resolved Principal is attached to the request
// context for the authorization decision and audit. Fails closed (401).
//
// - AUTHORIZATION — the Authorize hook, installed ONCE via app.Authorize. It
// runs at the framework's op-invoke seam, on the DECODED typed input the
// handler will act on, for REST and MCP alike. The value it authorizes is by
// construction the value the handler binds: there is no second parse of the
// body for it to diverge from. Fails closed (403).
//
// Splitting the two removes the defect a single body-reparsing middleware had:
// authorizing a target extracted from the raw bytes divergently from where the
// handler binds it. A write's target now comes from the one decode the handler
// itself runs on. A read's target rides in the query string (a GET has no body
// for the op seam to decode), so the Guard authorizes reads there; a read invoked
// over MCP DOES decode a target into its input, and the op seam authorizes that.
//
// Three scopes, never conflated (conflation is privilege escalation):
//
// - SuperAdmin — the principal's organization is the reserved "admin" org.
// The ONLY cross-tenant scope. Required for every write to a platform-owned
// (admin/built-in) resource: the signing-cert poisoning gate, admin-scoped
// application/provider registration, every reserved surface.
// - Org admin — IsAdmin, scoped to its OWN organization. Manages every
// resource its org owns; never another org's, never a platform-owned one.
// - Regular user — self-service only: reading its own user record.
//
// One predicate governs SuperAdmin everywhere: the principal's organization is
// "admin". That organization comes from the token SUBJECT — the authenticated
// principal's own owner/name — never from the token's `owner`/`organization`
// claims. Those name the APPLICATION's org and diverge from the user's org for a
// shared app, so trusting them would let a tenant user sign in through a shared
// admin-org app and read as SuperAdmin. Authenticity, expiry, algorithm, and
// signing-key trust are delegated to the same oidc.VerifyToken every protected
// route already uses; the org-admin flag comes from the loaded user record, the
// authoritative source (it is not a token claim).
package authz
import (
"context"
"crypto/subtle"
"errors"
"net/http"
"reflect"
"strings"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/oidc"
"github.com/hanzoai/iam/pkg/store"
)
// adminOrg is the reserved organization whose membership IS SuperAdmin — the one
// cross-tenant scope, the one predicate. The broader reserved-owner set
// {admin, built-in} the poisoning gate protects lives in ONE place,
// store.IsSigningCertOwner, shared with the token verifier and the JWKS.
const adminOrg = "admin"
// Principal is the identity a gated request acts as, resolved from a verified
// bearer. Org is the tenant (the authenticated principal's own org, from the
// subject); User is its name within that org (empty for a machine token); Admin
// is the org-admin flag; Super is the SuperAdmin predicate (Org == adminOrg).
type Principal struct {
Org string
User string
// App is the application NAME when the request authenticated as a confidential
// client (client_secret_basic), and "" for every human. An app principal is
// never Admin and never Super — its whole authority is its capability allowlist
// (cap.go), so a leaked client credential can neither read another tenant nor
// touch signing material.
App string
// AppOwner is the OWNING organization of that application row — "admin"/"built-in"
// for a platform app, the tenant's own org for a customer app. It is NOT App's
// served Organization. A capability (cap.go Allowed) is granted ONLY when this is
// a reserved platform signing owner, so a tenant that registers an app whose NAME
// (or clientId) collides with a platform console inherits none of its authority:
// the allowlist keys on the name, and the owner-pin binds that name to the
// platform. Empty for every human.
AppOwner string
// AppCert is the NAME of the signing cert that application row references
// (schema.Application.Cert). It is carried on the principal so the self-read
// clause can permit an app exactly one cert — its own — without the pure
// authorize() decision having to reach into the store. Empty for every human.
AppCert string
Admin bool
Super bool
}
type ctxKey struct{}
// From returns the Principal the Guard attached to ctx for a gated request, and
// whether one is present (public routes carry none).
func From(ctx context.Context) (*Principal, bool) {
p, ok := ctx.Value(ctxKey{}).(*Principal)
return p, ok
}
// Scope resolves the owner an org-scoped request is bound to. It is the ONE
// place the rule lives, and the rule is:
//
// AN ORG-SCOPED REQUEST IS HONOURED OR REFUSED, NEVER SILENTLY REINTERPRETED.
//
// A SuperAdmin — the only cross-tenant scope — is bound to the owner it names
// (empty = every tenant). Everyone else is bound to its OWN org and may say so:
// naming its own org, or naming none, both resolve to it. Naming a DIFFERENT org
// is refused, because the one thing this function must never do is answer a
// request about org B with org A's rows.
//
// It used to return p.Org for ANY owner, silently discarding the parameter.
// Measured against production 2026-07-28 with the hanzo-console credential (home
// org hanzo): ?owner=lux, ?owner=zoo and ?owner=nonexistent-org-xyz each answered
// 200/ok with 262 `hanzo` accounts. No tenant's rows escaped IAM — the pin held —
// so it was not a confidentiality breach here; it was MISATTRIBUTION, which is
// worse in one specific way. Nothing in the status code, the `status` field, the
// message or the count said the filter had been dropped, so the caller believed
// it held tenant B while holding tenant A. An operator asked for lux, was handed
// 262 hanzo accounts, and was one filter-and-delete from purging the wrong
// tenant. Downstream it WAS a leak: cloud's IAM edge (cloud/iam_edge.go) checks
// ?owner= against the calling tenant and then forwards it under ONE confidential
// client, so every tenant's team page asked for its own org and was served the
// edge credential's org instead. A pin that lies composes into a breach; a
// refusal cannot.
//
// The refusal is NOT an org-existence oracle, and by construction rather than by
// care: the decision is taken from the verified principal alone and never touches
// the store, so `lux` (a real tenant), `built-in` (reserved) and
// `nonexistent-org-xyz` (a fabrication) are the same comparison and the same
// bytes out. Its text names the CREDENTIAL's org, never the requested one. That
// is the same collapse cloud's per-org KMS store makes for this class of leak —
// every spelling the caller may not have routes to ONE existence-independent
// answer. It differs only in WHICH answer: KMS has no org parameter to refuse (it
// reads the org from the token), so absence is its only observable and it answers
// 404; here the org is a stated request parameter, so there IS an authorization
// decision to report, and reporting it is the entire point.
//
// An empty p.Org is refused too. A non-super with no org has no org scope, and
// returning "" would resolve to "no filter" — every tenant's rows, which is the
// exact branch TestListRoutesNeverLeakAnotherTenant exists to keep shut. Fail
// closed.
func Scope(ctx context.Context, owner string) (string, error) {
p, ok := From(ctx)
if !ok {
return "", zip.ErrForbidden("no principal")
}
if p.Super {
return owner, nil
}
if p.Org == "" || (owner != "" && owner != p.Org) {
return "", errForeignOrg(p)
}
return p.Org, nil
}
// errForeignOrg is the refusal a foreign owner earns. It is built from the
// PRINCIPAL's own org and never from the requested one, so every org the caller
// may not have — real, reserved, or invented — produces the byte-identical
// answer. Naming the caller's own org discloses nothing (its rows already carry
// it) and is what turns a bare "forbidden" into a diagnosis: you are pinned here,
// you asked for somewhere else.
func errForeignOrg(p *Principal) error {
if p.Org == "" {
return zip.ErrForbidden("forbidden: this credential carries no organization scope")
}
return zip.ErrForbidden("forbidden: this credential is scoped to organization " + p.Org)
}
// Deny renders a Scope/ScopeFor refusal in the envelope the caller's surface
// speaks — the SAME shaping the Guard's own refusal uses, so one refusal looks
// the same whether it was raised before the handler or inside it. A handler that
// answered it with httpx.Err would send HTTP 200 carrying {"status":"error"},
// which is how a refusal gets logged as a success.
func Deny(c *zip.Ctx, err error) error { return refuse(c, http.StatusForbidden, err.Error()) }
// ScopeFor resolves the owner a compat READ should query — the same decision as
// Scope, except that a self-read addresses its own owner verbatim.
//
// Scope pins a non-SuperAdmin to p.Org, which for an app principal is the tenant it
// SERVES (hanzo), not the org that OWNS its row (admin). So a confidential client
// authorized by the Guard to read admin/hanzo-cloud then had the query rewritten to
// hanzo/hanzo-cloud and got "the entity does not exist" — authorized and still
// unable to read itself, a 200 that is functionally the 403 it replaced.
//
// Rather than loosen Scope (whose binding IS the tenant gate on the handler-authorized
// paths — SCIM, service-accounts, memberships), the ONE self-read clause is asked
// again here, through the same authorize() it is defined in. There is no second copy
// of the rule: if authorize would admit this exact read, the owner it admitted is the
// owner we query; otherwise Scope decides, and Scope now REFUSES a foreign owner
// rather than rewriting it. That is the honour-or-refuse rule reaching this path
// too: a grant honours the org it names and answers with THAT org's row, correctly
// attributed; everything else is refused. Neither branch can hand back a row the
// request did not ask for.
func ScopeFor(ctx context.Context, path, owner, name string) (string, error) {
if p, ok := From(ctx); ok && owner != "" && authorize(p, "GET", entityOf(path), owner, name) {
if p.Super || (p.App != "" && owner == p.AppOwner) {
return owner, nil
}
}
return Scope(ctx, owner)
}
// Can reports whether the ctx principal may perform `method` on the entity's
// (owner, name) — the SAME policy the op-invoke seam (Authorize) applies, exposed
// for a RAW handler that does not pass through app.Authorize (e.g. SCIM, whose
// writes call the CRUD directly). Owner-pinning via Scope alone is NOT sufficient
// for a write: it enforces tenant isolation but not the admin/self clause, so a
// raw handler MUST call this. Fails closed when no principal is present.
func Can(ctx context.Context, method, entity, owner, name string) bool {
p, ok := From(ctx)
if !ok {
return false
}
return authorize(p, method, entity, owner, name)
}
// IsSuper reports whether the ctx principal is a SuperAdmin — used by a raw
// handler to gate a privileged field (e.g. provision-don't-promote: only a super
// may set isAdmin). Fails closed when no principal is present.
func IsSuper(ctx context.Context) bool {
p, ok := From(ctx)
return ok && p.Super
}
// CanSetOrg reports whether principal p may point a resource at organization
// `org` — the tenant an application SERVES (the org every credential minted
// through that app lands in), authorized EXACTLY as an owner target through the
// one policy: a SuperAdmin may set any org; anyone else only their OWN org, never
// a reserved platform org (admin/built-in — the SuperAdmin/signing vector) nor
// another tenant (cross-tenant mint). It is the gate the application create/update
// path applies to the Organization FIELD — closing the hole where authorizing only
// the top-level Owner let a tenant admin register an app whose Organization named
// the admin org (SuperAdmin) or a victim tenant. Fails closed on a nil principal.
func CanSetOrg(p *Principal, org string) bool {
if p == nil {
return false
}
return authorize(p, "POST", "applications", org, "")
}
// Optional resolves the Principal a PUBLIC route's caller happens to carry, or
// nil when the request is anonymous or its bearer does not verify. The Guard
// admits a public path WITHOUT resolving a principal (a browser must reach the
// pre-auth surface before it holds a token), so From() is empty there — a public
// handler that legitimately honors an authenticated caller resolves it here.
//
// It is the same fail-closed resolution every gated route runs (one verifier,
// one user load, one revocation check); only the outcome differs — a bad bearer
// is nil rather than a 401, because the caller's flow continues anonymously.
// A handler must therefore treat a nil Principal as "anonymous", never as an
// error, and must never widen authority on the strength of this alone: it proves
// only WHO the caller is, not that the caller INTENDED this request (the wallet
// link branch pairs it with a same-site check for exactly that reason).
func Optional(c *zip.Ctx, db orm.DB) *Principal {
p, err := principal(c, db)
if err != nil {
return nil
}
return p
}
// Fail-closed reasons. The Guard collapses all of them to one opaque 401 so a
// prober cannot tell a bad signature from an expired token from a revoked user.
var (
errNoBearer = errors.New("authz: no bearer")
errNoSubject = errors.New("authz: token subject carries no org")
errRevoked = errors.New("authz: principal is forbidden or deleted")
)
// isRead reports whether a method addresses its target through the query string
// rather than a body: a GET (or HEAD) has no body for the op-invoke seam to
// decode, so its target is authorized in the Guard. Every other method carries a
// body decoded once by the op and is authorized at that seam.
func isRead(method string) bool { return method == "GET" || method == "HEAD" }
// ReadTarget extracts the (owner, name) a GET addresses, from the query string.
// A native typed read files them as `?owner=&name=`; the the legacy surface compat verbs
// (get-user, get-organization, …) file them as `?id=<owner>/<name>`. Explicit
// owner/name win; the id split is a fallback only when owner is absent, so this
// can only make an id-based read's authorization MORE precise than the empty
// target it resolves to today (which fail-closed denies every non-super). It
// never widens: the tenant rule still pins owner to the principal's org, and the
// handler independently re-scopes the query owner through Scope, so a request
// that spells one owner in `?owner` and another in `?id` cannot read across
// tenants — the authorized owner and the queried owner are both pinned.
//
// It is exported so the compat read aliases resolve their target through the
// SAME function the Guard authorizes with: one extraction, so a handler can
// never address a row the Guard did not authorize.
func ReadTarget(c *zip.Ctx) (owner, name string) {
owner, name = c.Query("owner"), c.Query("name")
if owner == "" {
if id := c.Query("id"); id != "" {
if o, n, ok := strings.Cut(id, "/"); ok && o != "" {
return o, n
}
// A BARE id carries the name alone — `?id=cert-hanzo`, which is how a
// relying party asks for the cert its application row names. Previously
// this resolved to NO target at all (owner "" AND name ""), so the
// authorizer was handed nothing to reason about and fail-closed denied
// every caller including the one reading its own. Resolving the name half
// can only make the decision MORE precise: an empty owner still fails the
// tenant rule (owner != p.Org) and IsReservedOrg(""), so no clause is
// widened by knowing the name — only the self-read clause, which pins that
// name to the principal's own cert, can act on it.
if !strings.Contains(id, "/") {
return "", id
}
}
}
return owner, name
}
// handlerAuthorizedPrefixes are path subtrees whose target rides in the PATH, not
// the query — the Guard authenticates them (a bearer is still required) but does
// NOT pre-authorize the read; the handler authorizes on the path id via
// authz.Scope. SCIM (RFC 7644, /v1/iam/scim/v2/Users/{id}) is path-targeted, so it
// belongs here. This is the read analogue of a write deferring to the op-invoke
// seam — the target is authorized where it is bound, not guessed from the query.
// get-organization-projects (and its workspace tier, get-organization-workspaces)
// is the the legacy surface read verb whose target rides in ?organization= (the
// ScopeSwitcher's project/workspace list), not ?owner=/?id=/the path, so the Guard
// cannot pre-authorize it generically; the handler scopes it through authz.Scope
// instead (the read analogue of SCIM's path-targeted authorization).
// get-memberships is the the legacy surface alias of /v1/iam/memberships whose target rides in
// ?user=/?org=, so it belongs here for the same reason its REST twin does — the
// membership list handler's own scoped() check is the tenant gate.
var handlerAuthorizedPrefixes = []string{"/v1/iam/scim/", "/v1/iam/get-organization-projects", "/v1/iam/get-organization-workspaces", "/v1/iam/service-accounts", "/v1/iam/memberships", "/v1/iam/get-memberships"}
// handlerAuthorizedExact are SINGLE routes (not subtrees) the handler authorizes
// itself. get-user is here — not a prefix — because "/v1/iam/get-user" IS a prefix
// of "/v1/iam/get-users" (the generic, Guard-authorized list): a prefix entry would
// silently strip the Guard's read gate from get-users and let a request parameter
// narrow rather than deny a cross-tenant list. get-user carries a `?accessKey=`
// variant whose target is a secret key (no owner/name for the Guard to authorize),
// so the get-user handler authorizes BOTH its variants — the owner/name read through
// the SAME authz.Can the Guard would have applied, the key read behind CapKeyResolve.
//
// resolve-key is here for the same reason: its target is a publishable pk- riding in
// ?accessKey= (no owner/name for the Guard to authorize), and its handler authorizes
// itself behind CapPublishableResolve, returning ONLY the org — never a principal.
var handlerAuthorizedExact = map[string]bool{
"/v1/iam/get-user": true,
"/v1/iam/resolve-key": true,
}
// pathAuthorized reports whether path is handler-authorized: an exact single-route
// match, or under a handler-authorized subtree.
func pathAuthorized(path string) bool {
if handlerAuthorizedExact[path] {
return true
}
for _, p := range handlerAuthorizedPrefixes {
if strings.HasPrefix(path, p) {
return true
}
}
return false
}
// refuse writes the Guard's rejection in the envelope the CALLER can actually
// parse, so one surface answers in one shape.
//
// The the legacy surface-compatible verbs (/v1/iam/get-user, add-organization, …) are a
// contract: every client of them branches on a STRING `status` of "ok"/"error" and
// reads `msg`. The handlers honour that — get-account answers
// {"status":"error","msg":"please sign in first"} — but the Guard short-circuits
// BEFORE any handler runs, and zip's own error shape is {"status":401,
// "error":"…"}: `status` an int where the client expects a string, and the text
// under `error` where the client reads `msg`. So the same endpoint spoke two
// languages depending on whether it got far enough to answer for itself, and a
// client written against the documented one silently saw neither an ok nor a
// recognizable error. The fix belongs here, at the source, not in every client
// learning to tolerate both.
//
// Only the compat surface is reshaped. The native REST/OIDC routes keep zip's
// numeric-status error, which is THEIR contract — this is one envelope per
// surface, not one envelope everywhere. The HTTP status code is unchanged in both
// cases (401/403), so anything reading the code rather than the body is unaffected.
func refuse(c *zip.Ctx, status int, msg string) error {
if legacyVerb(c.Path()) {
return c.JSON(status, httpx.Response{Status: "error", Msg: msg})
}
if status == 401 {
return zip.ErrUnauthorized(msg)
}
return zip.ErrForbidden(msg)
}
// legacyVerbs are the request-shaped prefixes of the compat surface — the
// verb-per-path the legacy surface spelling (get-/add-/update-/delete-) that the console BFF,
// the @hanzo/iam SDK and the cloud clients hard-code. The native surface is
// noun-shaped (/v1/iam/users, /v1/iam/organizations), so the verb prefix is what
// distinguishes the two contracts without a second list to keep in sync.
var legacyVerbs = []string{"get-", "add-", "update-", "delete-"}
// legacyVerb reports whether path is one of the compat verbs.
func legacyVerb(path string) bool {
const p = "/v1/iam/"
if !strings.HasPrefix(path, p) {
return false
}
rest := path[len(p):]
for _, v := range legacyVerbs {
if strings.HasPrefix(rest, v) {
return true
}
}
return false
}
// Guard is the AUTHENTICATION middleware. Mount it with Use on the GROUP that
// holds the routes it gates — routes.Route registers IAM's authed surface on
// such a group — never on the app itself. zip places middleware by depth: on the
// app it becomes router middleware, a barrier in front of every request the
// binary will ever serve, so IAM embedded beside other subsystems authenticated
// THEIR routes against IAM's store and 401'd every valid request. Inside a
// group it is composed into that group's own route chains and reaches nothing
// else.
//
// Public vs gated stays structural — a public route is one registered on the
// pre-authentication group instead of on the guarded one, never an entry in an
// allow-list — and scoping now runs in the other direction too: a sibling
// subsystem sharing the app is not IAM's to authenticate.
//
// Every route it wraps requires a valid bearer (401 otherwise) whose Principal
// is attached to the request context for the authorization hook downstream. A
// read's authorization target rides in the query string, so reads are authorized
// here; a write's rides in the body, decoded once by the op and authorized at
// the op-invoke seam (Authorize) on that exact decoded value — this middleware
// never re-parses a write body, which is what let the old target extraction
// diverge from execution.
func Guard(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
// A CORS preflight carries no credentials BY DEFINITION — the browser
// strips them — so authenticating one is a category error: it can only
// ever fail. It also fails usefully for nobody, because a 401 preflight
// is indistinguishable to the page from "this origin is not allowed",
// which is how a legitimately-registered SPA gets told its own IdP is
// unreachable. Whether the path is actually open to a browser is CORS's
// question, already answered upstream (internal/cors): if it opened the
// path it terminated the walk with 204 and we never run; if it did not,
// falling through emits no allow-origin header and the browser blocks
// the real request anyway. Either way this is not a request to authorize.
if c.Method() == http.MethodOptions {
return c.Continue()
}
p, err := principal(c, db)
if err != nil {
return refuse(c, 401, "authentication required")
}
// A path-targeted resource (SCIM: /Users/{id}) carries its target in the
// PATH, not the query — so, like a write whose target rides in the body, the
// Guard authenticates (bearer required, principal attached) and the handler
// authorizes via authz.Scope on the path id. The Guard never authorizes an
// empty query target for these (which would fail-closed deny every non-super
// before the handler could scope). Every other read is authorized here.
if !pathAuthorized(c.Path()) {
rOwner, rName := ReadTarget(c)
if isRead(c.Method()) && !authorize(p, c.Method(), entityOf(c.Path()), rOwner, rName) {
return refuse(c, 403, "forbidden")
}
}
c.SetContext(context.WithValue(c.Context(), ctxKey{}, p))
return c.Continue()
}
}
// mcpPath is where zip mounts the MCP door. zip exports SpecPath and DocsPath
// but keeps this one unexported (zip/mcp.go defaultMCPPath), and IAM never moves
// it — MCPConfig.Path is left at its default wherever IAM builds an app.
const mcpPath = "/mcp"
// Control gates the framework's OWN projections: the MCP door, the OpenAPI
// document and the docs UI. It is the SECOND mounting of the one Guard, and it
// exists because those three addresses are not routes anybody registered.
//
// zip installs them at Build, directly onto the served app's router, with no
// middleware and after every entry in the program (zip/build.go materialise:
// "control routes are not entries at all"). A scoped seam therefore cannot reach
// them — a group's middleware is composed into that group's own route chains,
// and these are in no group — so the only seam that can is a depth-0 one.
//
// That is the whole reason authentication is mounted twice. Gating them matters
// because the MCP door dispatches tools/call straight into the typed ops: it is
// the same admin CRUD the REST surface exposes, reached by a different
// transport, and the op-invoke hook alone does not close it (Authorize admits a
// read whose decoded target is empty, on the REST-shaped assumption that the
// Guard already ran). Unauthenticated, that combination lists users.
//
// Narrow by construction, and that is what keeps it from being the bug it
// replaces: it is a depth-0 handler, so it is consulted on every request, but it
// ACTS only on the three addresses the framework itself owns and hands every
// other path straight on. A sibling subsystem's route is not one of them.
func Control(db orm.DB) zip.Handler {
guard := Guard(db) // one authentication decision, mounted twice, never copied
return func(c *zip.Ctx) error {
switch c.Path() {
case mcpPath, zip.SpecPath, zip.DocsPath:
return guard(c)
}
return c.Continue()
}
}
// Authorize is the AUTHORIZATION hook. It is installed with Authorize on the
// GROUP the typed ops register on — never on the app, which on a shared binary
// would make IAM's rules the HOST's and refuse a sibling subsystem's ops 403 —
// and the framework runs it at every typed op's invoke seam: after the request
// is decoded into its typed In and validated, before the handler runs, for REST
// and MCP alike. It authorizes the DECODED target: the exact (owner, name) the
// handler will bind, read from the same struct the handler runs on, so the value
// authorized cannot diverge from the value written.
//
// A REST read carries its target in the query string, not the body, so its
// decoded In is empty and the Guard already authorized it there — such a call is
// admitted here (owner == ""). Every write, and any read invoked over MCP (whose
// arguments DO decode a target into In), is authorized against authorize().
//
// Every typed op is authed by construction — the public surface is raw handlers
// on the unguarded group, none of which is a typed op — so this hook needs no
// public bypass: whenever it runs, the Guard has already run and attached a
// principal (over REST, on the guarded group the op registered on; over MCP, on
// the /mcp route authz.Control gates). That second clause is why Control is not
// optional. The owner == "" read admitted just below trusts the Guard to have
// authorized the query-string target, and over MCP the arguments decode into In
// rather than the query — so an ungated door would reach this line with no
// principal, no decoded target, and an admission.
func Authorize(ctx context.Context, op zip.Op, in any) error {
owner, name := decodedTarget(in)
if owner == "" && isRead(op.Method) {
return nil // REST read: target rode in the query, authorized by the Guard
}
p, present := From(ctx)
if !present {
return zip.ErrForbidden("forbidden") // gated op with no principal: fail closed
}
if !authorize(p, op.Method, entityOf(op.Path), owner, name) {
return zip.ErrForbidden("forbidden")
}
return nil
}
// authorize is the pure authorization decision: may p act on a resource owned by
// `owner` (named `name`) on the given entity? The order IS the policy:
//
// 1. SuperAdmin may do anything — the only cross-tenant scope.
// 2. A platform-owned resource — one under a RESERVED system org (store.IsReservedOrg:
// admin/built-in, the signing owners, PLUS "app", the service-principal org) — is
// writable only by a SuperAdmin. This single rule is the signing-cert poisoning
// gate, the admin-scoped app/provider registration gate, the built-in-org gap, AND
// the service-org ("app") consistency the self-service surfaces already enforce, all
// at once: a built-in-org principal is not SuperAdmin (that is admin only), so it
// cannot write a built-in-owned signing cert; and no capability app nor "app"-org
// admin can land a user under owner="app" (a platform identity) — the raw CRUD now
// consults the SAME predicate signup/onboarding do, so the reserved set never
// drifts between surfaces.
// 3. Tenant isolation: a normal principal may act only within its OWN org. An
// empty or foreign owner is refused — the target org is bound to the
// principal, never trusted from the request.
// 4. Inside its own org, an org admin manages everything; a regular user may
// only READ its own user record (self-service). The users entity serves
// reads as GET and writes as POST, so gating the self clause to GET keeps a
// regular user from writing its own record — a raw entity write would
// otherwise let it carry isAdmin and self-promote. Privileged self-mutation
// is the Phase-5 provision-don't-promote concern; here it is closed by
// denial.
func authorize(p *Principal, method, entity, owner, name string) bool {
if p.Super {
return true
}
// An app may READ THE ROW IT AUTHENTICATED AS, and no other. Reading its own
// registration is the ordinary bootstrap of an OIDC relying party — it is how a
// client discovers its own cert, redirect URIs and enabled methods — and it
// reveals nothing the holder of that client's credential does not already have.
//
// The owner-pin that closed the "every client credential is a global admin"
// escalation is not wrong; it was missing this case, and applications are not in
// capFor(), so a confidential client could not read even itself and every cloud
// deploy 403'd on its own bootstrap.
//
// Narrow by construction, in four ways at once: only an app principal (a human's
// authority is decided below), only a READ (never a write to its own row — that
// would let a client widen its own redirect URIs or grants), only the
// applications entity, and only the exact (AppOwner, App) pair the request
// authenticated as. Both halves of the key must match, so this is self-read and
// not "apps may read applications": a sibling in the same org differs in `name`
// and stays refused, and admin/<app> vs <tenant>/<app> — the same NAME under a
// different owner — differs in `owner`, so neither direction of that collision
// is admitted. That pairing is the same one Allowed() pins capabilities to.
if p.App != "" && isRead(method) {
// its own application row — both halves of the key must match
if entity == "applications" && owner != "" && owner == p.AppOwner && name == p.App {
return true
}
// ...and the ONE signing cert that row references. A relying party cannot
// bootstrap without it: InitAuthConfig reads its application, then reads
// application.Cert, then InitConfig(cert.Certificate) — so granting only the
// application fixes one line and panics identically on the next.
//
// Scoped to the cert its OWN application names, never "apps may read certs":
// name must equal the cert on the authenticated row, so an app cannot walk to
// another brand's signing cert. Read-only, and the read is masked anyway
// (Cert.Mask blanks PrivateKey and AccessSecret), so what crosses the wire is
// the PUBLIC certificate this client already has to trust to verify our
// tokens. A bare `?id=cert-hanzo` carries no owner half, so an empty owner is
// admitted ONLY here, where the cert NAME is already pinned to this principal.
// The owner half varies by CALLER, so all three shapes are admitted — what
// pins this read is the NAME, not the owner. ai/internal/iam/cert.go sends
// "<IAM_ORG>/<name>" (hanzo/cert-hanzo), GetApplication hardcodes admin/, and
// a bare id carries no owner at all. Measured: admin/cert-hanzo and
// hanzo/cert-hanzo are two rows seeded 3ms apart carrying the IDENTICAL 4096-bit
// modulus, both matching the single JWKS kid=cert-hanzo — so the owner half
// selects between duplicates of one keypair, not between different keys.
//
// name == p.AppCert is the whole gate and it is unchanged: an app reaches the
// one cert its own application row names and no other, whichever owner it
// spells. Read-only, and Cert.Mask blanks PrivateKey, so this discloses the
// PUBLIC key already published at /v1/iam/.well-known/jwks.
if entity == "certs" && p.AppCert != "" && name == p.AppCert &&
(owner == "" || owner == p.AppOwner || owner == p.Org) {
return true
}
// An org's OWN PaaS machine identity may READ that org's projects, and
// nothing else. This is how cloud's platform resolves a tenant's
// projects from the canonical store here instead of a second embedded
// database — the split-brain where a project created at /v1/iam was
// invisible to the PaaS and vice versa.
//
// Narrow by construction, four ways at once, mirroring the self-read
// blocks above: only a READ; only the projects entity; only the
// caller's OWN org (owner == p.Org, so one tenant's identity can never
// walk another's list); and only the identity the "<org>-platform-kms"
// contract names — the same string cloud's SanitizeIdentity recognises
// in order to DENY that principal SuperAdmin. The contract is the
// grant, stated once; no env allowlist to drift.
if entity == "projects" && owner != "" && owner == p.Org &&
p.App == p.Org+"-platform-kms" {
return true
}
}
if store.IsReservedOrg(owner) {
// The ONE exception to the reserved-owner gate is the tenant registry: every
// organization row is filed under the admin owner, but an org row is the
// TENANT'S own record, not platform trust material — a tenant reads its own
// org, its admin edits it, and an org-admin-capable confidential client
// manages orgs during onboarding (v1 requireAppCapability(CapOrgAdmin)).
// Certs, applications, providers, and users under a reserved owner
// (admin/built-in/app) stay SuperAdmin-only.
if entity != "organizations" {
return false
}
if p.App != "" {
return Allowed(p, CapOrgAdmin)
}
return name == p.Org && (isRead(method) || p.Admin)
}
// A confidential client's authority is its capability allowlist and nothing
// else — never Super, never Admin; an unmapped entity or unset allowlist denies.
if p.App != "" {
return Allowed(p, capFor(entity))
}
if owner == "" || owner != p.Org {
return false
}
if p.Admin {
return true
}
return method == "GET" && entity == "users" && name != "" && name == p.User
}
// owned is implemented by a typed input whose authorization target is NOT its
// top-level Owner/Name. The user create/update body nests the record under
// `user`, so its owner is in.User.Owner, not a top-level field; its AuthzTarget
// returns exactly what the handler binds — the handler calls the same method — so
// the value authorized is by construction the value written. Any future input
// that nests its owner implements this too: it is the ONE contract for nesting,
// so the seam never guesses which field the handler uses and never mistakes a
// read-only enrichment sub-struct (e.g. an application's resolved certObj, which
// carries its OWN owner) for the target.
type owned interface {
AuthzTarget() (owner, name string)
}
// decodedTarget returns the (owner, name) a decoded request addresses — exactly
// the values the handler will bind, read from the SAME decoded struct the handler
// runs on, so there is no second parse to diverge from. An input that nests its
// owner declares it via owned; every other input files its owner at the top level
// (directly, or promoted from an embedded record), read reflectively so no entity
// needs bespoke binding and an attacker-supplied nested sub-struct is never a
// target.
func decodedTarget(in any) (owner, name string) {
if o, ok := in.(owned); ok {
return o.AuthzTarget()
}
v := reflect.ValueOf(in)
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return "", ""
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return "", ""
}
return stringField(v, "Owner"), stringField(v, "Name")
}
// stringField returns the string value of the named field (traversing embedded
// anonymous fields via FieldByName), or "" when the field is absent or not a
// string. FieldByName does not descend named sub-fields, so it reads the record's
// own owner, never one nested under an unrelated field.
func stringField(v reflect.Value, name string) string {
f := v.FieldByName(name)
if f.IsValid() && f.Kind() == reflect.String {
return f.String()
}
return ""
}
// principal resolves the verified bearer into a Principal, failing closed on a
// missing/malformed/expired/wrong-key token (oidc.VerifyToken enforces the
// algorithm allowlist and trusted signing-cert resolution), a subject with no
// org, a store error, or a forbidden/deleted user. Org, Admin, and Super are
// read from the LOADED user record — authoritative — never from the token
// claims: SuperAdmin is a real, live member of the admin org, not a subject that
// merely names one. A subject with no user row (a client_credentials machine
// token, or a since-deleted user) authenticates but carries no admin or
// SuperAdmin authority and no self-service identity — org-scoped only, which on
// the raw CRUD authorizes to nothing until a later phase grants machine
// identities explicit scope. This closes the phantom-admin subject: a token for
// "admin/<nobody>" resolves to no authority, not SuperAdmin.
func principal(c *zip.Ctx, db orm.DB) (*Principal, error) {
if p, ok := app(c, db); ok {
return p, nil
}
bearer := httpx.Bearer(c)
if bearer == "" {
return nil, errNoBearer
}
ctx := c.Context()
claims, err := oidc.VerifyToken(ctx, db, bearer)
if err != nil {
return nil, err
}
// The subject is the principal's OWN stable identity, set server-side at mint and
// signed — a UUID for a v2 token, or "<owner>/<name>" pre-cutover. Resolve it to
// the live user through the ONE subject decoder (Id-or-name), and read Org/Admin/
// Super from the LOADED record — never from the `owner` claim (the app's org), so
// a token whose owner claim names admin but whose subject is a tenant user gets
// the tenant's authority, not the claim's (the org-confusion defense).
u, err := store.GetUserBySubject(ctx, db, claims.Subject)
if err != nil {
return nil, err // fail closed: cannot establish the principal
}
if u != nil {
if u.IsForbidden || u.IsDeleted {
return nil, errRevoked
}
return &Principal{Org: u.Owner, User: u.Name, Admin: u.IsAdmin, Super: u.Owner == adminOrg}, nil
}
// No user row. A machine token's subject is "<appOwner>/<appName>" — org-scoped
// to the app's owner half, carrying no admin/super authority. Anything else — an
// opaque UUID subject with no live user row (a since-deleted user, or a forgery
// the trusted-key verify already blocks) — establishes NO principal, fail closed.
owner, _, hasSlash := strings.Cut(claims.Subject, "/")
if !hasSlash || owner == "" {
return nil, errNoSubject
}
return &Principal{Org: owner}, nil
}
// app resolves an `Authorization: Basic <clientId>:<clientSecret>` credential into
// a confidential-client Principal — the transport every live server-side consumer
// authenticates with (RFC 6749 §2.3.1 client_secret_basic; cloud reads
// IAM_MINT_CLIENT_ID/SECRET and sends exactly this). The application NAME is the
// identity, because the capability allowlists key on the name.
//
// It is deliberately NOT an authority: the returned Principal is never Admin and
// never Super, so the ONLY thing it can do is what its name is allowlisted for
// (authorize → Allowed). This is what keeps the v1 "every confidential client is a
// global admin" hole closed as the transport is re-added.
//
// Fail-closed: an unparseable header, an unknown clientId, an application with no
// registered secret, an empty presented secret (a public client must never
// authenticate as an app), or a mismatch all report false — the caller then finds
// no bearer either and answers 401. The comparison is constant-time.
func app(c *zip.Ctx, db orm.DB) (*Principal, bool) {
id, secret, ok := httpx.Basic(c)
if !ok || id == "" || secret == "" {
return nil, false
}
a, err := store.GetApplicationByClientId(c.Context(), db, id)
if err != nil || a == nil || a.ClientSecret == "" {
return nil, false
}
if subtle.ConstantTimeCompare([]byte(a.ClientSecret), []byte(secret)) != 1 {
return nil, false
}
// AppOwner is the app row's OWNING org (a.Owner: "admin"/"built-in" for a platform
// app), NOT a.Organization (the tenant it SERVES). cap.go pins every capability to
// this being a reserved signing owner, so a tenant-owned app named/clientId'd like
// a console holds nothing. Org carries the served tenant, as before.
return &Principal{App: a.Name, AppOwner: a.Owner, AppCert: a.Cert, Org: a.Organization}, true
}
// entityOf returns the resource segment of an /v1/iam/<entity>[/verb] path, or
// "" for anything else (e.g. /mcp). Only the users entity needs distinguishing —
// its regular-user self-service rule — so every other segment is treated
// uniformly by the tenant rule.
func entityOf(path string) string {
const p = "/v1/iam/"
if !strings.HasPrefix(path, p) {
return ""
}
rest := path[len(p):]
if i := strings.IndexByte(rest, '/'); i >= 0 {
rest = rest[:i]
}
return entityNoun(rest)
}
// entityNoun folds the legacy VERB spelling of a path segment onto the entity
// noun the policy is written in: get-application -> applications, add-organization
// -> organizations. Both surfaces address the SAME rows, so they must resolve to
// the same entity — and they did not.
//
// This is what made the app self-read grant look inert in production. The native
// route /v1/iam/applications resolved to "applications" and matched; the compat
// alias /v1/iam/get-application resolved to the literal string "get-application",
// matched no clause, fell through to the reserved-owner gate and 403'd. Cloud calls
// the alias, so the grant never fired on the only path anyone uses.
//
// It is the wider bug too, not just this grant's: EVERY capability keyed on an
// entity was dead on the compat surface, because capFor("add-organization") is not
// capFor("organizations"). The allowlists that exist precisely so the brand consoles
// can manage orgs during onboarding were being consulted with a key that could never
// match. Folding here — the ONE place a path becomes an entity — restores the
// documented policy on both surfaces at once rather than teaching each clause two
// spellings.
func entityNoun(seg string) string {
for _, v := range legacyVerbs {
if strings.HasPrefix(seg, v) {
seg = seg[len(v):]
break
}
}
if seg == "" {
return ""
}
// EVERY policy clause is written in the plural — applications, certs,
// projects, users, organizations, keys — so every path segment is folded to
// the plural, not just the ones that carried a verb prefix.
//
// Pluralising only after stripping a verb is what split the policy in two.
// /v1/iam/get-application folded to "applications" and matched the app
// self-read clause; the NATIVE /v1/iam/application carries no verb, fell
// through as the singular "application", matched no clause, and hit the
// reserved-owner gate — so a relying party could read its own row over the
// legacy verb and was refused 403 over the native route. One policy, two
// answers, decided by spelling.
//
// Folding here is safe precisely because the clauses are plural: the only
// segments this newly changes are the singular natives (application, cert,
// key, user, organization, project), and each folds onto the entity it IS.
if !strings.HasSuffix(seg, "s") {
seg += "s"
}
return seg
}
+159
View File
@@ -0,0 +1,159 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz
import "testing"
// The confidential-client authorization policy: an app principal's ENTIRE
// authority is its capability allowlist — never Super, never Admin, never a
// tenant. This is the v1 "every client credential is a global admin" hole, held
// closed. authorize() IS the decision; this table is its truth for app principals.
func TestAuthorizeAppCapabilities(t *testing.T) {
// The allowlists reserve each capability to a named admin-owned app.
t.Setenv("IAM_USER_ADMIN_APPS", "hanzo-console")
t.Setenv("IAM_ORG_ADMIN_APPS", "hanzo-console")
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-team")
t.Setenv("IAM_SA_LIST_ALLOWED_APPS", "hanzo-reader")
console := &Principal{App: "hanzo-console", AppOwner: "admin", Org: "admin"} // admin-owned: user+org admin caps
nobody := &Principal{App: "rogue-app", AppOwner: "hanzo", Org: "hanzo"} // in no allowlist
// attacker: a tenant that registered <its-org>/hanzo-console — the SAME allow-listed
// NAME, but owned by a NON-signing org. The owner-pin (cap.go) denies every capability,
// so the public signup→onboard→register-app→Basic-auth escalation is inert.
attacker := &Principal{App: "hanzo-console", AppOwner: "evil", Org: "evil"}
cases := []struct {
name string
p *Principal
method string
entity string
owner string
name2 string
want bool
}{
// A capability-holding app may act on its mapped entity — cross-tenant by
// design (a platform console onboards any customer org).
{"console writes users in any org", console, "POST", "users", "orgb", "x", true},
{"console writes org (reserved-owner exception)", console, "POST", "organizations", "admin", "hanzo", true},
{"console reads org", console, "GET", "organizations", "admin", "hanzo", true},
// An app NEVER reaches signing material or unmapped entities, allowlisted
// or not — capFor has no mapping, so the allowlist is vacuously empty.
{"console -> certs denied", console, "POST", "certs", "admin", "k", false},
{"console -> providers denied", console, "POST", "providers", "hanzo", "p", false},
{"console -> tokens denied", console, "POST", "tokens", "hanzo", "t", false},
// An app in NO allowlist holds nothing — a leaked credential is inert.
{"rogue -> users denied", nobody, "POST", "users", "hanzo", "x", false},
{"rogue -> orgs denied", nobody, "POST", "organizations", "admin", "hanzo", false},
{"rogue -> own-org users denied", nobody, "POST", "users", "hanzo", "x", false},
// A user under a reserved owner is NEVER writable by an app — provision,
// never promote (no capability moves a user into the admin org).
{"console -> admin-org user denied", console, "POST", "users", "admin", "x", false},
{"console -> built-in user denied", console, "POST", "users", "built-in", "x", false},
// [INFO] consistency: even the LEGIT admin-owned console may not land a user in
// the reserved service-principal org "app" — a platform identity, super-only. The
// raw CRUD now consults IsReservedOrg, the SAME predicate signup/onboarding use.
{"console -> app-org user denied", console, "POST", "users", "app", "x", false},
// RED PoC, now DENIED: a tenant-owned app spoofing the console NAME holds NOTHING.
// The owner-pin refuses a non-signing owner BEFORE any allowlist name match, so a
// leaked/forged tenant credential named like the console cannot act on any tenant.
{"attacker spoof -> victim users denied", attacker, "POST", "users", "victim", "x", false},
{"attacker spoof -> org write denied", attacker, "POST", "organizations", "admin", "victim", false},
{"attacker spoof -> org delete denied", attacker, "DELETE", "organizations", "admin", "victim", false},
{"attacker spoof -> org read denied", attacker, "GET", "organizations", "admin", "victim", false},
{"attacker spoof -> own-org users denied", attacker, "POST", "users", "evil", "x", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := authorize(c.p, c.method, c.entity, c.owner, c.name2); got != c.want {
t.Fatalf("authorize(App=%q,%s,%s,%s/%s) = %v, want %v",
c.p.App, c.method, c.entity, c.owner, c.name2, got, c.want)
}
})
}
// An app principal is structurally never Super/Admin, so it can never take the
// human privileged paths even if a future bug set the flags.
if console.Super || console.Admin {
t.Fatal("an app principal must never carry Super/Admin")
}
// RED's four assertions, VERBATIM — the exact PoC principal &Principal{App:"hanzo-console"}
// (no owning signing org). Every one fired == true before the owner-pin; every one must
// be false now. A bare app principal is inert regardless of the NAME it presents.
red := &Principal{App: "hanzo-console"}
for _, a := range []struct{ method, entity, owner, name string }{
{"POST", "users", "victim", "x"},
{"POST", "organizations", "admin", "victim"},
{"DELETE", "organizations", "admin", "victim"},
{"GET", "organizations", "admin", "victim"},
} {
if authorize(red, a.method, a.entity, a.owner, a.name) {
t.Fatalf("RED PoC REOPENED: authorize(App=hanzo-console,%s,%s,%s/%s) GRANTED — the owner-pin failed",
a.method, a.entity, a.owner, a.name)
}
}
}
// The capability primitives, fail-secure to the letter.
func TestCapabilityPrimitives(t *testing.T) {
t.Setenv("IAM_ORG_ADMIN_APPS", "hanzo-console, brand-console")
t.Run("Allowed named", func(t *testing.T) {
if !Allowed(&Principal{App: "hanzo-console", AppOwner: "admin"}, CapOrgAdmin) {
t.Fatal("a named, admin-owned app must hold its capability")
}
})
t.Run("Allowed unnamed denied", func(t *testing.T) {
if Allowed(&Principal{App: "other", AppOwner: "admin"}, CapOrgAdmin) {
t.Fatal("an unnamed app must hold nothing") // admin-owned, so the NAME check alone denies
}
})
t.Run("Allowed unset env denied", func(t *testing.T) {
if Allowed(&Principal{App: "hanzo-console", AppOwner: "admin"}, CapKeyMint) { // IAM_KEY_MINT_ALLOWED_APPS unset here
t.Fatal("an unset allowlist must deny every app")
}
})
t.Run("Allowed owner-pin denies a non-signing owner", func(t *testing.T) {
// hanzo-console IS on IAM_ORG_ADMIN_APPS, but these rows are NOT admin/built-in owned.
if Allowed(&Principal{App: "hanzo-console", AppOwner: "hanzo"}, CapOrgAdmin) {
t.Fatal("a tenant-owned app must hold nothing even with an allow-listed name")
}
if Allowed(&Principal{App: "hanzo-console"}, CapOrgAdmin) { // AppOwner "" — no owning signing org at all
t.Fatal("an app with no owning signing org must hold nothing")
}
// built-in is the other reserved signing owner — a built-in-owned allow-listed app is legit.
if !Allowed(&Principal{App: "hanzo-console", AppOwner: "built-in"}, CapOrgAdmin) {
t.Fatal("a built-in-owned allow-listed app must hold its capability")
}
})
t.Run("Allowed non-app is vacuous", func(t *testing.T) {
if !Allowed(&Principal{Org: "hanzo"}, CapOrgAdmin) {
t.Fatal("a human holds capabilities vacuously; the org policy decides")
}
})
t.Run("BoundToOrg prefix", func(t *testing.T) {
p := &Principal{App: "hanzo-team"}
if !BoundToOrg(p, "hanzo") {
t.Fatal("hanzo-team must be bound to hanzo")
}
if BoundToOrg(p, "lux") {
t.Fatal("hanzo-team must NOT be bound to lux")
}
if BoundToOrg(&Principal{App: "hanzo"}, "hanzo") {
t.Fatal("an exact-name app (no agent segment) is bound to nothing")
}
})
t.Run("capFor mapping", func(t *testing.T) {
if capFor("organizations") != CapOrgAdmin || capFor("users") != CapUserAdmin {
t.Fatal("org/user entities must map to their capability")
}
if capFor("certs") != (Cap{}) || capFor("providers") != (Cap{}) {
t.Fatal("an unmapped entity must map to the empty (deny-all) capability")
}
})
}
+463
View File
@@ -0,0 +1,463 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz_test
import (
"net/http"
"testing"
"time"
)
// The eight required cases, each through the real registered router. Sub names map
// to seeded principals: admin/root = SuperAdmin, hanzo/boss = org admin,
// hanzo/alice = regular user, orgb/bob = a foreign org's admin.
// 1. An unauthenticated CRUD write is refused before any handler runs.
func TestUnauthenticatedWriteIs401(t *testing.T) {
h := newHarness(t)
cases := []struct {
name, method, path string
body any
}{
{"create user", "POST", "/v1/iam/users", user("hanzo", "x")},
{"write cert", "POST", "/v1/iam/certs", cert("admin", signingKid)},
{"register app", "POST", "/v1/iam/application", map[string]any{"owner": "admin", "name": "x"}},
{"delete user", "POST", "/v1/iam/users/delete", map[string]any{"owner": "hanzo", "name": "alice"}},
{"update cert", "POST", "/v1/iam/certs/update", cert("admin", signingKid)},
{"create org", "POST", "/v1/iam/organizations", map[string]any{"owner": "admin", "name": "x"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := h.do(t, c.method, c.path, "", c.body); got != http.StatusUnauthorized {
t.Fatalf("%s %s no bearer = %d, want 401", c.method, c.path, got)
}
})
}
}
// 2. A valid principal in orgB writing an orgA-owned entity is refused (tenant
// isolation): the target org is bound to the principal, never the body.
func TestCrossOrgWriteIs403(t *testing.T) {
h := newHarness(t)
bob := h.token(t, "orgb/bob") // org admin, but of orgb
cases := []struct {
name, method, path string
body any
}{
{"create user in hanzo", "POST", "/v1/iam/users", user("hanzo", "mole")},
{"update user in hanzo", "POST", "/v1/iam/users/update", user("hanzo", "alice")},
{"delete user in hanzo", "POST", "/v1/iam/users/delete", map[string]any{"owner": "hanzo", "name": "alice"}},
{"create role in hanzo", "POST", "/v1/iam/roles", map[string]any{"owner": "hanzo", "name": "r"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := h.do(t, c.method, c.path, bob, c.body); got != http.StatusForbidden {
t.Fatalf("orgb principal %s %s = %d, want 403", c.method, c.path, got)
}
})
}
}
// 3. THE poisoning gate. A non-SuperAdmin — org admin OR regular user OR a
// built-in-org member — writing an admin/built-in-owned signing cert is refused.
// Every cert write verb is covered, and the update/delete target the LIVE
// signing cert, so a bypass would truly overwrite the platform key.
func TestSigningCertPoisoningIs403(t *testing.T) {
h := newHarness(t)
principals := map[string]string{
"org admin (hanzo/boss)": h.token(t, "hanzo/boss"),
"regular user (hanzo/alice)": h.token(t, "hanzo/alice"),
"built-in member (built-in/svc)": h.token(t, "built-in/svc"),
}
writes := []struct {
name, path string
body any
}{
{"create admin cert", "/v1/iam/certs", cert("admin", "cert-forge")},
{"overwrite live admin cert", "/v1/iam/certs/update", cert("admin", signingKid)},
{"delete live admin cert", "/v1/iam/certs/delete", map[string]any{"owner": "admin", "name": signingKid}},
{"create built-in cert", "/v1/iam/certs", cert("built-in", "cert-forge")},
{"overwrite built-in cert", "/v1/iam/certs/update", cert("built-in", "anything")},
}
for who, tok := range principals {
for _, w := range writes {
t.Run(who+" "+w.name, func(t *testing.T) {
if got := h.do(t, "POST", w.path, tok, w.body); got != http.StatusForbidden {
t.Fatalf("%s writing %s = %d, want 403 (poisoning gate)", who, w.path, got)
}
})
}
}
}
// 4. A SuperAdmin (org == admin) may write the admin signing cert and act across
// any org. The guard admits it; the handler then succeeds (2xx). The rotation
// case overwrites the LIVE signing cert with a complete body (key preserved) —
// the legitimate operation the poisoning gate exists to reserve to SuperAdmins.
func TestSuperAdminWritesAdminCertAndCrossOrg(t *testing.T) {
h := newHarness(t)
root := h.token(t, "admin/root")
rotate := map[string]any{
"owner": "admin", "name": signingKid,
"cryptoAlgorithm": "RS256", "privateKey": rsaKeyToPEM(t, h.key),
}
cases := []struct {
name, method, path string
body any
}{
{"create a new admin signing cert", "POST", "/v1/iam/certs", cert("admin", "cert-fresh")},
{"rotate the live admin signing cert", "POST", "/v1/iam/certs/update", rotate},
{"create a user in any org", "POST", "/v1/iam/users", user("hanzo", "hire-by-root")},
{"create a user in another org", "POST", "/v1/iam/users", user("orgb", "hire-by-root")},
{"register an admin-owned app", "POST", "/v1/iam/application", map[string]any{"owner": "admin", "name": "root-app", "clientId": "root-app"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := h.do(t, c.method, c.path, root, c.body)
if got < 200 || got >= 300 {
t.Fatalf("SuperAdmin %s %s = %d, want 2xx", c.method, c.path, got)
}
})
}
}
// 5. An org admin manages its OWN org's users and apps (2xx) but not another
// org's (403). This is the org-admin tier: org-scoped, never cross-tenant.
func TestOrgAdminManagesOwnOrgOnly(t *testing.T) {
h := newHarness(t)
boss := h.token(t, "hanzo/boss")
allow := []struct {
name, method, path string
body any
}{
{"create user in own org", "POST", "/v1/iam/users", user("hanzo", "newhire")},
{"update self org's user", "POST", "/v1/iam/users/update", user("hanzo", "alice")},
{"register app in own org", "POST", "/v1/iam/application", map[string]any{"owner": "hanzo", "name": "hanzo-app", "clientId": "hanzo-app"}},
}
for _, c := range allow {
t.Run("allow/"+c.name, func(t *testing.T) {
got := h.do(t, c.method, c.path, boss, c.body)
if got < 200 || got >= 300 {
t.Fatalf("org admin %s %s (own org) = %d, want 2xx", c.method, c.path, got)
}
})
}
deny := []struct {
name, method, path string
body any
}{
{"create user in another org", "POST", "/v1/iam/users", user("orgb", "mole")},
{"register app in another org", "POST", "/v1/iam/application", map[string]any{"owner": "orgb", "name": "x", "clientId": "x"}},
{"write a platform (admin) app", "POST", "/v1/iam/application", map[string]any{"owner": "admin", "name": "x", "clientId": "x"}},
}
for _, c := range deny {
t.Run("deny/"+c.name, func(t *testing.T) {
if got := h.do(t, c.method, c.path, boss, c.body); got != http.StatusForbidden {
t.Fatalf("org admin %s %s (foreign) = %d, want 403", c.method, c.path, got)
}
})
}
}
// 6. A regular user may read its own user record (guard admits it) but not touch
// another's, and may NOT write even its own record — a raw self-write would let
// it carry isAdmin and self-promote, so writes are refused outright.
func TestRegularUserSelfServiceOnly(t *testing.T) {
h := newHarness(t)
alice := h.token(t, "hanzo/alice")
// Reading own record: the guard admits it (not 401/403). The Phase-1 GET
// handler binds no query, so the status is the handler's, never the guard's
// forbid — the point here is that the guard did NOT block self-read.
if got := h.do(t, "GET", "/v1/iam/users/get?owner=hanzo&name=alice", alice, nil); got == http.StatusForbidden || got == http.StatusUnauthorized {
t.Fatalf("regular self-read = %d, want the guard to admit it (not 401/403)", got)
}
// Everything else a regular user might try is refused.
deny := []struct {
name, method, path string
body any
}{
{"read another user", "GET", "/v1/iam/users/get?owner=hanzo&name=boss", nil},
{"list the org's users", "GET", "/v1/iam/users?owner=hanzo", nil},
{"update own record (self-promote)", "POST", "/v1/iam/users/update", map[string]any{"user": map[string]any{"owner": "hanzo", "name": "alice", "isAdmin": true}}},
{"create a user", "POST", "/v1/iam/users", user("hanzo", "puppet")},
{"delete another user", "POST", "/v1/iam/users/delete", map[string]any{"owner": "hanzo", "name": "boss"}},
{"read another org", "GET", "/v1/iam/users/get?owner=orgb&name=bob", nil},
}
for _, c := range deny {
t.Run("deny/"+c.name, func(t *testing.T) {
if got := h.do(t, c.method, c.path, alice, c.body); got != http.StatusForbidden {
t.Fatalf("regular user %s %s = %d, want 403", c.method, c.path, got)
}
})
}
}
// 7. Public routes are reachable with NO bearer — the pre-auth OIDC/OAuth and
// front-door surface a browser must reach before it holds a token. "Reachable"
// means NOT the guard's 401: the endpoint's own handler answers (which may be a
// 400 for a missing param — that is the handler, past the guard).
func TestPublicRoutesNeedNoBearer(t *testing.T) {
h := newHarness(t)
public := []struct{ method, path string }{
{"GET", "/.well-known/openid-configuration"},
{"GET", "/v1/iam/.well-known/openid-configuration"},
{"GET", "/v1/iam/.well-known/jwks"},
{"GET", "/.well-known/oauth-authorization-server"}, // RFC 8414 AS metadata (root)
{"GET", "/v1/iam/.well-known/oauth-authorization-server"}, // RFC 8414 AS metadata (v1)
{"POST", "/v1/iam/login"},
{"GET", "/v1/iam/oauth/authorize"},
{"POST", "/v1/iam/oauth/token"},
{"GET", "/v1/iam/get-app-login"},
{"GET", "/v1/iam/auth/methods"},
{"POST", "/v1/iam/oauth/logout"},
// The front-door session/identity surface — each self-resolves the caller
// (session cookie, else bearer) and answers anonymously (200 {status:error}
// or a handler 400), never the Guard's 401. These are the routes the old
// publicPaths list had to be patched to include; now they are public purely
// because oidc.Route registers them on the pre-Guard group.
{"GET", "/v1/iam/get-account"},
{"POST", "/v1/iam/signin"},
{"GET", "/v1/iam/whoami"},
{"GET", "/v1/iam/linked-accounts"},
{"POST", "/v1/iam/signup"},
{"POST", "/v1/iam/send-verification-code"},
{"POST", "/v1/iam/update-preferences"},
}
for _, c := range public {
t.Run(c.method+" "+c.path, func(t *testing.T) {
if got := h.do(t, c.method, c.path, "", map[string]any{}); got == http.StatusUnauthorized {
t.Fatalf("public %s %s = 401, want the endpoint reachable without a bearer", c.method, c.path)
}
})
}
// userinfo is bearer-gated but self-verifying: no bearer → its OWN 401
// (WWW-Authenticate), which is correct and must not be double-gated away.
if got := h.do(t, "GET", "/v1/iam/oauth/userinfo", "", nil); got != http.StatusUnauthorized {
t.Fatalf("userinfo no bearer = %d, want its own 401", got)
}
}
// 8. Bad bearers are refused with the same opaque 401 (no oracle): expired,
// wrong algorithm (HMAC / none — never in the allowlist), a kid that names no
// trusted cert, and a good-shape token under the wrong key. This reuses the
// Phase-2 verifier defenses verbatim.
func TestBadBearersAre401(t *testing.T) {
h := newHarness(t)
other := genRSA(t)
path, body := "/v1/iam/users", user("hanzo", "x")
bad := map[string]string{
"expired": h.mint(t, "admin/root", time.Now().Add(-time.Hour)),
"forged kid": mintKid(t, h.key, "cert-nonexistent", "admin/root"),
"wrong key": mintKid(t, other, signingKid, "admin/root"),
"hmac alg": signHS256(t, signingKid, "admin/root"),
"alg none": forgeNone(signingKid, "admin/root"),
"garbage": "not.a.jwt",
}
for name, tok := range bad {
t.Run(name, func(t *testing.T) {
if got := h.do(t, "POST", path, tok, body); got != http.StatusUnauthorized {
t.Fatalf("bad bearer %q = %d, want 401", name, got)
}
})
}
// A revoked (forbidden) user's otherwise-valid token is refused too.
t.Run("revoked user", func(t *testing.T) {
if got := h.do(t, "POST", path, h.token(t, "hanzo/ghost"), body); got != http.StatusUnauthorized {
t.Fatalf("revoked user = %d, want 401", got)
}
})
}
// Org-confusion escalation defense: a token minted through a SHARED admin-org
// app carries owner/organization = "admin" while its subject is a tenant user.
// The guard authorizes from the subject (the real user's org), never the owner
// claim, so this token is a hanzo REGULAR user — it cannot write an admin cert
// or reach across orgs, exactly as if the misleading claim were absent.
func TestOwnerClaimCannotEscalate(t *testing.T) {
h := newHarness(t)
// alice is a regular hanzo user; the token lies that owner == admin.
tok := h.sharedAppToken(t, "hanzo/alice", "admin")
cases := []struct {
name, method, path string
body any
}{
{"write admin signing cert", "POST", "/v1/iam/certs", cert("admin", "cert-forge")},
{"overwrite live admin cert", "POST", "/v1/iam/certs/update", cert("admin", signingKid)},
{"create a user cross-org", "POST", "/v1/iam/users", user("orgb", "mole")},
{"promote self in own org", "POST", "/v1/iam/users/update", map[string]any{"user": map[string]any{"owner": "hanzo", "name": "alice", "isAdmin": true}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := h.do(t, c.method, c.path, tok, c.body); got != http.StatusForbidden {
t.Fatalf("owner-claim=admin %s %s = %d, want 403 (claim must not escalate)", c.method, c.path, got)
}
})
}
}
// A verified token whose subject names NO live user — a machine token, a
// since-deleted user, or a forged-looking "admin/<nobody>" — authenticates but
// carries no authority: SuperAdmin requires a real member of the admin org, so
// the phantom-admin subject is refused everywhere.
func TestPhantomSubjectHasNoAuthority(t *testing.T) {
h := newHarness(t)
ghostAdmin := h.token(t, "admin/nobody") // no such user seeded
ghostTenant := h.token(t, "hanzo/nobody")
cases := []struct {
name, tok, method, path string
body any
}{
{"phantom admin -> admin cert", ghostAdmin, "POST", "/v1/iam/certs", cert("admin", "cert-forge")},
{"phantom admin -> user in admin org", ghostAdmin, "POST", "/v1/iam/users", user("admin", "x")},
{"phantom admin -> user in a tenant", ghostAdmin, "POST", "/v1/iam/users", user("hanzo", "x")},
{"phantom tenant -> user in own org", ghostTenant, "POST", "/v1/iam/users", user("hanzo", "x")},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := h.do(t, c.method, c.path, c.tok, c.body); got != http.StatusForbidden {
t.Fatalf("%s = %d, want 403 (phantom subject has no authority)", c.name, got)
}
})
}
}
// The framework's generic side doors (MCP tool-call, OpenAPI doc) are gated by
// the same fail-closed default — proven on a REAL, installed route and a REAL
// tool INVOCATION, not just the envelope path. newHarness calls app.Prepare(), so
// /mcp and /openapi are actually registered (the old test hit a route that was
// never registered, so the guard's 401 masked the fact the invocation was untested),
// and the tool id is the framework's real one (post_v1_iam_certs), so a
// regression that let a tool arguments-mask through would FAIL here, not pass.
func TestFrameworkSideDoorsAreGated(t *testing.T) {
h := newHarness(t)
forge := cert("admin", "cert-forge") // {owner:"admin", …} — the poisoning target
// No bearer reaches /mcp at all: the guard authenticates the envelope before
// any dispatch, so it is 401 — never an unauthorized invocation, never a 404.
if got := h.do(t, "POST", "/mcp", "", mcpEnvelope("post_v1_iam_certs", forge)); got != http.StatusUnauthorized {
t.Fatalf("POST /mcp no bearer = %d, want 401 (guard fail-closed)", got)
}
// The OpenAPI doc — now a real installed route — is gated too.
if got := h.do(t, "GET", "/.well-known/openapi.json", "", nil); got != http.StatusUnauthorized {
t.Fatalf("GET openapi.json no bearer = %d, want 401", got)
}
// A non-SuperAdmin driving the REAL cert tool is refused at the op-invoke seam
// (isError), and — the assertion that matters — NOTHING is written.
boss := h.token(t, "hanzo/boss")
if status, isErr := h.mcpToolCall(t, boss, "post_v1_iam_certs", forge); status != http.StatusOK || !isErr {
t.Fatalf("MCP post_v1_iam_certs (non-super) = status %d isError %v, want 200/true (refused at op seam)", status, isErr)
}
if h.certExists(t, "admin", "cert-forge") {
t.Fatal("MCP cert-forge PERSISTED an admin-owned cert — the /mcp side door is OPEN")
}
}
// THE critical bug (finding #1), proven closed at the REST seam. The users entity
// is the one input that nests its owner, so an org admin who masks a benign
// top-level owner over a nested admin/isAdmin record must NOT create a platform
// SuperAdmin. The write is refused (403) AND — the assertion the vacuous test
// lacked — the store holds no such row afterward. Query the store, not the status.
func TestUserOwnerMaskIsRefused(t *testing.T) {
h := newHarness(t)
boss := h.token(t, "hanzo/boss") // org admin of hanzo — authorized for "hanzo" only
// The PoC verbatim: top-level owner is the attacker's OWN org (which the guard
// would authorize), the nested record targets the reserved admin org with
// isAdmin — a platform SuperAdmin (owner=="admin" IS the predicate) if it landed.
createMask := map[string]any{
"owner": "hanzo",
"user": map[string]any{"owner": "admin", "name": "red-super", "isAdmin": true},
"password": "x",
}
if got := h.do(t, "POST", "/v1/iam/users", boss, createMask); got != http.StatusForbidden {
t.Fatalf("users create owner-mask = %d, want 403", got)
}
if h.userExists(t, "admin", "red-super") {
t.Fatal("owner-mask PERSISTED admin/red-super — total-account-takeover path is OPEN")
}
// The same mask, aimed cross-tenant: inject a user into a foreign org.
crossOrgMask := map[string]any{
"owner": "hanzo",
"user": map[string]any{"owner": "orgb", "name": "mole"},
"password": "x",
}
if got := h.do(t, "POST", "/v1/iam/users", boss, crossOrgMask); got != http.StatusForbidden {
t.Fatalf("users create cross-org mask = %d, want 403", got)
}
if h.userExists(t, "orgb", "mole") {
t.Fatal("owner-mask injected a user into orgb (cross-tenant)")
}
// Hijack an EXISTING admin-org user via /users/update (nested owner=admin):
// refused, and the victim's privilege/credentials are untouched.
hijack := map[string]any{
"user": map[string]any{"owner": "admin", "name": "root", "isAdmin": true},
"password": "attacker-chosen",
}
if got := h.do(t, "POST", "/v1/iam/users/update", boss, hijack); got != http.StatusForbidden {
t.Fatalf("users update hijack of admin/root = %d, want 403", got)
}
if h.userIsAdmin(t, "admin", "root") {
t.Fatal("update hijack flipped admin/root.isAdmin — privilege takeover via /users/update")
}
}
// The MCP arguments-mask (finding #2), proven closed at the SAME op-invoke seam —
// the design claim "the guard gates /mcp" made real, independent of the prod
// MCP.Disabled flag (this harness leaves MCP ENABLED). A non-SuperAdmin driving
// the real tools with admin-targeted arguments is refused and writes nothing; a
// SuperAdmin drives the same tool successfully, so the seam refuses by AUTHORITY,
// not by blanket-denying every MCP call.
func TestMCPArgumentsMaskIsRefused(t *testing.T) {
h := newHarness(t)
boss := h.token(t, "hanzo/boss")
attackerPEM := rsaKeyToPEM(t, genRSA(t))
// a) cert-forge over MCP arguments: an admin signing cert with an attacker key.
forge := map[string]any{
"owner": "admin", "name": "cert-forge",
"cryptoAlgorithm": "RS256", "privateKey": attackerPEM,
}
if status, isErr := h.mcpToolCall(t, boss, "post_v1_iam_certs", forge); status != http.StatusOK || !isErr {
t.Fatalf("MCP cert-forge (non-super) = status %d isError %v, want 200/true (refused)", status, isErr)
}
if h.certExists(t, "admin", "cert-forge") {
t.Fatal("MCP cert-forge PERSISTED an admin signing cert with an attacker key")
}
// b) the users owner-mask over MCP arguments: a nested admin SuperAdmin record.
userMask := map[string]any{
"owner": "hanzo",
"user": map[string]any{"owner": "admin", "name": "red-super", "isAdmin": true},
"password": "x",
}
if status, isErr := h.mcpToolCall(t, boss, "post_v1_iam_users", userMask); status != http.StatusOK || !isErr {
t.Fatalf("MCP users owner-mask (non-super) = status %d isError %v, want 200/true (refused)", status, isErr)
}
if h.userExists(t, "admin", "red-super") {
t.Fatal("MCP users owner-mask PERSISTED admin/red-super — total takeover via /mcp")
}
// Control: a SuperAdmin drives the SAME cert tool successfully — the seam
// discriminates by authority; it does not just refuse everything over MCP.
root := h.token(t, "admin/root")
legit := map[string]any{
"owner": "admin", "name": "cert-legit",
"cryptoAlgorithm": "RS256", "privateKey": rsaKeyToPEM(t, h.key),
}
if status, isErr := h.mcpToolCall(t, root, "post_v1_iam_certs", legit); status != http.StatusOK || isErr {
t.Fatalf("MCP cert create by SuperAdmin = status %d isError %v, want 200/false (allowed)", status, isErr)
}
if !h.certExists(t, "admin", "cert-legit") {
t.Fatal("SuperAdmin MCP cert create did not persist — the seam is over-refusing")
}
}
+363
View File
@@ -0,0 +1,363 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz_test
// End-to-end authorization tests driven through the REAL registered router
// (routes.Route, which installs authz.Guard on the AUTHED group, so gating is
// structural — the public routes, registered on a group that has no Guard, are
// never reached by it).
// Every case is a HTTP request
// a client could send: a status code is the whole contract. Tokens are genuine
// RS256 JWTs signed by the seeded admin signing cert, so they pass the exact
// oidc.VerifyToken the guard reuses — nothing here is mocked.
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"io"
"net/http/httptest"
"path/filepath"
"sync"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/orm"
ormdb "github.com/hanzoai/orm/db"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/routes"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/testhttp"
)
const signingKid = "cert-hanzo" // the seeded admin signing cert's name = JWKS kid
// Two RSA keys, generated once for the whole suite: the trust-anchor key the
// signing cert holds, and a distinct "other" key for the wrong-key bearer test.
// Keygen is the slow part and the crypto under test is identical whichever key
// it is, so caching them keeps the suite (and -race) fast.
var (
anchorKeyOnce, otherKeyOnce sync.Once
anchorKey, otherKey *rsa.PrivateKey
)
func trustKey() *rsa.PrivateKey {
anchorKeyOnce.Do(func() { anchorKey = mustRSA() })
return anchorKey
}
func mustRSA() *rsa.PrivateKey {
k, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err)
}
return k
}
// harness holds the registered app, the RSA key the signing cert holds (so a test
// can mint a token any principal would carry), and the store (so a test can
// assert that a refused write persisted NOTHING — the real security property, not
// just a status code).
type harness struct {
app *zip.App
key *rsa.PrivateKey
db orm.DB
}
// userExists reports whether a user row (owner, name) is persisted — used to
// prove a refused create/update wrote nothing.
func (h *harness) userExists(t *testing.T, owner, name string) bool {
t.Helper()
u, err := store.GetUserByName(context.Background(), h.db, owner, name)
if err != nil {
t.Fatalf("lookup user %s/%s: %v", owner, name, err)
}
return u != nil
}
// certExists reports whether a cert row (owner, name) is persisted.
func (h *harness) certExists(t *testing.T, owner, name string) bool {
t.Helper()
c, err := store.GetCert(context.Background(), h.db, owner, name)
if err != nil {
t.Fatalf("lookup cert %s/%s: %v", owner, name, err)
}
return c != nil
}
// userIsAdmin reports the persisted isAdmin flag of (owner, name) — used to prove
// a refused update did NOT flip a victim's privilege.
func (h *harness) userIsAdmin(t *testing.T, owner, name string) bool {
t.Helper()
u, err := store.GetUserByName(context.Background(), h.db, owner, name)
if err != nil || u == nil {
t.Fatalf("expected user %s/%s to exist: %v", owner, name, err)
}
return u.IsAdmin
}
// newHarness opens a fresh SQLite store, seeds the trust anchor (an admin-owned
// RS256 signing cert) plus a cast of principals across three orgs, and registers
// the full router — guard and all. MCP is left ENABLED here (unlike prod) so the
// tests prove the guard, not a disabled feature, closes the /mcp side door.
func newHarness(t *testing.T) *harness {
t.Helper()
_ = schema.Kinds() // force kind registration
key := trustKey()
dir := t.TempDir()
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(dir, "authz.db"),
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
// Trust anchor: the admin-owned signing cert the verifier and JWKS trust.
// Poisoning tests target THIS row, so a bypassed guard would really overwrite
// the live signing key.
seedCert(t, db, "admin", signingKid, rsaKeyToPEM(t, key))
// Principals: one per scope, plus a revoked user and a cross-tenant org.
seedUser(t, db, "admin", "root", false, false, false) // SuperAdmin (org == admin)
seedUser(t, db, "hanzo", "boss", true, false, false) // org admin of hanzo
seedUser(t, db, "hanzo", "alice", false, false, false) // regular user in hanzo
seedUser(t, db, "orgb", "bob", true, false, false) // org admin of orgb (cross-tenant)
seedUser(t, db, "hanzo", "ghost", true, true, false) // forbidden — revoked
seedUser(t, db, "built-in", "svc", true, false, false) // built-in org, NOT SuperAdmin
app := zip.New(zip.Config{AppName: "authz-test", DisableStartupMessage: true})
routes.Route(app, db)
// Install the deferred framework projections (/mcp, /openapi) for real, so the
// side-door tests drive the ACTUAL routes — the same surface a served app
// exposes — not a route that never got registered. MCP is left ENABLED here
// (unlike prod) so the tests prove the guard, not a disabled feature, closes it.
if err := app.Build(); err != nil {
t.Fatalf("build: %v", err)
}
return &harness{app: app, key: key, db: db}
}
// mint signs an RS256 bearer for subject `sub` (an "owner/name") with the given
// expiry, under the trusted kid — the exact shape a real token carries.
func (h *harness) mint(t *testing.T, sub string, exp time.Time) string {
t.Helper()
return signRS256(t, h.key, signingKid, jwt.MapClaims{
"sub": sub,
"iat": time.Now().Add(-time.Minute).Unix(),
"exp": exp.Unix(),
})
}
// token is a convenience for a valid, hour-long bearer for sub.
func (h *harness) token(t *testing.T, sub string) string {
return h.mint(t, sub, time.Now().Add(time.Hour))
}
// sharedAppToken mints a valid bearer whose owner/organization claims say
// ownerClaim (as a token minted through a SHARED admin-org app would) while the
// subject names a different, tenant user. The guard must authorize from the
// subject, never these claims — the org-confusion escalation defense.
func (h *harness) sharedAppToken(t *testing.T, sub, ownerClaim string) string {
t.Helper()
return signRS256(t, h.key, signingKid, jwt.MapClaims{
"sub": sub, "owner": ownerClaim, "organization": ownerClaim, "exp": future(),
})
}
// do issues one request through the real router and returns the status code.
func (h *harness) do(t *testing.T, method, path, bearer string, body any) int {
t.Helper()
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
req := httptest.NewRequest(method, path, r)
req.Host = "hanzo.id"
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := testhttp.Do(h.app, req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
return resp.StatusCode
}
// mcpEnvelope builds a JSON-RPC 2.0 tools/call for the framework tool `tool`
// (its real op id, e.g. "post_v1_iam_certs") with `args` as the tool arguments —
// the same body an MCP agent would POST to /mcp.
func mcpEnvelope(tool string, args any) map[string]any {
return map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": map[string]any{"name": tool, "arguments": args},
}
}
// mcpToolCall fires an MCP tools/call for `tool` with `args` through the REAL
// registered /mcp route and reports the HTTP status plus whether the op-invoke
// authorizer refused it. A refusal at the op seam surfaces as an isError result
// with HTTP 200 (MCP reports handler errors in-band), never a transport 403, so
// a refused write shows up as isError==true — the status stays 200.
func (h *harness) mcpToolCall(t *testing.T, bearer, tool string, args any) (status int, isError bool) {
t.Helper()
b, _ := json.Marshal(mcpEnvelope(tool, args))
req := httptest.NewRequest("POST", "/mcp", bytes.NewReader(b))
req.Host = "hanzo.id"
req.Header.Set("Content-Type", "application/json")
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := testhttp.Do(h.app, req)
if err != nil {
t.Fatalf("mcp tools/call %s: %v", tool, err)
}
defer func() { _ = resp.Body.Close() }()
var out struct {
Result struct {
IsError bool `json:"isError"`
} `json:"result"`
}
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out.Result.IsError
}
// ---- seed helpers ----------------------------------------------------------
func seedCert(t *testing.T, db orm.DB, owner, name, privPEM string) {
t.Helper()
c := orm.New[schema.Cert](db)
c.Owner, c.Name = owner, name
c.CryptoAlgorithm = "RS256"
c.PrivateKey = privPEM
c.SetId(owner + "/" + name)
if err := c.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed cert %s/%s: %v", owner, name, err)
}
}
func seedUser(t *testing.T, db orm.DB, owner, name string, admin, forbidden, deleted bool) {
t.Helper()
u := orm.New[schema.User](db)
u.Owner, u.Name = owner, name
u.IsAdmin, u.IsForbidden, u.IsDeleted = admin, forbidden, deleted
u.SetId(owner + "/" + name)
if err := u.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed user %s/%s: %v", owner, name, err)
}
}
func rsaKeyToPEM(t *testing.T, k *rsa.PrivateKey) string {
t.Helper()
return string(pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k),
}))
}
func signRS256(t *testing.T, key *rsa.PrivateKey, kid string, claims jwt.MapClaims) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tok.Header["kid"] = kid
s, err := tok.SignedString(key)
if err != nil {
t.Fatalf("sign: %v", err)
}
return s
}
func future() int64 { return time.Now().Add(time.Hour).Unix() }
// mintKid signs an hour-long RS256 token for sub under an arbitrary key and kid,
// for the forged-kid and wrong-key bearer tests.
func mintKid(t *testing.T, key *rsa.PrivateKey, kid, sub string) string {
return signRS256(t, key, kid, jwt.MapClaims{"sub": sub, "exp": future()})
}
// genRSA returns the suite's cached "other" key — a valid key that is NOT the
// trust anchor, for the wrong-signature bearer test.
func genRSA(t *testing.T) *rsa.PrivateKey {
t.Helper()
otherKeyOnce.Do(func() { otherKey = mustRSA() })
return otherKey
}
// signHS256 forges an HMAC-signed token carrying the trusted kid. The verifier's
// algorithm allowlist has no HMAC family, so it is rejected before any key is
// consulted (the classic alg-confusion downgrade, closed).
func signHS256(t *testing.T, kid, sub string) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": sub, "exp": future()})
tok.Header["kid"] = kid
s, err := tok.SignedString([]byte("attacker-chosen-secret"))
if err != nil {
t.Fatalf("hs256 sign: %v", err)
}
return s
}
// forgeNone hand-builds an alg:none token (header.claims. with an empty
// signature) — the unsigned-token attack. "none" is absent from the allowlist,
// so it never verifies.
func forgeNone(kid, sub string) string {
enc := func(v any) string {
b, _ := json.Marshal(v)
return base64.RawURLEncoding.EncodeToString(b)
}
head := enc(map[string]any{"alg": "none", "typ": "JWT", "kid": kid})
body := enc(map[string]any{"sub": sub, "exp": future()})
return head + "." + body + "."
}
// cert is a minimal signing-cert create/update/delete body.
func cert(owner, name string) map[string]any {
return map[string]any{"owner": owner, "name": name, "cryptoAlgorithm": "RS256"}
}
// user wraps a create/update user body ({user:{...}, password}).
func user(owner, name string) map[string]any {
return map[string]any{"user": map[string]any{"owner": owner, "name": name}, "password": "x"}
}
// A CORS preflight carries no credentials — the browser strips them — so the
// Guard must never answer one with 401. It used to, which is indistinguishable
// to the page from "your origin is not allowed": a registered console asking
// its own IdP "which orgs am I in?" got a failed preflight and rendered an
// empty org switcher, with nothing in the network log but a 401 on OPTIONS.
//
// The pairing is the point. Opening the preflight must not open the DATA, so
// each case also asserts the real GET is still refused without a bearer.
func TestGuard_NeverAuthenticatesAPreflight(t *testing.T) {
h := newHarness(t)
for _, path := range []string{
"/v1/iam/get-organizations",
"/v1/iam/get-organization",
"/v1/iam/get-users",
} {
if got := h.do(t, "OPTIONS", path, "", nil); got == 401 {
t.Errorf("OPTIONS %s answered 401: a preflight has no credentials to "+
"reject, and the browser reads this as origin-not-allowed", path)
}
if got := h.do(t, "GET", path, "", nil); got != 401 {
t.Errorf("GET %s without a bearer = %d, want 401: letting the preflight "+
"through must not let the READ through", path, got)
}
}
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz
import "testing"
// The pure policy, tested exhaustively and independent of HTTP. authorize IS the
// security decision; this table is its full truth.
func TestAuthorizePolicy(t *testing.T) {
super := &Principal{Org: "admin", User: "root", Super: true}
orgAdmin := &Principal{Org: "hanzo", User: "boss", Admin: true}
regular := &Principal{Org: "hanzo", User: "alice"}
builtin := &Principal{Org: "built-in", User: "svc", Admin: true} // NOT super
cases := []struct {
name string
p *Principal
method string
entity string
owner string
name2 string
want bool
}{
// SuperAdmin: unrestricted, including the reserved owners and cross-org.
{"super writes admin cert", super, "POST", "certs", "admin", "k", true},
{"super writes built-in cert", super, "POST", "certs", "built-in", "k", true},
{"super cross-org user", super, "POST", "users", "orgb", "x", true},
// Poisoning gate: no non-super may write a reserved-owner resource.
{"org admin -> admin cert", orgAdmin, "POST", "certs", "admin", "k", false},
{"org admin -> built-in cert", orgAdmin, "POST", "certs", "built-in", "k", false},
{"regular -> admin cert", regular, "POST", "certs", "admin", "k", false},
{"built-in member -> built-in cert", builtin, "POST", "certs", "built-in", "k", false},
{"built-in member -> admin app", builtin, "POST", "application", "admin", "a", false},
// Tenant isolation: own org only.
{"org admin own org", orgAdmin, "POST", "users", "hanzo", "x", true},
{"org admin foreign org", orgAdmin, "POST", "users", "orgb", "x", false},
{"org admin empty owner", orgAdmin, "POST", "certs", "", "k", false},
// Regular user: read own record only; no writes, no others, no self-promote.
{"regular read own", regular, "GET", "users", "hanzo", "alice", true},
{"regular read other", regular, "GET", "users", "hanzo", "boss", false},
{"regular list org", regular, "GET", "users", "hanzo", "", false},
{"regular write own (self-promote)", regular, "POST", "users", "hanzo", "alice", false},
{"regular read own non-user entity", regular, "GET", "roles", "hanzo", "alice", false},
{"regular read foreign org self-name", regular, "GET", "users", "orgb", "alice", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := authorize(c.p, c.method, c.entity, c.owner, c.name2); got != c.want {
t.Fatalf("authorize(%s) = %v, want %v", c.name, got, c.want)
}
})
}
}
// SuperAdmin is exactly org=="admin"; built-in is NOT super — the built-in gap
// the poisoning gate must close depends on this.
func TestSuperIsAdminOrgOnly(t *testing.T) {
if (&Principal{Org: "built-in", Super: false}).Super {
t.Fatal("built-in must not be SuperAdmin")
}
// A built-in-org principal fails the reserved-owner write even for its own org.
if authorize(&Principal{Org: "built-in", Admin: true}, "POST", "certs", "built-in", "k") {
t.Fatal("built-in admin must not write built-in signing certs")
}
}
// Public vs gated is no longer a path allow-list this package owns — it is
// STRUCTURAL, decided by which group a route is registered on in routes.Route
// (the public group holds no Guard, the authed group holds it). The boundary is
// therefore proven end-to-end over the real registered router: TestPublicRoutesNeedNoBearer
// (public routes reachable without a bearer), TestUnauthenticatedWriteIs401 /
// TestCrossOrgWriteIs403 (authed routes gated), and TestFrameworkSideDoorsAreGated
// (/mcp + /openapi gated) in authz_cases_test.go.
func TestEntityOf(t *testing.T) {
cases := map[string]string{
"/v1/iam/users": "users",
"/v1/iam/users/get": "users",
"/v1/iam/users/update": "users",
"/v1/iam/certs/delete": "certs",
// Singular natives fold to the plural the policy is written in. It read
// "application" until that split the policy: the legacy verb folded to
// "applications" and matched the app self-read clause, while this native
// route stayed singular, matched nothing, and 403'd the same caller.
"/v1/iam/application": "applications",
"/v1/iam/audit-logs": "audit-logs",
"/mcp": "",
"/healthz": "",
"/v1/iam/": "",
}
for path, want := range cases {
if got := entityOf(path); got != want {
t.Errorf("entityOf(%q) = %q, want %q", path, got, want)
}
}
}
+123
View File
@@ -0,0 +1,123 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz_test
// Read-path authorization, driven through the REAL registered router. A status code
// is not the contract here — the BODY is: a listing that returns 200 while
// carrying the admin signing key is a total compromise. Every case asserts on
// what actually crossed the network.
import (
"bytes"
"encoding/json"
"io"
"net/http/httptest"
"strings"
"testing"
"github.com/hanzoai/iam/internal/testhttp"
)
// doBody is do() plus the response body — the read surface's real contract.
func (h *harness) doBody(t *testing.T, method, path, bearer string, body any) (int, string) {
t.Helper()
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
req := httptest.NewRequest(method, path, r)
req.Host = "hanzo.id"
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := testhttp.Do(h.app, req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
b, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return resp.StatusCode, string(b)
}
// leaks reports whether a response body carries private key material.
func leaks(body string) bool {
return strings.Contains(body, "PRIVATE KEY") || strings.Contains(body, `"privateKey":"-`)
}
// TestCertPrivateKeyNeverLeaks is the PoC that proved a full token-forgery
// compromise: a hanzo org admin listed certs and received the admin trust
// anchor's private key. Two independent defects composed into it — the listing
// ignored its owner (a GET binds no query, so in.Owner was always "", and an
// empty owner listed EVERY tenant), and the response serialized privateKey. Both
// are closed: the owner is resolved from the verified bearer (authz.Scope), and
// a Cert is masked on the way out (schema.Cert.Mask), so the key material that
// signs every token cannot cross the API at all — a relying party reads the
// PUBLIC half from the JWKS (RFC 7517).
func TestCertPrivateKeyNeverLeaks(t *testing.T) {
h := newHarness(t)
anchor := rsaKeyToPEM(t, h.key) // the admin signing cert's real private key
t.Run("the org-admin PoC leaks neither key material nor another tenant's cert", func(t *testing.T) {
status, body := h.doBody(t, "GET", "/v1/iam/certs?owner=hanzo", h.token(t, "hanzo/boss"), nil)
if status != 200 {
t.Fatalf("own-org listing must succeed, got %d: %s", status, body)
}
if strings.Contains(body, anchor) || leaks(body) {
t.Fatal("LEAK: admin signing key material in an org-admin listing")
}
if strings.Contains(body, signingKid) {
t.Fatal("CROSS-TENANT: the admin-owned cert appeared in a hanzo listing")
}
})
t.Run("a query owner cannot widen the listing past the bearer", func(t *testing.T) {
// Ask for the admin org explicitly: the guard denies the cross-tenant
// read, and even if it did not, Scope binds the listing to hanzo.
status, body := h.doBody(t, "GET", "/v1/iam/certs?owner=admin", h.token(t, "hanzo/boss"), nil)
if status == 200 && (strings.Contains(body, signingKid) || leaks(body)) {
t.Fatalf("LEAK: querying owner=admin escaped the bearer's scope: %s", body)
}
})
t.Run("SuperAdmin reads every tenant but never key material", func(t *testing.T) {
status, body := h.doBody(t, "GET", "/v1/iam/certs", h.token(t, "admin/root"), nil)
if status != 200 {
t.Fatalf("SuperAdmin listing must succeed, got %d: %s", status, body)
}
if !strings.Contains(body, signingKid) {
t.Fatalf("SuperAdmin must still SEE the cert (masked, not hidden): %s", body)
}
if strings.Contains(body, anchor) || leaks(body) {
t.Fatal("LEAK: key material served to SuperAdmin — the key never leaves the store")
}
})
t.Run("an unscoped listing by a tenant is refused, never lists-all", func(t *testing.T) {
status, body := h.doBody(t, "GET", "/v1/iam/certs", h.token(t, "hanzo/boss"), nil)
if status == 200 && strings.Contains(body, signingKid) {
t.Fatalf("LEAK: an empty owner listed every tenant: %s", body)
}
})
t.Run("the JWKS still publishes the PUBLIC half at both paths", func(t *testing.T) {
// The keys are masked out of the CRUD surface, not out of the protocol:
// the gateway defaults to the root path, the SDK reads the /v1/iam one.
for _, p := range []string{"/.well-known/jwks", "/v1/iam/.well-known/jwks"} {
status, body := h.doBody(t, "GET", p, "", nil)
if status != 200 {
t.Fatalf("%s must be public and serve keys, got %d", p, status)
}
if !strings.Contains(body, `"kty":"RSA"`) || !strings.Contains(body, signingKid) {
t.Fatalf("%s must publish the signing key: %s", p, body)
}
if leaks(body) || strings.Contains(body, `"d":`) {
t.Fatalf("LEAK: %s served private material: %s", p, body)
}
}
})
}
+176
View File
@@ -0,0 +1,176 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz
import (
"os"
"strings"
"github.com/hanzoai/iam/pkg/store"
)
// Confidential-client capabilities — the port of the v1 gate (object/app_authz.go
// + controllers/app_mutation_guard.go requireAppCapability) that revoked the
// "every client credential is a global admin" privilege.
//
// A Cap is a named authority an app principal holds ONLY when its application
// name is listed in the allowlist Env names. It is the ONLY thing an app
// principal's authority is made of: an app is never a SuperAdmin and never an
// org admin (see Principal), so a leaked client credential grants exactly the
// capabilities its NAME was allowlisted for and nothing more.
//
// The key is the application NAME, not its (owner, name) row. That alone would let
// ANY owner's app claim a listed name, so Allowed ALSO pins the app's OWNING org to
// a reserved platform signing owner (store.IsSigningCertOwner): the name is thereby
// reserved to the platform's admin-owned app, and a tenant that registers
// <theirOrg>/hanzo-console — same name, its own owner — inherits none of its grants.
// The pin is what ENFORCES that reservation; the name match alone was the escalation.
// Cap is one capability: a Name for diagnostics and the Env var holding its
// comma-separated allowlist of application names.
type Cap struct {
Name string
Env string
}
// The capability set, matching the live allowlists byte-for-byte
// (universe infra/k8s/operator/crs/iam.yaml). Every one is fail-secure: an unset
// or empty allowlist denies EVERY app.
var (
// CapKeyMint gates minting, rotating, or revoking a credential on another
// principal's behalf — the service-account administration boundary, since a
// minted key is an org-billing credential.
CapKeyMint = Cap{Name: "key-mint", Env: "IAM_KEY_MINT_ALLOWED_APPS"}
// CapUserAdmin gates cross-user account mutation (owner, isAdmin, email,
// type, credentials) — cloud moves an onboarding user into the org it just
// created through this.
CapUserAdmin = Cap{Name: "user", Env: "IAM_USER_ADMIN_APPS"}
// CapOrgAdmin gates organization create/read/update/delete. Unlike the
// signing-material capabilities this one is populated in every environment:
// the brand consoles legitimately create customer orgs during onboarding.
CapOrgAdmin = Cap{Name: "organization", Env: "IAM_ORG_ADMIN_APPS"}
// CapServiceAccountRead gates LISTING an org's service accounts — names and
// metadata only, never secrets. It is the read-only counterpart to
// CapKeyMint (a read cap can never mint, rotate, or delete a credential) and
// is additionally tenant-bound by BoundToOrg.
CapServiceAccountRead = Cap{Name: "service-account-read", Env: "IAM_SA_LIST_ALLOWED_APPS"}
// CapKeyResolve gates resolving an opaque SECRET API key (sk-) to its owning
// principal via get-user?accessKey. It is a CREDENTIAL-DISCLOSURE boundary: the
// caller presents a secret key and learns WHO it authenticates, so it must never
// be an arbitrary authenticated caller. A public pk- is NOT resolved here: it is
// write-only, and its own narrower CapPublishableResolve turns it into an org, never
// a principal. The intended sole holder is the cloud
// identity boundary (SanitizeIdentity), which turns a keyed request into the same
// principal a JWT yields. Fail-secure exactly like the others: an unset or empty
// allowlist lets NO app resolve a key. Enforced additionally as app-only at the
// handler (a human, even a SuperAdmin, holds a capability vacuously — so the key
// path also requires p.App != "" to keep this a service-only door).
//
// Keyed on the application NAME (via Allowed → p.App), matching all four sibling
// Caps above — the ONE way capabilities are matched in this family. RED F3 asked
// whether it should key on clientId like the issuetoken mint verbs (appInList);
// deliberately NOT, because (1) that is a DIFFERENT, older mechanism, so making
// CapKeyResolve clientId-based would make it the sole clientId-keyed Cap —
// inconsistent with its own family — and (2) it would require adding ClientId to
// the Principal shape. The owner-pin (Allowed requires AppOwner ∈ signing owners)
// already defeats the name-collision vector: a tenant app that reuses a listed
// name is not a signing owner and holds nothing. Under the <org>-<app> convention
// name == clientId, so the two are equivalent in practice. Gate unchanged.
CapKeyResolve = Cap{Name: "key-resolve", Env: "IAM_KEY_RESOLVE_APPS"}
// CapPublishableResolve gates resolving a WRITE-ONLY publishable pk- to just the
// ORG that holds it (keys.resolve → /v1/iam/resolve-key), for cloud's ingest
// boundary. It is strictly NARROWER than CapKeyResolve and deliberately a separate
// authority: this door discloses only an org (a pk- is public, shipped in client
// JS), NEVER a principal, so the two must not be conflated — a client granted the
// org-resolve capability must never thereby be able to disclose WHO a secret key
// authenticates. Fail-secure exactly like the others: an unset or empty allowlist
// lets NO app resolve a publishable key. Keyed on the application NAME (via
// Allowed → p.App), like every sibling Cap, with the same owner-pin (Allowed
// requires AppOwner ∈ reserved signing owners), so a tenant app that reuses a
// listed name inherits nothing.
CapPublishableResolve = Cap{Name: "publishable-resolve", Env: "IAM_PUBLISHABLE_RESOLVE_APPS"}
)
// Allowed reports whether p holds c.
//
// A non-app principal holds every capability vacuously: this gate concerns
// confidential clients ONLY, and a human's authority is decided by the org
// policy in authorize(). Conflating the two would either lock every human out or
// hand every app a human's scope.
//
// Fail-secure, exactly as v1: an app whose allowlist is unset, empty, or does
// not name it holds nothing.
func Allowed(p *Principal, c Cap) bool {
if p == nil {
return false
}
if p.App == "" {
return true // not an app; the org policy decides
}
// The owner-pin: an app holds a platform capability ONLY when its OWNING org is a
// reserved platform signing owner (admin/built-in). Every allow-listed console is
// admin-owned, so this never revokes a legitimate grant — but it binds the NAME
// allowlist to the platform: a tenant that registers <theirOrg>/hanzo-console
// (same name, its OWN owner) is not a signing owner, so it inherits nothing. This
// is the single gate that turns the allowlist's NAME key from a spoofable label
// into an authority reserved to the admin-owned app.
if !store.IsSigningCertOwner(p.AppOwner) {
return false
}
if c.Env == "" {
return false
}
for _, item := range strings.Split(os.Getenv(c.Env), ",") {
if strings.TrimSpace(item) == p.App {
return true
}
}
return false
}
// BoundToOrg reports whether an app principal is bound to org by the
// <org>-<app> naming convention — app/hanzo-team may act on organization=hanzo
// and on no other tenant's. The org is derived from the (allowlist-reserved)
// application NAME, so the binding holds regardless of the app row's owner, and
// it is the same prefix rule the service-account names it reads obey.
func BoundToOrg(p *Principal, org string) bool {
if p == nil || org == "" {
return false
}
prefix := org + "-"
return len(p.App) > len(prefix) && strings.HasPrefix(p.App, prefix)
}
// capFor maps an entity to the capability a confidential client needs to act on
// it. An entity with NO mapping grants an app nothing: unmapped denies exactly
// as an unset allowlist does, which IS v1's live behaviour for every capability
// the deployment leaves empty — certs, providers, tokens, syncers, webhooks are
// all deny-all by design, because no client credential should ever reach signing
// material. Only the two entities a live confidential client touches are mapped:
// the brand consoles create customer orgs, and cloud moves the onboarding user
// into the org it just created.
func capFor(entity string) Cap {
switch entity {
case "organizations":
return CapOrgAdmin
case "users":
return CapUserAdmin
case "keys":
// The same authority that already mints, rotates and revokes a user's
// credential on its behalf (CapKeyMint) also READS the key set it manages —
// a strictly smaller disclosure than the mint it is already trusted with,
// and safe on its own now that every key read is masked (schema.Key.Mask
// blanks the confidential sk- half). Without this, the ONE key list in the
// system was reachable by SuperAdmin alone, so the surface a user calls to
// see their own keys had no truthful read at all and reported "no key"
// immediately after a successful mint.
return CapKeyMint
}
return Cap{}
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz
import "testing"
// entityNoun is the fold that makes both surfaces name the same entity. Pin it
// directly: this is the mapping the whole compat authorization surface rides on.
func TestEntityNoun_FoldsVerbSpellingOntoTheEntity(t *testing.T) {
for seg, want := range map[string]string{
"get-application": "applications",
"add-organization": "organizations",
"update-user": "users",
"delete-membership": "memberships", // already plural, left alone
"get-cert": "certs",
"get-users": "users",
"applications": "applications", // native noun, unchanged
"certs": "certs",
"organizations": "organizations",
"get-": "",
"": "",
} {
if got := entityNoun(seg); got != want {
t.Errorf("entityNoun(%q) = %q, want %q", seg, got, want)
}
}
}
// Every key route must name the SAME entity, so a capability keyed on it is live on
// all of them. The keys package used to serve its list at /v1/iam/keys and every other
// op at /v1/iam/key, which entityOf reads as two different entities ("keys" and
// "key") — so any capability granted for keys was dead on whichever half you did not
// name. This is the same defect entityNoun fixes for the legacy verb spellings,
// arrived at from the other direction: an inconsistent NOUN rather than a verb.
func TestEntityOf_EveryKeyRouteNamesOneEntity(t *testing.T) {
for _, path := range []string{
"/v1/iam/keys",
"/v1/iam/keys/get",
"/v1/iam/keys/update",
"/v1/iam/keys/delete",
} {
if got := entityOf(path); got != "keys" {
t.Errorf("entityOf(%q) = %q, want \"keys\" — a capability keyed on keys is dead on this path", path, got)
}
}
}
// The read that makes a user's own key list truthful: the confidential client already
// trusted to MINT, ROTATE and REVOKE a user's credential may also READ the key set it
// manages. Strictly less disclosure than the mint it already holds, and safe on its
// own because every key read is masked (schema.Key.Mask blanks the sk- half).
func TestCapFor_KeysMapsToTheMintCapability(t *testing.T) {
if capFor("keys") != CapKeyMint {
t.Fatalf("capFor(\"keys\") = %+v, want CapKeyMint — without it the ONE key list is SuperAdmin-only and a user cannot see their own keys", capFor("keys"))
}
// Still fail-secure: holding it requires being ON the allow-list, under a reserved
// signing owner. The capability is a grant to a named platform app, not to apps.
t.Setenv(CapKeyMint.Env, "hanzo-console")
if !Allowed(&Principal{App: "hanzo-console", AppOwner: "admin"}, capFor("keys")) {
t.Fatal("an allow-listed, admin-owned minter must be able to read the keys it manages")
}
if Allowed(&Principal{App: "hanzo-console", AppOwner: "acme"}, capFor("keys")) {
t.Fatal("the owner-pin must deny a tenant app that reuses an allow-listed name")
}
if Allowed(&Principal{App: "other-app", AppOwner: "admin"}, capFor("keys")) {
t.Fatal("an app that is not on the allow-list must hold nothing")
}
}
@@ -0,0 +1,48 @@
package authz
import "testing"
// The "<org>-platform-kms" machine identity may READ its own org's projects —
// the grant that lets cloud's platform resolve a tenant's projects from THIS
// store instead of a second embedded database. Each wall of the grant gets its
// own negative: wrong method, wrong entity, wrong org, wrong identity.
func TestAuthorize_KMSMachineReadsOwnProjects(t *testing.T) {
acme := &Principal{Org: "acme", App: "acme-platform-kms", AppOwner: "admin"}
if !authorize(acme, "GET", "projects", "acme", "web") {
t.Fatal("the org's own platform-kms identity must read the org's projects")
}
if !authorize(acme, "GET", "projects", "acme", "") {
t.Fatal("listing the org's projects is the same read")
}
// Wrong ORG: one tenant's identity can never walk another's list.
if authorize(acme, "GET", "projects", "rival", "web") {
t.Fatal("cross-org project read must be refused")
}
// Wrong METHOD: the grant is a read, never a write.
for _, m := range []string{"POST", "PUT", "PATCH", "DELETE"} {
if authorize(acme, m, "projects", "acme", "web") {
t.Fatalf("%s on projects must be refused — the grant is read-only", m)
}
}
// Wrong ENTITY: projects and nothing else.
for _, e := range []string{"users", "organizations", "applications", "providers"} {
if authorize(acme, "GET", e, "acme", "x") {
t.Fatalf("the grant must not widen to %s", e)
}
}
// Wrong IDENTITY: only the contract-named app. A sibling app in the same
// org, and another org's platform-kms name, both stay refused.
for _, app := range []string{"acme-console", "rival-platform-kms", "platform-kms"} {
p := &Principal{Org: "acme", App: app, AppOwner: "admin"}
if authorize(p, "GET", "projects", "acme", "web") {
t.Fatalf("app %q must not inherit the platform-kms grant", app)
}
}
// An empty owner target is not admitted: the read must NAME the org so the
// owner==p.Org pin has something to hold.
if authorize(acme, "GET", "projects", "", "web") {
t.Fatal("an owner-less project read must be refused")
}
}
+445
View File
@@ -0,0 +1,445 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz_test
// AN ORG-SCOPED REQUEST IS HONOURED OR REFUSED, NEVER SILENTLY REINTERPRETED.
//
// The defect these tests pin down, reproduced twice against production on
// 2026-07-28 with the hanzo-console client credential (home org `hanzo`):
//
// GET /v1/iam/get-users?owner=hanzo -> 200 ok, 262 records, owner=hanzo
// GET /v1/iam/get-users?owner=lux -> 200 ok, 262 records, owner=hanzo
// GET /v1/iam/get-users?owner=nonexistent-xyz -> 200 ok, 262 records, owner=hanzo
//
// Nothing in the status code, the `status` field, the message or the count says
// the filter was dropped, so a FABRICATED org is indistinguishable from a real
// one AND from the caller's own. That is not a confidentiality breach — no
// tenant's rows escape — it is MISATTRIBUTION, which is worse in one specific
// way: the caller believes it holds tenant B while holding tenant A. It nearly
// caused a production purge of the wrong tenant: an operator asked for
// owner=lux, received 262 hanzo accounts, and every surface signal read success.
//
// A status code is therefore NOT the contract here. Every case asserts on the
// RECORDS that crossed the wire, because "200 with somebody else's rows" is the
// exact failure being closed.
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http/httptest"
"testing"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/internal/testhttp"
)
// The two spellings an unauthorized caller must not be able to tell apart: a
// FOREIGN-BUT-REAL tenant, and a name no tenant has ever had. If these two
// answers differ in any byte, the refusal is an org-existence oracle.
const (
foreignRealOrg = "lux"
fabricatedOrg = "nonexistent-org-xyz"
)
// ---- request helpers -------------------------------------------------------
// reply is one response reduced to what a client can actually observe.
type reply struct {
status int
body string
}
// records decodes the v1 envelope's `data` array into (owner, name) pairs — the
// rows that actually crossed the wire.
func (r reply) records(t *testing.T) []schema.User {
t.Helper()
var env struct {
Data []schema.User `json:"data"`
}
if err := json.Unmarshal([]byte(r.body), &env); err != nil {
return nil // an error envelope carries no array; zero records is the point
}
return env.Data
}
// owners returns the DISTINCT owners present in a listing — the misattribution
// assertion: a request for org X must never answer with rows owned by Y.
func (r reply) owners(t *testing.T) map[string]int {
t.Helper()
got := map[string]int{}
for _, u := range r.records(t) {
got[u.Owner]++
}
return got
}
// send issues one request through the REAL registered router and returns
// everything a client sees. auth is applied verbatim as the Authorization value.
func (h *harness) send(t *testing.T, method, path, auth string, body any) reply {
t.Helper()
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
req := httptest.NewRequest(method, path, r)
req.Host = "hanzo.id"
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if auth != "" {
req.Header.Set("Authorization", auth)
}
resp, err := testhttp.Do(h.app, req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
b, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return reply{status: resp.StatusCode, body: string(b)}
}
// asApp is the client_secret_basic header a confidential client sends — the
// exact transport the hanzo-console credential used in the production repro.
func asApp(clientID, secret string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(clientID+":"+secret))
}
// asUser is the bearer header a human carries.
func asUser(tok string) string { return "Bearer " + tok }
// ---- fixtures --------------------------------------------------------------
// seedScopeFixture builds the production shape: a foreign-but-real tenant `lux`
// with its own users and projects alongside hanzo's, plus the admin-owned
// hanzo-console application whose capability allowlist admits it to the users
// entity. That capability is what carries the request PAST the Guard and into
// authz.Scope — without it the Guard refuses first and the silent discard is
// never reached, which is why a unit test on authorize() alone proves nothing
// here.
func seedScopeFixture(t *testing.T, h *harness) {
t.Helper()
t.Setenv("IAM_USER_ADMIN_APPS", "hanzo-console")
t.Setenv("IAM_ORG_ADMIN_APPS", "hanzo-console")
seedAppRow(t, h.db, "admin", "hanzo-console", "s3cret", signingKid)
// The foreign-but-real tenant. Its org row exists, its users exist — so a
// refusal that consulted the store COULD tell it apart from a fabrication.
seedOrgRow(t, h.db, foreignRealOrg)
seedUser(t, h.db, foreignRealOrg, "lux-alice", true, false, false)
seedUser(t, h.db, foreignRealOrg, "lux-bob", false, false, false)
seedProjectRow(t, h.db, foreignRealOrg, "lux-secret-project")
seedOrgRow(t, h.db, "hanzo")
seedProjectRow(t, h.db, "hanzo", "hanzo-project")
}
// seedOrgRow registers a tenant in the org registry, which is admin-owned: the
// org's identity is its NAME, not its owner.
func seedOrgRow(t *testing.T, db orm.DB, name string) {
t.Helper()
o := orm.New[schema.Organization](db)
o.Owner, o.Name = "admin", name
o.SetId("admin/" + name)
if err := o.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed org %s: %v", name, err)
}
}
// seedProjectRow adds one project under a tenant.
func seedProjectRow(t *testing.T, db orm.DB, owner, name string) {
t.Helper()
p := orm.New[schema.Project](db)
p.Owner, p.Name = owner, name
p.SetId(owner + "/" + name)
if err := p.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed project %s/%s: %v", owner, name, err)
}
}
// ---- the bug ---------------------------------------------------------------
// THE PRODUCTION REPRO. A non-super principal asking for an org that is not its
// own must be REFUSED — not answered with its own org's rows under the foreign
// org's name.
func TestScope_ForeignOrgIsRefusedNotSilentlyReinterpreted(t *testing.T) {
h := newHarness(t)
seedScopeFixture(t, h)
auth := asApp("hanzo-console", "s3cret")
// Own org: unchanged, and it is what makes the foreign case meaningful —
// there ARE hanzo rows to be misattributed.
own := h.send(t, "GET", "/v1/iam/get-users?owner=hanzo", auth, nil)
if own.status != 200 {
t.Fatalf("own-org listing = %d, want 200 (unchanged): %s", own.status, own.body)
}
if len(own.records(t)) == 0 {
t.Fatal("own-org listing returned no rows; the fixture cannot prove misattribution")
}
for _, org := range []string{foreignRealOrg, fabricatedOrg} {
t.Run(org, func(t *testing.T) {
got := h.send(t, "GET", "/v1/iam/get-users?owner="+org, auth, nil)
// (1) The refusal must be EXPLICIT.
if got.status != 403 {
t.Errorf("GET get-users?owner=%s = %d, want 403 — an org-scoped request "+
"is honoured or refused, never silently reinterpreted: %s",
org, got.status, got.body)
}
// (2) And it must carry NOTHING. A 403 that still ships rows, or a
// 200 carrying the caller's own rows under another org's name, is
// the misattribution this closes.
if owners := got.owners(t); len(owners) > 0 {
t.Errorf("GET get-users?owner=%s returned rows owned by %v — the caller "+
"asked for %s and was handed somebody else's tenant", org, owners, org)
}
})
}
}
// The refusal must not become an ORG-EXISTENCE ORACLE. A foreign-but-real tenant
// and a name no tenant has ever had must be answered IDENTICALLY, byte for byte,
// or an unauthorized caller enumerates the customer list one guess at a time.
//
// The property is structural, not cosmetic: the decision is taken from the
// verified principal alone and never touches the store, so there is no lookup
// whose outcome could differ. This test is what keeps it that way.
func TestScope_ForeignAndFabricatedOrgsAreIndistinguishable(t *testing.T) {
h := newHarness(t)
seedScopeFixture(t, h)
auth := asApp("hanzo-console", "s3cret")
for _, path := range []string{
"/v1/iam/get-users?owner=",
"/v1/iam/get-organizations?owner=",
"/v1/iam/get-organization-projects?organization=",
"/v1/iam/scim/v2/Users?owner=",
} {
t.Run(path, func(t *testing.T) {
real := h.send(t, "GET", path+foreignRealOrg, auth, nil)
fake := h.send(t, "GET", path+fabricatedOrg, auth, nil)
if real.status != fake.status {
t.Errorf("%s: real org -> %d, fabricated org -> %d: the STATUS distinguishes "+
"a tenant that exists from one that does not", path, real.status, fake.status)
}
if real.body != fake.body {
t.Errorf("%s: the BODY distinguishes a real tenant from a fabricated one\n"+
" real (%s): %s\n fake (%s): %s",
path, foreignRealOrg, real.body, fabricatedOrg, fake.body)
}
})
}
}
// A SuperAdmin's cross-tenant reach is the ONE cross-tenant scope and is
// unchanged: it asks for lux and it gets LUX, not hanzo.
func TestScope_SuperAdminCrossOrgReadIsUnchanged(t *testing.T) {
h := newHarness(t)
seedScopeFixture(t, h)
root := asUser(h.token(t, "admin/root"))
got := h.send(t, "GET", "/v1/iam/get-users?owner="+foreignRealOrg, root, nil)
if got.status != 200 {
t.Fatalf("SuperAdmin cross-org listing = %d, want 200: %s", got.status, got.body)
}
owners := got.owners(t)
if owners[foreignRealOrg] == 0 {
t.Errorf("SuperAdmin asked for %s and got %v — cross-tenant reach regressed",
foreignRealOrg, owners)
}
if len(owners) != 1 {
t.Errorf("SuperAdmin asked for %s and got rows from %v — the owner filter was dropped",
foreignRealOrg, owners)
}
}
// Own-org access is untouched for a HUMAN too, on the endpoints the Guard does
// not pre-authorize (their target rides in ?organization=, so authz.Scope is the
// only gate they have).
func TestScope_OwnOrgReadIsUnchangedForAHuman(t *testing.T) {
h := newHarness(t)
seedScopeFixture(t, h)
boss := asUser(h.token(t, "hanzo/boss"))
got := h.send(t, "GET", "/v1/iam/get-organization-projects?organization=hanzo", boss, nil)
if got.status != 200 {
t.Fatalf("own-org project list = %d, want 200 (unchanged): %s", got.status, got.body)
}
var env struct {
Data []schema.Project `json:"data"`
}
if err := json.Unmarshal([]byte(got.body), &env); err != nil {
t.Fatalf("decode %s: %v", got.body, err)
}
if len(env.Data) == 0 {
t.Errorf("own-org project list came back empty: %s", got.body)
}
}
// The SAME silent discard, reachable by an ORDINARY HUMAN — no client credential
// needed. get-organization-projects and get-organization-workspaces are
// handler-authorized (their target rides in ?organization=, which the Guard does
// not inspect), so authz.Scope is the whole gate, and it rewrote the parameter.
// An org admin asking for lux's projects got HANZO's, labelled lux.
func TestScope_HandlerAuthorizedReadsAreNotSilentlyRewritten(t *testing.T) {
h := newHarness(t)
seedScopeFixture(t, h)
boss := asUser(h.token(t, "hanzo/boss"))
for _, path := range []string{
"/v1/iam/get-organization-projects?organization=" + foreignRealOrg,
"/v1/iam/get-organization-workspaces?organization=" + foreignRealOrg,
} {
t.Run(path, func(t *testing.T) {
got := h.send(t, "GET", path, boss, nil)
if got.status != 403 {
t.Errorf("GET %s = %d, want 403: %s", path, got.status, got.body)
}
var env struct {
Data []map[string]any `json:"data"`
}
_ = json.Unmarshal([]byte(got.body), &env)
for _, row := range env.Data {
t.Errorf("GET %s returned a row owned by %v — a hanzo row answering a "+
"request for %s is the misattribution, not a leak", path, row["owner"], foreignRealOrg)
}
})
}
}
// THE WORST SHAPE OF THE BUG: a path-targeted SCIM read. `/Users/lux/alice`
// named a specific row in a specific tenant; Scope rewrote the owner half and
// the handler answered with hanzo/alice — a DIFFERENT HUMAN, under the requested
// identity's URL. A caller that then acts on that record acts on the wrong
// person in the wrong tenant.
func TestScope_SCIMPathTargetIsNeverRewrittenToAnotherTenant(t *testing.T) {
h := newHarness(t)
seedScopeFixture(t, h)
boss := asUser(h.token(t, "hanzo/boss"))
got := h.send(t, "GET", "/v1/iam/scim/v2/Users/"+foreignRealOrg+"/alice", boss, nil)
if got.status == 200 {
t.Errorf("GET /Users/%s/alice = 200 — it resolved SOMEBODY, and hanzo/alice is "+
"the only alice there is: %s", foreignRealOrg, got.body)
}
if got.status != 403 {
t.Errorf("GET /Users/%s/alice = %d, want 403: %s", foreignRealOrg, got.status, got.body)
}
}
// A WRITE misattribution is worse than a read one: a SCIM provisioning call that
// named tenant `lux` created the account inside `hanzo`. Assert the refusal AND
// that nothing was persisted anywhere — the real security property, not the
// status code.
func TestScope_SCIMProvisioningNeverLandsInTheWrongTenant(t *testing.T) {
h := newHarness(t)
seedScopeFixture(t, h)
boss := asUser(h.token(t, "hanzo/boss"))
body := map[string]any{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": "misfiled",
"urn:ietf:params:scim:schemas:extension:hanzo:2.0:User": map[string]any{
"owner": foreignRealOrg,
},
}
got := h.send(t, "POST", "/v1/iam/scim/v2/Users", boss, body)
if got.status == 201 {
t.Errorf("SCIM create naming owner=%s succeeded (%d): %s", foreignRealOrg, got.status, got.body)
}
if h.userExists(t, "hanzo", "misfiled") {
t.Errorf("a create that NAMED tenant %s persisted the account under hanzo — "+
"the caller believes it provisioned %s", foreignRealOrg, foreignRealOrg)
}
if h.userExists(t, foreignRealOrg, "misfiled") {
t.Errorf("a hanzo admin provisioned an account inside %s", foreignRealOrg)
}
}
// get-users AND get-organization must give the SAME answer to "may this
// principal see another tenant's org?" — for every principal that holds no
// cross-tenant grant. A human org-admin is the case that carries a secret, and on
// both verbs a foreign-but-real org and a fabricated one are the identical
// existence-independent refusal. Answering one of them differently would make the
// pair an org-existence oracle no matter how carefully the other was written.
func TestScope_GetUsersAndGetOrganizationAgreeForAnUngrantedPrincipal(t *testing.T) {
h := newHarness(t)
seedScopeFixture(t, h)
boss := asUser(h.token(t, "hanzo/boss")) // org-admin of hanzo, no capability, not super
for _, verb := range []struct{ name, pattern string }{
{"get-users", "/v1/iam/get-users?owner=%s"},
{"get-organization", "/v1/iam/get-organization?id=admin%%2F%s"},
} {
t.Run(verb.name, func(t *testing.T) {
real := h.send(t, "GET", fmt.Sprintf(verb.pattern, foreignRealOrg), boss, nil)
fake := h.send(t, "GET", fmt.Sprintf(verb.pattern, fabricatedOrg), boss, nil)
if real.status == 200 || fake.status == 200 {
t.Errorf("%s admitted a foreign org: real=%d fake=%d", verb.name, real.status, fake.status)
}
if real.status != fake.status || real.body != fake.body {
t.Errorf("%s distinguishes a real tenant from a fabricated one — that pair IS "+
"the org-existence oracle\n real: %d %s\n fake: %d %s",
verb.name, real.status, real.body, fake.status, fake.body)
}
})
}
}
// The other half of the coherent policy: where a cross-tenant grant DOES exist,
// it HONOURS the org the request names. CapOrgAdmin is the brand consoles'
// registry authority — they create customer orgs during onboarding and read
// Organization.Founder to resume a partial one — so a grant holder asking for lux
// gets LUX's row. Correctly attributed is the whole requirement; substituting
// hanzo's row here would be the same misattribution wearing a capability.
func TestScope_AGrantHonoursTheOrgItNamesAndNeverSubstitutes(t *testing.T) {
h := newHarness(t)
seedScopeFixture(t, h)
got := h.send(t, "GET", "/v1/iam/get-organization?id=admin%2F"+foreignRealOrg,
asApp("hanzo-console", "s3cret"), nil)
if got.status != 200 {
t.Fatalf("CapOrgAdmin registry read = %d, want 200 — onboarding reads Founder "+
"through this: %s", got.status, got.body)
}
var env struct {
Data struct {
Owner string `json:"owner"`
Name string `json:"name"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(got.body), &env); err != nil {
t.Fatalf("decode %s: %v", got.body, err)
}
if env.Data.Name != foreignRealOrg {
t.Errorf("asked for org %q, got %q — a grant must return the org it was asked for, "+
"never another", foreignRealOrg, env.Data.Name)
}
}
// An UNSTATED scope is not a reinterpreted one. Omitting ?owner= has always
// meant "my own org" and still does — the rule is about a request that NAMES an
// org it may not have, not about one that names none.
func TestScope_UnstatedOwnerStillMeansOwnOrg(t *testing.T) {
h := newHarness(t)
seedScopeFixture(t, h)
got := h.send(t, "GET", "/v1/iam/get-users", asApp("hanzo-console", "s3cret"), nil)
if got.status != 200 {
t.Fatalf("get-users with no owner = %d, want 200 (unchanged): %s", got.status, got.body)
}
owners := got.owners(t)
if owners["hanzo"] == 0 || len(owners) != 1 {
t.Errorf("get-users with no owner returned %v, want hanzo only", owners)
}
}
+204
View File
@@ -0,0 +1,204 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz_test
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http/httptest"
"testing"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/internal/testhttp"
)
// THE REQUEST CLOUD ACTUALLY MAKES.
//
// The first cut of the self-read grant was unit-tested against authorize() with
// entity "applications" and passed — while production still 403'd, because the
// live caller uses the the legacy surface alias /v1/iam/get-application and entityOf resolved
// that to the literal "get-application", which matched no clause. A test written
// against the noun surface proves nothing about the verb surface, exactly like the
// login tests that post authorize params in the body no real client uses.
//
// So every case here goes through the REAL router, over the compat verb, with
// client_secret_basic — the shape hanzo-cloud sends.
// seedAppRow registers an application the way the platform does: owned by admin,
// holding a secret, referencing a signing cert.
func seedAppRow(t *testing.T, db orm.DB, owner, name, secret, cert string) {
t.Helper()
a := orm.New[schema.Application](db)
a.Owner, a.Name = owner, name
a.ClientId, a.ClientSecret = name, secret
a.Organization, a.Cert = "hanzo", cert
a.SetId(owner + "/" + name)
if err := a.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed app %s/%s: %v", owner, name, err)
}
}
// seedCertRow adds a signing cert row under an owner.
func seedCertRow(t *testing.T, db orm.DB, owner, name string) {
t.Helper()
c := orm.New[schema.Cert](db)
c.Owner, c.Name = owner, name
c.CryptoAlgorithm = "RS256"
c.SetId(owner + "/" + name)
if err := c.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed cert %s/%s: %v", owner, name, err)
}
}
// basicGet issues a GET with client_secret_basic, as a confidential client does.
func (h *harness) basicGet(t *testing.T, path, clientID, secret string) int {
t.Helper()
req := httptest.NewRequest("GET", path, nil)
req.Host = "hanzo.id"
req.Header.Set("Authorization", "Basic "+
base64.StdEncoding.EncodeToString([]byte(clientID+":"+secret)))
resp, err := testhttp.Do(h.app, req)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
return resp.StatusCode
}
// A relying party bootstraps: read its own application, then the cert that
// application names. Both must succeed over the COMPAT VERB, or cloud panics one
// line after the read it was granted.
func TestSelfRead_OverTheCompatVerbCloudActuallyCalls(t *testing.T) {
h := newHarness(t)
seedAppRow(t, h.db, "admin", "hanzo-cloud", "s3cret", signingKid)
// Production carries the SAME cert under two owners (seed drift, same keypair);
// mirror that so the org-qualified spelling the binary sends is exercised.
seedCertRow(t, h.db, "hanzo", signingKid)
for _, tc := range []struct {
name, path string
want int
}{
// The exact request, both spellings of the id the caller may send.
{"own application, owner-qualified", "/v1/iam/get-application?id=admin%2Fhanzo-cloud", 200},
// 200 is not enough: Scope used to rewrite the owner to the app's SERVED org,
// so the read was authorized and then answered "the entity does not exist" —
// a 200 that is functionally the 403 it replaced. The body is asserted below.
{"own cert, owner-qualified", "/v1/iam/get-cert?id=admin%2F" + signingKid, 200},
{"own cert, bare name", "/v1/iam/get-cert?id=" + signingKid, 200},
// THE SHAPE THE BINARY SENDS. ai/internal/iam/cert.go:35 builds
// "<IAM_ORG>/<name>", so hanzo/cert-hanzo is the only spelling that matters in
// production; the bare form is the one I verified last time and it was not it.
{"own cert, org-qualified (what ai sends)", "/v1/iam/get-cert?id=hanzo%2F" + signingKid, 200},
// The native noun surface must agree — one policy, two spellings. The LIST
// route is not the self-read: ApplicationQuery carries only Owner, so
// ?name= is ignored and this asks to enumerate EVERY application under the
// reserved admin org — which a tenant app may not do. 403 is the right
// answer and the policy agreeing with itself.
//
// It read 400 until zip v1.17.1 taught a typed op to read the whole URL. The
// query never bound, so validate fired on an empty Owner and the shape
// complaint landed BEFORE authz could refuse — a 400 standing in for a 403,
// which this case then asserted as "reaches the handler = authorized".
{"noun surface LIST of a reserved org is refused", "/v1/iam/applications?owner=admin&name=hanzo-cloud", 403},
// The actual self-read on the noun surface: ONE application by its natural
// key, which is what the compat verb above expresses.
{"own application, noun surface (single)", "/v1/iam/application?owner=admin&name=hanzo-cloud", 200},
} {
t.Run(tc.name, func(t *testing.T) {
if got := h.basicGet(t, tc.path, "hanzo-cloud", "s3cret"); got != tc.want {
t.Errorf("GET %s as hanzo-cloud = %d, want %d", tc.path, got, tc.want)
}
})
}
}
// The grant stays a SELF-read. Everything an app is not is still refused, over the
// same verb surface that now resolves correctly — normalizing entityOf must not
// have turned the compat aliases into an open door.
func TestSelfRead_StillRefusesEverythingElse(t *testing.T) {
h := newHarness(t)
seedAppRow(t, h.db, "admin", "hanzo-cloud", "s3cret", signingKid)
seedAppRow(t, h.db, "admin", "hanzo-console", "other", signingKid)
seedAppRow(t, h.db, "hanzo", "hanzo-cloud-tenant", "tsecret", "cert-other")
// A second signing cert this app does NOT reference.
c := orm.New[schema.Cert](h.db)
c.Owner, c.Name = "admin", "cert-lux"
c.CryptoAlgorithm = "RS256"
c.SetId("admin/cert-lux")
if err := c.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed cert: %v", err)
}
for _, tc := range []struct{ name, path string }{
{"a sibling application", "/v1/iam/get-application?id=admin%2Fhanzo-console"},
{"same name, tenant owner", "/v1/iam/get-application?id=hanzo%2Fhanzo-cloud"},
{"a cert it does not reference", "/v1/iam/get-cert?id=admin%2Fcert-lux"},
{"a cert it does not reference, bare", "/v1/iam/get-cert?id=cert-lux"},
{"the whole application list", "/v1/iam/get-applications?owner=admin"},
{"the whole cert list", "/v1/iam/get-certs?owner=admin"},
{"a user row", "/v1/iam/get-users?owner=admin"},
} {
t.Run(tc.name, func(t *testing.T) {
if got := h.basicGet(t, tc.path, "hanzo-cloud", "s3cret"); got == 200 {
t.Errorf("GET %s as hanzo-cloud was ADMITTED (200); self-read must not widen", tc.path)
}
})
}
}
// A bad client secret is still not a principal at all.
func TestSelfRead_WrongSecretIsNotAPrincipal(t *testing.T) {
h := newHarness(t)
seedAppRow(t, h.db, "admin", "hanzo-cloud", "s3cret", signingKid)
if got := h.basicGet(t, "/v1/iam/get-application?id=admin%2Fhanzo-cloud", "hanzo-cloud", "wrong"); got == 200 {
t.Errorf("a wrong client secret read the application row")
}
}
// A 200 whose body says "the entity does not exist" is not a fix. Assert the row
// actually comes back — this is the failure the first probe caught.
func TestSelfRead_ReturnsTheRowNotAnEmptyOk(t *testing.T) {
h := newHarness(t)
seedAppRow(t, h.db, "admin", "hanzo-cloud", "s3cret", signingKid)
req := httptest.NewRequest("GET", "/v1/iam/get-application?id=admin%2Fhanzo-cloud", nil)
req.Host = "hanzo.id"
req.Header.Set("Authorization", "Basic "+
base64.StdEncoding.EncodeToString([]byte("hanzo-cloud:s3cret")))
resp, err := testhttp.Do(h.app, req)
if err != nil {
t.Fatalf("request: %v", err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
var env struct {
Status string `json:"status"`
Msg string `json:"msg"`
Data struct {
Name string `json:"name"`
Owner string `json:"owner"`
Cert string `json:"cert"`
} `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
if env.Status != "ok" {
t.Fatalf("self-read answered status=%q msg=%q — authorized but unable to read itself", env.Status, env.Msg)
}
if env.Data.Owner != "admin" || env.Data.Name != "hanzo-cloud" {
t.Errorf("got %s/%s, want admin/hanzo-cloud", env.Data.Owner, env.Data.Name)
}
if env.Data.Cert != signingKid {
t.Errorf("cert = %q, want %q — cloud reads this next", env.Data.Cert, signingKid)
}
}
+97
View File
@@ -0,0 +1,97 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz
import "testing"
// SELF-READ. An app may read the row it authenticated as — the ordinary bootstrap
// of an OIDC relying party — and nothing else. The owner-pin that closed the
// "every client credential is a global admin" escalation was missing this one case,
// so a confidential client could not read even itself and every cloud deploy 403'd.
//
// The grant is keyed on BOTH halves of (AppOwner, App), which is what keeps it a
// self-read rather than "apps may read applications".
func TestAuthorize_AppReadsOnlyItsOwnRecord(t *testing.T) {
cloud := &Principal{App: "hanzo-cloud", AppOwner: "admin", Org: "hanzo"}
for _, tc := range []struct {
name string
p *Principal
method, owner, target string
want bool
why string
}{
{"its own record", cloud, "GET", "admin", "hanzo-cloud", true,
"an app must be able to bootstrap from its own registration"},
// The two collision directions the owner-pin exists to separate. Both were
// 403 before this change and must STAY 403.
{"same name, tenant owner", cloud, "GET", "hanzo", "hanzo-cloud", false,
"a tenant-registered app of the same NAME is a different row"},
{"sibling in the same owner", cloud, "GET", "admin", "hanzo-console", false,
"reading a sibling would make this 'apps may read applications'"},
{"another tenant's app", cloud, "GET", "acme", "acme-thing", false,
"cross-tenant read"},
// Read only. A write to its own row would let a client widen its own
// redirect URIs or grant types — self-escalation.
{"write to its own record", cloud, "POST", "admin", "hanzo-cloud", false,
"self-read must never become self-write"},
{"delete its own record", cloud, "DELETE", "admin", "hanzo-cloud", false,
"self-read must never become self-delete"},
// The grant is scoped to the applications entity alone.
{"users under its own owner", cloud, "GET", "admin", "hanzo-cloud", false,
"the entity is users here, not applications"},
// An app with no owner pin holds nothing (the fail-closed default).
{"unpinned app", &Principal{App: "hanzo-cloud"}, "GET", "admin", "hanzo-cloud", false,
"an app whose AppOwner is empty matches no row"},
{"empty owner target", cloud, "GET", "", "hanzo-cloud", false,
"an empty owner must never match"},
} {
t.Run(tc.name, func(t *testing.T) {
entity := "applications"
if tc.name == "users under its own owner" {
entity = "users"
}
if got := authorize(tc.p, tc.method, entity, tc.owner, tc.target); got != tc.want {
t.Errorf("authorize(%s %s %s/%s) = %v, want %v — %s",
tc.method, entity, tc.owner, tc.target, got, tc.want, tc.why)
}
})
}
}
// A HUMAN is unaffected by the self-read clause: their authority is still decided
// by the org policy below it, so a tenant user cannot read a platform app row.
func TestAuthorize_SelfReadDoesNotLeakToHumans(t *testing.T) {
human := &Principal{Org: "hanzo", User: "alice", Admin: true}
if authorize(human, "GET", "applications", "admin", "hanzo-cloud") {
t.Errorf("an org admin read a platform-owned application row")
}
}
// ONE ENVELOPE PER SURFACE. The compat verbs are verb-shaped and their clients
// branch on a STRING status; the native surface is noun-shaped and keeps zip's
// numeric-status error.
func TestLegacyVerb_SelectsTheCompatSurfaceOnly(t *testing.T) {
for path, want := range map[string]bool{
"/v1/iam/get-account": true,
"/v1/iam/get-application": true,
"/v1/iam/add-organization": true,
"/v1/iam/update-user": true,
"/v1/iam/delete-membership": true,
"/v1/iam/users": false, // native REST noun
"/v1/iam/organizations": false,
"/v1/iam/oauth/token": false, // RFC 6749 shape
"/v1/iam/scim/v2/Users/x": false, // RFC 7644 shape
"/healthz": false,
"/v1/iam/": false,
"/v1/other/get-thing": false, // not the IAM surface
} {
if got := legacyVerb(path); got != want {
t.Errorf("legacyVerb(%q) = %v, want %v", path, got, want)
}
}
}
+157
View File
@@ -0,0 +1,157 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz_test
// Tenant isolation on the LIST routes, driven through the real registered router.
//
// The bug this pins was a confused deputy, and it was invisible from either half
// alone. The Guard authorizes on the query string — asking for a foreign org is
// correctly refused — and then the handler filtered on `in.Owner` instead of on
// the principal. A zip typed GET binds NOTHING from the request (a body is read
// only for non-GET), so `in.Owner` arrived EMPTY on every REST call, took the
// "empty owner lists everything" branch, and returned every tenant's rows.
//
// So the shape was: name someone else's org and get 403; name YOUR OWN org and
// get the whole table. A status-code assertion passes throughout — only the body
// shows it, which is why every case here reads the response.
//
// certs was the one lister that already resolved the owner via authz.Scope, and
// it is included as the control: if the others ever regress, certs still passes
// and the diff points straight at the cause.
import (
"context"
"strings"
"testing"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/orm"
)
func seedRole(t *testing.T, db orm.DB, owner, name string) {
t.Helper()
r := orm.New[schema.Role](db)
r.Owner, r.Name = owner, name
r.SetId(owner + "/" + name)
if err := r.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed role %s/%s: %v", owner, name, err)
}
}
func seedInvitation(t *testing.T, db orm.DB, owner, name string) {
t.Helper()
i := orm.New[schema.Invitation](db)
i.Owner, i.Name = owner, name
i.SetId(owner + "/" + name)
if err := i.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed invitation %s/%s: %v", owner, name, err)
}
}
func seedToken(t *testing.T, db orm.DB, owner, name string) {
t.Helper()
tk := orm.New[schema.Token](db)
tk.Owner, tk.Name = owner, name
tk.SetId(owner + "/" + name)
if err := tk.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed token %s/%s: %v", owner, name, err)
}
}
func seedWebauthn(t *testing.T, db orm.DB, owner, name string) {
t.Helper()
w := orm.New[schema.WebauthnCredential](db)
w.Owner, w.Name = owner, name
w.SetId(owner + "/" + name)
if err := w.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed webauthn %s/%s: %v", owner, name, err)
}
}
func seedAuditLog(t *testing.T, db orm.DB, owner, name string) {
t.Helper()
a := orm.New[schema.AuditLog](db)
a.Owner, a.Name = owner, name
a.Organization = owner
a.SetId(owner + "/" + name)
if err := a.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed auditlog %s/%s: %v", owner, name, err)
}
}
// TestListRoutesNeverLeakAnotherTenant is the regression. Each lister gets one
// row in the caller's org and one in a foreign org; an org admin listing its own
// org must see its own row and MUST NOT see the foreign one.
//
// The marker names are deliberately distinctive so a match cannot be incidental.
func TestListRoutesNeverLeakAnotherTenant(t *testing.T) {
h := newHarness(t)
seedRole(t, h.db, "hanzo", "role-mine-hanzo")
seedRole(t, h.db, "orgb", "role-secret-orgb")
seedInvitation(t, h.db, "hanzo", "invite-mine-hanzo")
seedInvitation(t, h.db, "orgb", "invite-secret-orgb")
seedAuditLog(t, h.db, "hanzo", "audit-mine-hanzo")
seedAuditLog(t, h.db, "orgb", "audit-secret-orgb")
seedCert(t, h.db, "orgb", "cert-secret-orgb", "")
seedToken(t, h.db, "hanzo", "token-mine-hanzo")
seedToken(t, h.db, "orgb", "token-secret-orgb")
seedWebauthn(t, h.db, "hanzo", "wa-mine-hanzo")
seedWebauthn(t, h.db, "orgb", "wa-secret-orgb")
boss := h.token(t, "hanzo/boss") // org admin of hanzo, and of nothing else
for _, c := range []struct {
route string
mine string
foreign string
}{
{"/v1/iam/roles?owner=hanzo", "role-mine-hanzo", "role-secret-orgb"},
{"/v1/iam/invitations?owner=hanzo", "invite-mine-hanzo", "invite-secret-orgb"},
{"/v1/iam/audit-logs?owner=hanzo", "audit-mine-hanzo", "audit-secret-orgb"},
{"/v1/iam/tokens?owner=hanzo", "token-mine-hanzo", "token-secret-orgb"},
{"/v1/iam/webauthn-credentials?owner=hanzo", "wa-mine-hanzo", "wa-secret-orgb"},
// organizations is the tenant registry — authz treats it as the ONE
// exception to the reserved-owner gate, and the route is SuperAdmin-only,
// so this case should refuse rather than list. Included so a future change
// that opens it to tenants shows up here rather than silently.
{"/v1/iam/organizations?owner=hanzo", "", "orgb"},
{"/v1/iam/certs?owner=hanzo", "", "cert-secret-orgb"}, // control: already scoped
} {
t.Run(c.route, func(t *testing.T) {
status, body := h.doBody(t, "GET", c.route, boss, nil)
if status != 200 {
t.Skipf("route answered %d, not a listing to check here", status)
}
if strings.Contains(body, c.foreign) {
t.Errorf("LEAK: hanzo/boss listing its OWN org received orgb's %q.\n"+
"The guard refuses ?owner=orgb, so the only way this row crosses the wire is a "+
"handler that ignored the principal and listed every tenant.\nbody: %s",
c.foreign, body)
}
if c.mine != "" && !strings.Contains(body, c.mine) {
t.Errorf("scoping is too tight: hanzo/boss cannot see its OWN row %q.\nbody: %s", c.mine, body)
}
})
}
}
// A foreign org must still be refused outright — the fix must not have moved the
// refusal from the guard into a silently-empty listing.
func TestListRoutesStillRefuseAForeignOrg(t *testing.T) {
h := newHarness(t)
boss := h.token(t, "hanzo/boss")
for _, route := range []string{
"/v1/iam/roles?owner=orgb",
"/v1/iam/invitations?owner=orgb",
"/v1/iam/audit-logs?owner=orgb",
"/v1/iam/certs?owner=orgb",
} {
status, body := h.doBody(t, "GET", route, boss, nil)
if status == 200 {
t.Errorf("%s returned 200 for a foreign org; want a refusal.\nbody: %s", route, body)
}
}
}
+513
View File
@@ -0,0 +1,513 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
// Package bootstrap serves the operator-driven service-account provisioning
// endpoints — `POST /v1/iam/admin/{applications,users}/upsert`. The Hanzo K8s
// operator (operator-core) reconciles an IAM CR's spec.applications[]/users[] here,
// binding the service-account OAuth apps that KMS/signers authenticate with, with NO
// human admin in the loop. It is idempotent (create OR update by the natural key)
// so a ~30s reconcile is a no-op once converged.
//
// Auth is a UNIFIED SERVICE TOKEN presented as `Authorization: Bearer <token>`,
// validated constant-time against the first non-empty of HANZO_API_KEY /
// KMS_SERVICE_TOKEN / IAM_SERVICE_TOKEN — the same pipeline the old iam used. The
// token is system-level (bypasses the org-membership gate), so these routes live in
// the PUBLIC group (before the Guard) and self-authenticate here. An unset token
// fails closed: no service token configured → no bootstrap.
//
// Both are TYPED ops, so the credential is DECLARED — `header:"Authorization"` on
// the input — rather than read out of a request the op cannot see. That is what
// makes them ops at all: a fact no projection can read is not a fact the API has,
// and the document, the tool schema and the command now all name the header the
// call needs. It carries `json:"-"`, so the body and the query string cannot
// supply it; a transport with no headers (MCP, the call plane) presents nothing
// and is refused, which is the same fail-closed answer an unset token gets.
package bootstrap
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/hanzoai/iam/internal/cred"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
)
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
// Route registers the bootstrap upsert endpoints on the PUBLIC group r (they
// self-authenticate via the service token, not a bearer principal).
//
// r is the CONCRETE *zip.App a group already is: zipdoc resolves an op's path
// prefix STATICALLY and cannot see through a zip.Router parameter, so a typed op
// registered on one would have its doc comment filed under the wrong path and
// dropped from both the document and the MCP tool. The prefix is empty either
// way; nothing about the mount changes.
//
// Every status each op can answer is DECLARED, because zip refuses one that is
// not — and because the document publishes exactly this set, so a generated
// client has a branch for each. These two answer their refusals in their own
// envelope (see reply), which is what a declared non-2xx is for.
func Route(r *zip.App, db orm.DB) {
zip.Post[registration, reply](r, "/v1/iam/admin/applications/upsert", upsertApplication(db),
zip.WithOperationID("upsertApplication"),
zip.WithStatus(200, 400, 401, 500),
zip.WithTags("bootstrap"))
zip.Post[person, reply](r, "/v1/iam/admin/users/upsert", upsertUser(db),
zip.WithOperationID("upsertUser"),
zip.WithStatus(200, 400, 401, 500),
zip.WithTags("bootstrap"))
}
// reply is what both upserts answer, and the STATUS it rides on — this surface's
// envelope as a VALUE, because a typed op returns its answer instead of writing
// one. It is NOT httpx.Answer: these two predate that envelope and say `action`
// (created or updated) where it says `code`, and carry no `data` at all on a
// refusal. The operator parses this shape, so it is the shape that stays.
//
// The fields are in alphabetical order deliberately. Each of these bodies used to
// be a map[string]any, encoding/json sorts a map's keys, and the wire may not move
// under an operator that is already parsing it — so the struct emits the same
// bytes in the same order.
type reply struct {
Action string `json:"action,omitempty"`
Data any `json:"data,omitempty"`
Msg string `json:"msg,omitempty"`
Status string `json:"status"`
code int
}
// StatusCode is [zip.StatusCoder]: the status this answer rides on. Zero means
// the answer never named one, and 200 is what an unnamed answer has always been.
func (r *reply) StatusCode() int {
if r.code == 0 {
return 200
}
return r.code
}
// done is the 200 {status:"ok", action, data} answer — created or updated, and
// what the upsert left behind.
func done(action string, data any) *reply {
return &reply{Action: action, Data: data, Status: "ok", code: 200}
}
// refuse is the {status:"error", msg} answer under the status that matches it.
// ONE function writes a refusal here; every one below names its status.
//
// It returns a VALUE rather than an error, and that is the whole contract: a
// non-nil error renders zip's own {status,error} envelope, which is not what this
// surface has ever answered.
func refuse(status int, msg string) *reply {
return &reply{Msg: msg, Status: "error", code: status}
}
// credential is what an application upsert answers with: the registration as it
// now stands, including the client secret — the operator is the caller, and this
// is where it learns a secret it did not send. Alphabetical, per reply.
type credential struct {
ClientId string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
Name string `json:"name"`
Organization string `json:"organization"`
}
// account is what a user upsert answers with: the natural key of the row it
// created or updated — the name as STORED, which the username rule may have
// rewritten. Alphabetical, per reply.
type account struct {
Name string `json:"name"`
Owner string `json:"owner"`
}
// decoded is what happened when the request body was read, carried on the input
// because the handler is the only thing that may answer for it.
//
// zip renders a decode failure as its own {status,error} envelope and skips the
// decoder entirely when there is no body — so an op that does neither has to learn
// both facts itself. Unexported, so it is on no wire and in no schema.
type decoded struct {
sent bool
err error
}
// check is the refusal a body earns before a handler looks at it, or nil when it
// arrived and parsed. The two sentences are the ones this surface has always
// answered.
func (d decoded) check() *reply {
switch {
case !d.sent:
return refuse(400, "invalid body: empty request body")
case d.err != nil:
return refuse(400, "invalid body: "+d.err.Error())
}
return nil
}
// registration is the application an operator declares (operator-core's
// UpsertRequest), plus the service credential it presents.
type registration struct {
Organization string `json:"organization"`
Name string `json:"name"`
ClientId string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
GrantTypes []string `json:"grantTypes"`
RedirectUris []string `json:"redirectUris"`
DisplayName string `json:"displayName"`
Cert string `json:"cert"`
// Public declares a client that CANNOT hold a credential — a browser SPA,
// a CLI, a desktop app. It proves itself with PKCE instead, and the token
// endpoint treats "no stored secret" as exactly that (token.go: a secret is
// verified only when one is stored). Without this flag every upsert minted
// a secret, so a public client could never be registered at all and its
// browser code->token exchange 401'd `invalid_client` forever.
Public bool `json:"public"`
// IsShared declares that this application serves EVERY organization, not only
// the one named in Organization. It is the honest description of a brand app —
// hanzo-id, hanzo-chat, a brand console — whose customers each live in their own
// tenant: self-service onboarding moves a founder OUT of the brand org, so
// `user.Owner != app.Organization` is the steady state and the app really does
// serve every org. Application.ServesOrg reads it as one of the three ways to
// say yes.
//
// A POINTER because omission must PRESERVE. This upsert is the operator's
// steady-state reconcile and most callers say nothing about sharing; a plain
// bool would read as false on every one of them and silently un-share an app —
// the same shape of accident that de-secreted apps through update-application.
// Nil means "not stated, leave it"; only an explicit true or false moves it.
IsShared *bool `json:"isShared"`
// ExpireInHours and RefreshExpireInHours are the application's token
// lifetimes. They are the ONLY declarative way to say that a refresh token
// must OUTLIVE its access token: with neither stated, oidc.refreshTTL clamps
// the refresh lifetime to the access lifetime, so the refresh_token grant the
// registration advertises expires at the same instant as the token it was
// meant to renew and can never be exercised. `hanzo-cli` sat in exactly that
// state — a browser re-login every hour, and a live refresh returning 401.
//
// POINTERS, for the same reason as IsShared: a plain float would read as 0 on
// every reconcile that says nothing and reset a deliberate lifetime back to
// the default. Nil means "not stated, leave it".
ExpireInHours *float64 `json:"expireInHours"`
RefreshExpireInHours *float64 `json:"refreshExpireInHours"`
// Auth is the `Authorization: Bearer <token>` header, the unified service
// token this surface authenticates on. `json:"-"` keeps it off the body and
// out of the query string, so the header is the only way to present it.
Auth string `json:"-" header:"Authorization"`
decoded
}
// UnmarshalJSON decodes the body and RECORDS the outcome instead of failing on it,
// so the handler stays the only thing that answers — see decoded.
//
// `body` is the same fields with none of the methods, which is what keeps this
// from calling itself. It is also what a mismatched field is reported against, so
// the message names the body rather than a Go type the caller has never heard of.
func (r *registration) UnmarshalJSON(b []byte) error {
type body registration
var v body
err := json.Unmarshal(b, &v)
*r = registration(v)
r.decoded = decoded{sent: true, err: err}
return nil
}
// upsertApplication creates an application or updates it in place, so a
// deployment can declare the applications it needs and run the same declaration
// on every environment and on every redeploy.
//
// It says which of the two it did. Leave the client secret out and the existing
// one is kept — so re-running your deployment does not rotate a credential your
// running services are holding.
func upsertApplication(db orm.DB) zip.TypedHandler[registration, reply] {
return func(ctx context.Context, in *registration) (*reply, error) {
if !httpx.ServiceAuth(in.Auth) {
return refuse(401, "a valid service token is required"), nil
}
if bad := in.check(); bad != nil {
return bad, nil
}
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
return refuse(400, "name is required"), nil
}
existing, err := store.GetApplicationByName(ctx, db, "admin", in.Name)
if err != nil {
return refuse(500, "server_error"), nil
}
var existingSecret string
if existing != nil {
existingSecret = existing.ClientSecret
}
in.ClientSecret = resolveSecret(in.Public, in.ClientSecret, existing != nil, existingSecret)
if in.ClientId == "" {
in.ClientId = in.Name // <org>-<app> convention: clientId == name
}
action := "created"
if existing != nil {
action = "updated"
existing.ClientId = in.ClientId
existing.ClientSecret = in.ClientSecret
existing.Organization = pick(in.Organization, existing.Organization)
if in.DisplayName != "" {
existing.DisplayName = in.DisplayName
}
if len(in.GrantTypes) > 0 {
existing.GrantTypes = in.GrantTypes
}
if len(in.RedirectUris) > 0 {
existing.RedirectUris = in.RedirectUris
}
if in.Cert != "" {
existing.Cert = in.Cert
}
if in.IsShared != nil {
existing.IsShared = *in.IsShared
}
existing.ExpireInHours = ttl(in.ExpireInHours, existing.ExpireInHours)
existing.RefreshExpireInHours = ttl(in.RefreshExpireInHours, existing.RefreshExpireInHours)
existing.EnablePassword = true
if err := existing.UpdateCtx(ctx); err != nil {
return refuse(500, "server_error"), nil
}
} else {
// A new application must NAME a signing cert, or it is not a
// registration — it is a login that fails after the user has already
// authenticated. Resolved here, where "brand new" is known, rather
// than left to be discovered at the token endpoint.
//
// The cert ROW is deliberately not required to exist yet: an app that
// records `cert-hanzo` signs correctly the moment that cert does,
// whereas demanding it up front would order application creation
// behind cert seeding and break a first-boot reconcile that has not
// reached the certs. The name is the durable fact; its resolution is
// the token endpoint's job.
if in.Cert = resolveCert(in.Cert, in.Organization); in.Cert == "" {
return refuse(400, fmt.Sprintf(
"application %q would have no signing cert and no organization to "+
"derive one from, so it could never issue a token: state `cert`",
in.Name)), nil
}
a := orm.New[schema.Application](db)
model := a.Model
a.Owner, a.Name = "admin", in.Name
a.ClientId, a.ClientSecret = in.ClientId, in.ClientSecret
a.Organization, a.DisplayName = in.Organization, pick(in.DisplayName, in.Name)
a.GrantTypes, a.RedirectUris, a.Cert = in.GrantTypes, in.RedirectUris, in.Cert
a.EnablePassword = true
a.ExpireInHours = ttl(in.ExpireInHours, schema.DefaultExpireInHours)
a.RefreshExpireInHours = ttl(in.RefreshExpireInHours, 0)
// A new app is single-tenant unless it says otherwise — fail closed.
a.IsShared = in.IsShared != nil && *in.IsShared
a.Model = model
a.SetId("admin/" + in.Name)
if err := a.CreateCtx(ctx); err != nil {
return refuse(500, "server_error"), nil
}
}
return done(action, &credential{
ClientId: in.ClientId, ClientSecret: in.ClientSecret,
Name: in.Name, Organization: in.Organization,
}), nil
}
}
// person is the user an operator declares, plus the service credential it
// presents.
type person struct {
Owner string `json:"owner"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Phone string `json:"phone"`
Password string `json:"password"`
PasswordType string `json:"passwordType"`
IsAdmin bool `json:"isAdmin"`
// Auth is the `Authorization: Bearer <token>` header — see registration.Auth.
Auth string `json:"-" header:"Authorization"`
decoded
}
// UnmarshalJSON decodes the body and RECORDS the outcome — see registration's.
func (p *person) UnmarshalJSON(b []byte) error {
type body person
var v body
err := json.Unmarshal(b, &v)
*p = person(v)
p.decoded = decoded{sent: true, err: err}
return nil
}
// upsertUser creates a person or updates them in place, so a deployment can
// declare the accounts it needs and re-run that declaration safely.
//
// Passwords are hashed before they are stored. Leave the password out and their
// current one is kept, so a redeploy never locks somebody out.
func upsertUser(db orm.DB) zip.TypedHandler[person, reply] {
return func(ctx context.Context, in *person) (*reply, error) {
if !httpx.ServiceAuth(in.Auth) {
return refuse(401, "a valid service token is required"), nil
}
if bad := in.check(); bad != nil {
return bad, nil
}
in.Owner, in.Name = strings.TrimSpace(in.Owner), strings.TrimSpace(in.Name)
if in.Owner == "" || in.Name == "" {
return refuse(400, "owner and name are required"), nil
}
var hash string
if in.Password != "" {
h, err := cred.Hash(in.Password)
if err != nil {
return refuse(500, "server_error"), nil
}
hash = h
}
existing, err := store.GetUserByName(ctx, db, in.Owner, in.Name)
if err != nil {
return refuse(500, "server_error"), nil
}
action := "created"
if existing != nil {
action = "updated"
existing.DisplayName = pick(in.DisplayName, existing.DisplayName)
existing.Email = pick(in.Email, existing.Email)
existing.Phone = pick(in.Phone, existing.Phone)
existing.IsAdmin = in.IsAdmin
if hash != "" {
existing.PasswordHash, existing.PasswordType, existing.PasswordSalt = hash, cred.TypeArgon2id, ""
}
existing.UpdatedTime = now()
if err := existing.UpdateCtx(ctx); err != nil {
return refuse(500, "server_error"), nil
}
} else {
// A new row obeys THE username rule; an existing one is found above and
// merely updated, so a legacy name is never rewritten by an upsert that
// happened to touch it. This path writes through orm directly rather than
// users.Create (it seeds the first admin, before any principal exists), so
// it states the rule itself — the one place that has to.
name, err := schema.Username(in.Name)
if err != nil {
return refuse(400, err.Error()), nil
}
in.Name = name // the id and the response report what was STORED
u := orm.New[schema.User](db)
model := u.Model
u.Owner, u.Name = in.Owner, name
u.DisplayName, u.Email, u.Phone, u.IsAdmin = in.DisplayName, in.Email, in.Phone, in.IsAdmin
if hash != "" {
u.PasswordHash, u.PasswordType = hash, cred.TypeArgon2id
}
u.CreatedTime, u.UpdatedTime = now(), now()
u.Model = model
u.SetId(in.Owner + "/" + in.Name)
if err := u.CreateCtx(ctx); err != nil {
return refuse(500, "server_error"), nil
}
}
return done(action, &account{Name: in.Name, Owner: in.Owner}), nil
}
}
// ---- helpers ----
// resolveSecret decides the credential an upsert stores. It is the ONE place
// public-vs-confidential is settled, split out so the rule is testable without
// a store:
//
// - public -> NO secret. That absence is exactly what the token endpoint
// reads as "PKCE, do not demand client auth", so it must also
// CLEAR one left by an earlier confidential registration.
// - explicit -> honour it; rotation is deliberate.
// - existing app -> preserve what it has, INCLUDING an empty secret. Minting
// one because "the stored secret is empty" would silently
// turn a public client confidential on the next reconcile.
// - brand new -> mint one.
func resolveSecret(public bool, requested string, hasExisting bool, existing string) string {
switch {
case public:
return ""
case requested != "":
return requested
case hasExisting:
return existing
default:
return randomSecret()
}
}
// resolveCert decides the signing cert a NEW application is created with. Same
// shape as resolveSecret, and split out for the same reason: it is the ONE place
// the rule lives.
//
// It is not cosmetic, and it fails LATE if it is wrong. issueTokens resolves
// app.Cert to sign, so an application created without one authenticates the user,
// mints an authorization code, redeems it — and only then discovers it has
// nothing to sign with, answering the token exchange `500 server_error`. From the
// browser that is indistinguishable from an outage, and it is exactly the state
// `hanzo-tabs` shipped in.
//
// - requested -> honour it.
// - otherwise -> the organization's own cert. Every application here already
// follows one signing identity per org (`cert-hanzo`, `cert-lux`,
// `cert-adnexus`…), so the default is that convention, not an invention.
//
// The caller VERIFIES the result resolves to a real cert and refuses the
// registration otherwise. Only the create path consults this: on an existing
// application a blank request means "not stated", never "clear it", which is what
// lets a document add the field without rotating anything.
func resolveCert(requested, org string) string {
if r := strings.TrimSpace(requested); r != "" {
return r
}
if org = strings.TrimSpace(org); org != "" {
return "cert-" + org
}
return ""
}
// ttl applies an optionally-declared token lifetime: nil PRESERVES cur (an
// omitted field never resets a deliberate lifetime on a steady-state reconcile),
// a stated value wins — including an explicit 0, which is how a document says
// "back to the default".
func ttl(declared *float64, cur float64) float64 {
if declared == nil {
return cur
}
return *declared
}
// pick returns a if non-empty (trimmed), else b.
func pick(a, b string) string {
if strings.TrimSpace(a) != "" {
return a
}
return b
}
// randomSecret returns a 32-byte URL-safe random client secret.
func randomSecret() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func now() string { return time.Now().UTC().Format(time.RFC3339) }
+124
View File
@@ -0,0 +1,124 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package bootstrap_test
import (
"context"
"encoding/json"
"io"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/hanzoai/orm"
ormdb "github.com/hanzoai/orm/db"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/routes"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/testhttp"
)
const svcToken = "svc-token-secret-value"
func boot(t *testing.T) (*zip.App, orm.DB) {
t.Helper()
t.Setenv("IAM_SERVICE_TOKEN", svcToken)
_ = schema.Kinds()
dir := t.TempDir()
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(dir, "boot.db"),
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
app := zip.New(zip.Config{AppName: "bootstrap-test", DisableStartupMessage: true})
routes.Route(app, db)
if err := app.Build(); err != nil {
t.Fatalf("build: %v", err)
}
return app, db
}
func post(t *testing.T, app *zip.App, path, token, body string) (int, map[string]any) {
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(body))
req.Host = "hanzo.id"
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := testhttp.Do(app, req)
if err != nil {
t.Fatalf("POST %s: %v", path, err)
}
b, _ := io.ReadAll(resp.Body)
var m map[string]any
_ = json.Unmarshal(b, &m)
return resp.StatusCode, m
}
func TestUpsertApplication_createThenIdempotentUpdate(t *testing.T) {
app, db := boot(t)
body := `{"organization":"hanzo","name":"hanzo-kms","clientId":"hanzo-kms","grantTypes":["client_credentials"]}`
// Create — a secret is generated, action=created.
st, m := post(t, app, "/v1/iam/admin/applications/upsert", svcToken, body)
if st != 200 || m["status"] != "ok" || m["action"] != "created" {
t.Fatalf("create: status=%d body=%v", st, m)
}
data, _ := m["data"].(map[string]any)
secret, _ := data["clientSecret"].(string)
if secret == "" {
t.Fatalf("no clientSecret generated: %v", data)
}
if a, _ := store.GetApplicationByName(context.Background(), db, "admin", "hanzo-kms"); a == nil {
t.Fatalf("app not persisted")
}
// Re-upsert with NO secret — idempotent: action=updated, the SAME secret is
// preserved (no rotation storm on a steady-state reconcile).
st2, m2 := post(t, app, "/v1/iam/admin/applications/upsert", svcToken, body)
if st2 != 200 || m2["action"] != "updated" {
t.Fatalf("re-upsert: status=%d body=%v", st2, m2)
}
data2, _ := m2["data"].(map[string]any)
if data2["clientSecret"] != secret {
t.Fatalf("clientSecret rotated on idempotent re-upsert: %v → %v", secret, data2["clientSecret"])
}
}
func TestUpsertUser_createHashesPassword(t *testing.T) {
app, db := boot(t)
body := `{"owner":"hanzo","name":"svc-signer","password":"s3cret","isAdmin":false}`
st, m := post(t, app, "/v1/iam/admin/users/upsert", svcToken, body)
if st != 200 || m["action"] != "created" {
t.Fatalf("create user: status=%d body=%v", st, m)
}
u, _ := store.GetUserByName(context.Background(), db, "hanzo", "svc-signer")
if u == nil || u.PasswordHash == "" || u.PasswordHash == "s3cret" {
t.Fatalf("password not hashed: %+v", u)
}
if u.PasswordType != "argon2id" {
t.Fatalf("passwordType = %q, want argon2id", u.PasswordType)
}
}
func TestBootstrap_requiresServiceToken(t *testing.T) {
app, _ := boot(t)
body := `{"name":"x"}`
// No token → 401.
if st, _ := post(t, app, "/v1/iam/admin/applications/upsert", "", body); st != 401 {
t.Fatalf("no-token status = %d, want 401", st)
}
// Wrong token → 401.
if st, _ := post(t, app, "/v1/iam/admin/applications/upsert", "wrong-token", body); st != 401 {
t.Fatalf("wrong-token status = %d, want 401", st)
}
}
+40
View File
@@ -0,0 +1,40 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package bootstrap
import "testing"
// A new application must be created able to SIGN. issueTokens resolves app.Cert,
// so a registration without one authenticates the user, mints a code, redeems it,
// and only then answers `500 server_error` — a login that fails after the user has
// already done everything right, and looks from the browser like an outage.
//
// `hanzo-tabs` shipped in exactly that state: registered by an upsert that never
// mentioned a cert, and every sign-in died at the token exchange.
func TestResolveCert_ANewApplicationCanAlwaysSign(t *testing.T) {
for _, tc := range []struct {
name string
requested string
org string
want string
}{
{name: "explicit wins", requested: "cert-special", org: "hanzo", want: "cert-special"},
{name: "explicit wins with no org", requested: "cert-special", want: "cert-special"},
{name: "derived from the organization", org: "hanzo", want: "cert-hanzo"},
{name: "derived for any brand", org: "lux", want: "cert-lux"},
{name: "blank is not a cert", requested: " ", org: "zoo", want: "cert-zoo"},
} {
t.Run(tc.name, func(t *testing.T) {
if got := resolveCert(tc.requested, tc.org); got != tc.want {
t.Errorf("resolveCert(%q, %q) = %q, want %q", tc.requested, tc.org, got, tc.want)
}
})
}
// Nothing to derive from. The caller must REFUSE rather than create a client
// that can never mint a token — an empty result is what triggers that, so it
// has to stay empty rather than become a plausible-looking guess.
if got := resolveCert("", ""); got != "" {
t.Errorf("resolveCert with nothing to go on = %q, want empty so the caller refuses", got)
}
}
+96
View File
@@ -0,0 +1,96 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package bootstrap_test
import (
"context"
"testing"
"github.com/hanzoai/iam/pkg/store"
)
// isShared is the honest declaration that an application serves EVERY organization,
// and the upsert is the ONE door that can set it without collateral damage:
// update-application is a full REPLACE over a read that MASKS the client secret, so
// the natural read-modify-write de-secrets the app. This endpoint merges field by
// field and preserves the credential, which is why the brand-app flags are set here.
//
// The semantics that make it safe to call on a live fleet: an omitted isShared
// PRESERVES whatever is stored. Most operator reconciles say nothing about sharing,
// and a plain bool would read as false on every one of them and silently un-share
// the apps — turning the steady-state reconcile into a recurring outage for every
// self-service customer.
func TestUpsertApplication_isSharedOmittedPreserves(t *testing.T) {
app, db := boot(t)
ctx := context.Background()
const path = "/v1/iam/admin/applications/upsert"
base := `{"organization":"hanzo","name":"hanzo-id","clientId":"hanzo-id"`
// Created without the field: single-tenant, fail closed.
if st, m := post(t, app, path, svcToken, base+`}`); st != 200 || m["action"] != "created" {
t.Fatalf("create: status=%d body=%v", st, m)
}
a, _ := store.GetApplicationByName(ctx, db, "admin", "hanzo-id")
if a == nil || a.IsShared {
t.Fatalf("a new app must default to single-tenant, got isShared=%v", a.IsShared)
}
// Declared shared.
if st, _ := post(t, app, path, svcToken, base+`,"isShared":true}`); st != 200 {
t.Fatalf("set isShared: status=%d", st)
}
a, _ = store.GetApplicationByName(ctx, db, "admin", "hanzo-id")
if !a.IsShared {
t.Fatalf("isShared:true did not persist")
}
// The steady-state reconcile: the field is omitted, and must NOT un-share.
if st, _ := post(t, app, path, svcToken, base+`}`); st != 200 {
t.Fatalf("reconcile: status=%d", st)
}
a, _ = store.GetApplicationByName(ctx, db, "admin", "hanzo-id")
if !a.IsShared {
t.Fatalf("an omitted isShared UN-SHARED the app; every operator reconcile would " +
"lock out every self-service customer of this brand")
}
// Un-sharing stays possible, it just has to be DELIBERATE.
if st, _ := post(t, app, path, svcToken, base+`,"isShared":false}`); st != 200 {
t.Fatalf("clear isShared: status=%d", st)
}
a, _ = store.GetApplicationByName(ctx, db, "admin", "hanzo-id")
if a.IsShared {
t.Fatalf("an explicit isShared:false must un-share")
}
}
// The reason this door was chosen over update-application: it does not touch the
// credential. Pinned together with the flag so a future refactor cannot reintroduce
// the de-secret trap on the one path the fleet is configured through.
func TestUpsertApplication_isSharedDoesNotDisturbTheSecret(t *testing.T) {
app, db := boot(t)
ctx := context.Background()
const path = "/v1/iam/admin/applications/upsert"
base := `{"organization":"hanzo","name":"hanzo-chat","clientId":"hanzo-chat"`
if st, _ := post(t, app, path, svcToken, base+`}`); st != 200 {
t.Fatalf("create failed")
}
before, _ := store.GetApplicationByName(ctx, db, "admin", "hanzo-chat")
if before.ClientSecret == "" {
t.Fatalf("expected a generated secret to protect")
}
if st, _ := post(t, app, path, svcToken, base+`,"isShared":true}`); st != 200 {
t.Fatalf("set isShared failed")
}
after, _ := store.GetApplicationByName(ctx, db, "admin", "hanzo-chat")
if after.ClientSecret != before.ClientSecret {
t.Fatalf("flipping isShared changed the client secret %q → %q; a confidential "+
"client would have been silently turned public", before.ClientSecret, after.ClientSecret)
}
if !after.IsShared {
t.Fatalf("isShared did not persist")
}
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package bootstrap_test
import (
"context"
"testing"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
)
// Token lifetimes are DECLARABLE through the upsert, and an omitted lifetime
// PRESERVES what the app has. Without the first half there is no declarative way
// to say a refresh token must outlive its access token, and oidc.refreshTTL
// clamps the refresh lifetime to the access lifetime — the registration
// advertises a refresh_token grant that can never be exchanged, which is where
// hanzo-cli's hourly browser re-login came from. Without the second half every
// steady-state converge would reset the lifetime it just set.
func TestUpsertApplication_tokenLifetimes(t *testing.T) {
app, db := boot(t)
const path = "/v1/iam/admin/applications/upsert"
get := func() *schema.Application {
t.Helper()
a, err := store.GetApplicationByName(context.Background(), db, "admin", "hanzo-cli")
if err != nil || a == nil {
t.Fatalf("load hanzo-cli: %v", err)
}
return a
}
// Create with a declared refresh lifetime; the access lifetime is unstated
// and must fall back to the ONE default, not to zero.
if st, m := post(t, app, path, svcToken,
`{"organization":"hanzo","name":"hanzo-cli","refreshExpireInHours":720}`); st != 200 || m["action"] != "created" {
t.Fatalf("create: status=%d body=%v", st, m)
}
a := get()
if a.RefreshExpireInHours != 720 {
t.Fatalf("refreshExpireInHours = %v, want 720", a.RefreshExpireInHours)
}
if a.ExpireInHours != schema.DefaultExpireInHours {
t.Fatalf("expireInHours = %v, want the default %v", a.ExpireInHours, schema.DefaultExpireInHours)
}
if a.RefreshExpireInHours <= a.ExpireInHours {
t.Fatal("the refresh lifetime must outlive the access lifetime")
}
// A converge that says nothing about lifetimes preserves both.
if st, _ := post(t, app, path, svcToken, `{"organization":"hanzo","name":"hanzo-cli"}`); st != 200 {
t.Fatalf("re-upsert failed: %d", st)
}
if a := get(); a.RefreshExpireInHours != 720 || a.ExpireInHours != schema.DefaultExpireInHours {
t.Fatalf("an omitted lifetime was not preserved: expire=%v refresh=%v", a.ExpireInHours, a.RefreshExpireInHours)
}
// A stated lifetime moves it — including an explicit 0, the way a document
// says "back to the default".
if st, _ := post(t, app, path, svcToken,
`{"organization":"hanzo","name":"hanzo-cli","expireInHours":8,"refreshExpireInHours":0}`); st != 200 {
t.Fatalf("update failed: %d", st)
}
if a := get(); a.ExpireInHours != 8 || a.RefreshExpireInHours != 0 {
t.Fatalf("stated lifetimes not applied: expire=%v refresh=%v", a.ExpireInHours, a.RefreshExpireInHours)
}
}
+42
View File
@@ -0,0 +1,42 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package bootstrap
import "testing"
// A public client must STAY public across reconciles. An upsert that omits
// `public` (an operator reconcile, a probe, any caller that only sets a name)
// must not read "no stored secret" as "mint one" — that silently converts the
// client back to confidential and every browser login starts failing
// `invalid_client` with nothing in the provision document changed to explain it.
func TestUpsertApplication_PublicStaysPublicAcrossReconciles(t *testing.T) {
for _, tc := range []struct {
name string
public bool
reqSecret string
existingSecret string
hasExisting bool
wantSecretEmpty bool
wantSecretEquals string
}{
{name: "public clears an inherited secret", public: true, existingSecret: "old", hasExisting: true, wantSecretEmpty: true},
{name: "omitting public preserves empty", hasExisting: true, existingSecret: "", wantSecretEmpty: true},
{name: "omitting public preserves a secret", hasExisting: true, existingSecret: "keepme", wantSecretEquals: "keepme"},
{name: "explicit secret wins", reqSecret: "rotated", hasExisting: true, existingSecret: "old", wantSecretEquals: "rotated"},
} {
t.Run(tc.name, func(t *testing.T) {
got := resolveSecret(tc.public, tc.reqSecret, tc.hasExisting, tc.existingSecret)
switch {
case tc.wantSecretEmpty && got != "":
t.Errorf("secret = %q, want empty", got)
case tc.wantSecretEquals != "" && got != tc.wantSecretEquals:
t.Errorf("secret = %q, want %q", got, tc.wantSecretEquals)
}
})
}
// A brand-new confidential client still gets one.
if resolveSecret(false, "", false, "") == "" {
t.Error("a new confidential client must be minted a secret")
}
}
+164
View File
@@ -0,0 +1,164 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package bootstrap_test
import (
"io"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"github.com/hanzoai/orm"
ormdb "github.com/hanzoai/orm/db"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/bootstrap"
"github.com/hanzoai/iam/internal/testhttp"
"github.com/hanzoai/iam/pkg/schema"
)
// The operator parses these bodies BY HAND, so the BYTES are the contract: the
// status, the key order, and the presence or absence of every key. Each body was
// a map[string]any once, encoding/json sorts a map's keys, and the structs that
// replaced the maps emit the same bytes in the same order. This pins that — a
// field reordered, an omitempty dropped, or a refusal that starts rendering zip's
// own {status,error} envelope all fail here.
//
// It drives bootstrap.Route on its own app rather than the whole route table:
// this is the surface under test, and bootstrap_test.go already proves the two
// addresses are mounted in the table.
// wire is one app serving only the bootstrap surface, over its own store.
func wire(t *testing.T) *zip.App {
t.Helper()
t.Setenv("IAM_SERVICE_TOKEN", svcToken)
_ = schema.Kinds()
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(t.TempDir(), "wire.db"),
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
app := zip.New(zip.Config{AppName: "bootstrap-wire", DisableStartupMessage: true})
bootstrap.Route(app, db)
if err := app.Build(); err != nil {
t.Fatalf("build: %v", err)
}
return app
}
// raw is the answer as it reaches the wire: the status and the exact bytes.
func raw(t *testing.T, app *zip.App, path, auth, body string) (int, string) {
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(body))
req.Host = "hanzo.id"
req.Header.Set("Content-Type", "application/json")
if auth != "" {
req.Header.Set("Authorization", auth)
}
resp, err := testhttp.Do(app, req)
if err != nil {
t.Fatalf("POST %s: %v", path, err)
}
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
func TestWire(t *testing.T) {
const (
apps = "/v1/iam/admin/applications/upsert"
users = "/v1/iam/admin/users/upsert"
nope = `{"msg":"a valid service token is required","status":"error"}`
)
bearer := "Bearer " + svcToken
app := wire(t)
// Ordered: the created/updated pairs are two requests against one store.
for _, tc := range []struct {
name string
path string
auth string
body string
status int
want string
}{
// The service token, and the ONLY way to present it. A body field and a
// query param are both refused, which is what `json:"-"` on the declared
// header buys: a credential that cannot arrive anywhere it would be logged.
{"app: no token", apps, "", `{"name":"x"}`, 401, nope},
{"app: wrong token", apps, "Bearer nope", `{"name":"x"}`, 401, nope},
{"app: not a bearer", apps, svcToken, `{"name":"x"}`, 401, nope},
{"app: token in the body", apps, "", `{"name":"x","Auth":"` + bearer + `"}`, 401, nope},
{"app: token in the query", apps + "?Auth=" + url.QueryEscape(bearer), "", `{"name":"x"}`, 401, nope},
{"app: header named in the query", apps + "?Authorization=" + url.QueryEscape(bearer), "", `{"name":"x"}`, 401, nope},
{"user: no token", users, "", `{"owner":"hanzo","name":"z"}`, 401, nope},
// The body, before a handler looks at it.
{"app: no body", apps, bearer, ``, 400,
`{"msg":"invalid body: empty request body","status":"error"}`},
{"user: no body", users, bearer, ``, 400,
`{"msg":"invalid body: empty request body","status":"error"}`},
{"app: null body", apps, bearer, `null`, 400,
`{"msg":"name is required","status":"error"}`},
// What each upsert insists on.
{"app: no name", apps, bearer, `{}`, 400,
`{"msg":"name is required","status":"error"}`},
{"app: blank name", apps, bearer, `{"name":" "}`, 400,
`{"msg":"name is required","status":"error"}`},
{"app: nothing to sign with", apps, bearer, `{"name":"x"}`, 400,
`{"msg":"application \"x\" would have no signing cert and no organization to derive one ` +
"from, so it could never issue a token: state `cert`" + `","status":"error"}`},
{"user: no owner", users, bearer, `{"name":"z"}`, 400,
`{"msg":"owner and name are required","status":"error"}`},
{"user: no name", users, bearer, `{"owner":"hanzo"}`, 400,
`{"msg":"owner and name are required","status":"error"}`},
{"user: unusable name", users, bearer, `{"owner":"hanzo","name":"Not A Name"}`, 400,
`{"msg":"username \"Not A Name\" is not usable: use 1-63 characters of a-z, 0-9, dot, ` +
`underscore or hyphen, starting with a letter or digit","status":"error"}`},
// What each upsert answers when it works. The secret is stated, so the
// whole body is deterministic.
{"app: created", apps, bearer,
`{"organization":"hanzo","name":"hanzo-kms","clientId":"hanzo-kms","clientSecret":"s3cret"}`, 200,
`{"action":"created","data":{"clientId":"hanzo-kms","clientSecret":"s3cret",` +
`"name":"hanzo-kms","organization":"hanzo"},"status":"ok"}`},
{"app: updated", apps, bearer,
`{"organization":"hanzo","name":"hanzo-kms","clientId":"hanzo-kms","clientSecret":"s3cret"}`, 200,
`{"action":"updated","data":{"clientId":"hanzo-kms","clientSecret":"s3cret",` +
`"name":"hanzo-kms","organization":"hanzo"},"status":"ok"}`},
{"user: created", users, bearer, `{"owner":"hanzo","name":"svc-signer"}`, 200,
`{"action":"created","data":{"name":"svc-signer","owner":"hanzo"},"status":"ok"}`},
{"user: updated", users, bearer, `{"owner":"hanzo","name":"svc-signer"}`, 200,
`{"action":"updated","data":{"name":"svc-signer","owner":"hanzo"},"status":"ok"}`},
} {
t.Run(tc.name, func(t *testing.T) {
st, got := raw(t, app, tc.path, tc.auth, tc.body)
if st != tc.status || got != tc.want {
t.Errorf("POST %s\n got %d %s\nwant %d %s", tc.path, st, got, tc.status, tc.want)
}
})
}
}
// A body that is JSON but not THIS body is refused in this surface's envelope,
// which is the whole reason the input records its own decode outcome rather than
// letting the framework render the failure. The sentence is the decoder's own and
// is not pinned; the envelope around it is ours and is.
//
// A body that is not JSON AT ALL never reaches the op — encoding/json rejects the
// syntax before any Unmarshaler runs, so zip answers 400 in its own
// {status,error} envelope. That seam belongs to the framework; everything after
// it belongs here.
func TestWireDecode(t *testing.T) {
app := wire(t)
st, got := raw(t, app, "/v1/iam/admin/applications/upsert", "Bearer "+svcToken, `{"name":5}`)
if st != 400 || !strings.HasPrefix(got, `{"msg":"invalid body: `) || !strings.HasSuffix(got, `","status":"error"}`) {
t.Errorf("got %d %s, want 400 in this surface's error envelope", st, got)
}
}
+21
View File
@@ -0,0 +1,21 @@
// Code generated by zipdoc; DO NOT EDIT.
package bootstrap
import (
"github.com/zap-proto/zip"
)
func init() {
zip.Describe("POST /v1/iam/admin/applications/upsert", zip.Doc{
Description: "Creates an application or updates it in place, so a\ndeployment can declare the applications it needs and run the same declaration\non every environment and on every redeploy.\n\nIt says which of the two it did. Leave the client secret out and the existing\none is kept — so re-running your deployment does not rotate a credential your\nrunning services are holding.",
Fields: map[string]string{
"registration.expireInHours": "ExpireInHours and RefreshExpireInHours are the application's token\nlifetimes. They are the ONLY declarative way to say that a refresh token\nmust OUTLIVE its access token: with neither stated, oidc.refreshTTL clamps\nthe refresh lifetime to the access lifetime, so the refresh_token grant the\nregistration advertises expires at the same instant as the token it was\nmeant to renew and can never be exercised. `hanzo-cli` sat in exactly that\nstate — a browser re-login every hour, and a live refresh returning 401.\n\nPOINTERS, for the same reason as IsShared: a plain float would read as 0 on\nevery reconcile that says nothing and reset a deliberate lifetime back to\nthe default. Nil means \"not stated, leave it\".",
"registration.isShared": "IsShared declares that this application serves EVERY organization, not only\nthe one named in Organization. It is the honest description of a brand app —\nhanzo-id, hanzo-chat, a brand console — whose customers each live in their own\ntenant: self-service onboarding moves a founder OUT of the brand org, so\n`user.Owner != app.Organization` is the steady state and the app really does\nserve every org. Application.ServesOrg reads it as one of the three ways to\nsay yes.\n\nA POINTER because omission must PRESERVE. This upsert is the operator's\nsteady-state reconcile and most callers say nothing about sharing; a plain\nbool would read as false on every one of them and silently un-share an app —\nthe same shape of accident that de-secreted apps through update-application.\nNil means \"not stated, leave it\"; only an explicit true or false moves it.",
"registration.public": "Public declares a client that CANNOT hold a credential — a browser SPA,\na CLI, a desktop app. It proves itself with PKCE instead, and the token\nendpoint treats \"no stored secret\" as exactly that (token.go: a secret is\nverified only when one is stored). Without this flag every upsert minted\na secret, so a public client could never be registered at all and its\nbrowser code->token exchange 401'd `invalid_client` forever.",
},
})
zip.Describe("POST /v1/iam/admin/users/upsert", zip.Doc{
Description: "Creates a person or updates them in place, so a deployment can\ndeclare the accounts it needs and re-run that declaration safely.\n\nPasswords are hashed before they are stored. Leave the password out and their\ncurrent one is kept, so a redeploy never locks somebody out.",
})
}
+186
View File
@@ -0,0 +1,186 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
// Package certs serves the IAM v2 CRUD surface for the `certs` entity: a
// signing / TLS certificate owner-scoped by (owner, name). Every operation is a
// typed zip handler over hanzoai/orm; the orm string key is "owner/name". Reads
// scope to one owner (organization); writes address one cert by its (owner,
// name) key.
package certs
import (
"context"
"errors"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/authz"
"github.com/hanzoai/iam/pkg/schema"
)
// Handler binds the certs operations to one orm store.
type Handler struct {
db orm.DB
}
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
// Route registers the certs CRUD routes on app against db. Reads are zip.Get,
// writes are zip.Post; the create/update body is the schema.Cert row itself, so
// the HTTP contract and the stored entity never drift.
func Route(app *zip.App, db orm.DB) {
h := &Handler{db: db}
zip.Get(app, "/v1/iam/certs", h.List, zip.WithTags("certs"))
zip.Post(app, "/v1/iam/certs", h.Create, zip.WithTags("certs"))
zip.Post(app, "/v1/iam/certs/get", h.Get, zip.WithTags("certs"))
zip.Post(app, "/v1/iam/certs/update", h.Update, zip.WithTags("certs"))
zip.Post(app, "/v1/iam/certs/delete", h.Delete, zip.WithTags("certs"))
}
// Ref addresses one cert by its owner-scoped natural key.
type Ref struct {
Owner string `json:"owner"`
Name string `json:"name"`
}
// ListInput scopes a listing to one owner (organization).
type ListInput struct {
Owner string `json:"owner"`
}
// ListOutput is the owner-scoped page of certs.
type ListOutput struct {
Certs []*schema.Cert `json:"certs"`
Total int `json:"total"`
}
// DeleteOutput reports the delete result.
type DeleteOutput struct {
Deleted bool `json:"deleted"`
}
// key builds the orm string key from the (owner, name) natural key.
func key(owner, name string) string { return owner + "/" + name }
// List returns your organization's signing certificates, newest first — the keys
// the tokens your applications verify are signed with. Private key material is
// masked.
//
// You see your own organization's certificates and no one else's; which
// organization that is comes from your credentials, not from the request, so a
// query parameter can never widen the listing.
func (h *Handler) List(ctx context.Context, in *ListInput) (*ListOutput, error) {
owner, err := authz.Scope(ctx, in.Owner)
if err != nil {
return nil, err
}
q := orm.TypedQuery[schema.Cert](h.db)
if owner != "" {
q = q.Filter("owner", owner)
}
certs, err := q.Order("-createdTime").GetAll(ctx)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
out := make([]*schema.Cert, len(certs))
for i, c := range certs {
out[i] = c.Mask()
}
return &ListOutput{Certs: out, Total: len(out)}, nil
}
// Get returns one signing certificate — its algorithm, its validity window and
// its public half. The private key is masked.
func (h *Handler) Get(_ context.Context, in *Ref) (*schema.Cert, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
cert, err := orm.Get[schema.Cert](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
return cert.Mask(), nil
}
// Create adds a signing certificate your applications can verify tokens against
// — the call you make to bring your own key, or to stage the next one before a
// rotation. A name already used in your organization is refused.
func (h *Handler) Create(ctx context.Context, in *schema.Cert) (*schema.Cert, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
switch _, err := orm.Get[schema.Cert](h.db, key(in.Owner, in.Name)); {
case err == nil:
return nil, zip.ErrConflict("cert already exists")
case !errors.Is(err, orm.ErrNotFound):
return nil, zip.ErrInternal(err.Error())
}
// orm.New binds the store and applies defaults; overlay the decoded row,
// then restore the bound Model so its db handle survives the assignment.
cert := orm.New[schema.Cert](h.db)
model := cert.Model
*cert = *in
cert.Model = model
if cert.CreatedTime == "" {
cert.CreatedTime = time.Now().UTC().Format(time.RFC3339)
}
cert.SetId(key(in.Owner, in.Name))
if err := cert.CreateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return cert, nil
}
// Update changes a signing certificate's settings. What it is called does not
// change, and neither does when it was added.
func (h *Handler) Update(ctx context.Context, in *schema.Cert) (*schema.Cert, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
cert, err := orm.Get[schema.Cert](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
// Keep the loaded Model (id, createdAt, key, snapshot) and the original
// creation stamp; overlay the decoded domain fields onto them.
model := cert.Model
created := cert.CreatedTime
*cert = *in
cert.Model = model
cert.Owner, cert.Name = in.Owner, in.Name
if created != "" {
cert.CreatedTime = created
}
if err := cert.UpdateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return cert, nil
}
// Delete removes a signing certificate. Tokens signed with it can no longer be
// verified, so retire it only once nothing is still presenting them.
func (h *Handler) Delete(ctx context.Context, in *Ref) (*DeleteOutput, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
cert, err := orm.Get[schema.Cert](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
if err := cert.DeleteCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &DeleteOutput{Deleted: true}, nil
}
// mapErr translates an orm lookup error into the matching HTTP status.
func mapErr(err error) error {
if errors.Is(err, orm.ErrNotFound) {
return zip.ErrNotFound("cert not found")
}
return zip.ErrInternal(err.Error())
}
+37
View File
@@ -0,0 +1,37 @@
// Code generated by zipdoc; DO NOT EDIT.
package certs
import (
"github.com/zap-proto/zip"
)
func init() {
zip.Describe("GET /v1/iam/certs", zip.Doc{
Description: "Returns your organization's signing certificates, newest first — the keys\nthe tokens your applications verify are signed with. Private key material is\nmasked.\n\nYou see your own organization's certificates and no one else's; which\norganization that is comes from your credentials, not from the request, so a\nquery parameter can never widen the listing.",
Fields: map[string]string{
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
},
})
zip.Describe("POST /v1/iam/certs", zip.Doc{
Description: "Adds a signing certificate your applications can verify tokens against\n— the call you make to bring your own key, or to stage the next one before a\nrotation. A name already used in your organization is refused.",
Fields: map[string]string{
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
},
})
zip.Describe("POST /v1/iam/certs/delete", zip.Doc{
Description: "Removes a signing certificate. Tokens signed with it can no longer be\nverified, so retire it only once nothing is still presenting them.",
})
zip.Describe("POST /v1/iam/certs/get", zip.Doc{
Description: "Returns one signing certificate — its algorithm, its validity window and\nits public half. The private key is masked.",
Fields: map[string]string{
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
},
})
zip.Describe("POST /v1/iam/certs/update", zip.Doc{
Description: "Changes a signing certificate's settings. What it is called does not\nchange, and neither does when it was added.",
Fields: map[string]string{
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
},
})
}
+7 -4
View File
@@ -1,7 +1,8 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
// Package compare implements the Phase-0 drift gate: it counts rows per
// entity in the v1 Casdoor database and the v2 orm store and prints the
// entity in the v1 the legacy surface database and the v2 orm store and prints the
// absolute drift. Cutover (MIGRATION.md §5) is blocked until drift is 0.
//
// Read-only by construction: the v1 side issues only SELECT COUNT(*); the v2
@@ -22,7 +23,7 @@ import (
"github.com/hanzoai/orm"
)
// pair maps a v1 Casdoor table to the v2 orm kind that mirrors it.
// pair maps a v1 the legacy surface table to the v2 orm kind that mirrors it.
type pair struct {
v1Table string
v2Kind string
@@ -44,6 +45,8 @@ var mapping = []pair{
{"token", "tokens"},
{"record", "audit_logs"},
{"invitation", "invitations"},
{"web3_nonce", "challenges"},
{"wallet_link", "wallets"},
}
// Run writes a tab-aligned per-entity drift report to w. ctx bounds every
@@ -56,7 +59,7 @@ func Run(ctx context.Context, v2 orm.DB, legacyDSN string, w io.Writer) error {
if scheme == "" {
return errors.New("compare: --legacy DSN must start with postgres:// or mysql://")
}
return fmt.Errorf("compare: no %q driver in this build — rebuild with `-tags migration` to read the v1 Casdoor database", scheme)
return fmt.Errorf("compare: no %q driver in this build — rebuild with `-tags migration` to read the v1 the legacy surface database", scheme)
}
legacy, err := sql.Open(driver, legacyArg(scheme, legacyDSN))
+3 -2
View File
@@ -1,9 +1,10 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//go:build migration
// This file is linked only in `go build -tags migration`. It registers the v1
// Casdoor database drivers (Postgres via pgx, MySQL) so `iam2 compare` can
// the legacy surface database drivers (Postgres via pgx, MySQL) so `iam compare` can
// read the legacy store. The default build omits it, keeping the serving
// binary free of any non-SQLite driver.
package compare
+3 -2
View File
@@ -1,10 +1,11 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//go:build !migration
package compare
// legacyDriver reports no driver in the default build. `iam2 compare` needs a
// legacyDriver reports no driver in the default build. `iam compare` needs a
// `-tags migration` build to link the v1 Postgres/MySQL driver — see
// legacy_migration.go. This keeps the serving binary free of non-SQLite drivers.
func legacyDriver(string) (string, bool) { return "", false }
+340
View File
@@ -0,0 +1,340 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
// Package compat serves the legacy VERB surface (get-users, get-organizations,
// …) over iam's orm store, in the v1 Response envelope. It exists because every
// live consumer — the console admin BFF, the gateway admin-api, the hanzo.id
// portal — hard-codes the legacy verb spellings and the `{status,data,data2}`
// envelope, while iam's native surface is REST (`/v1/iam/users`,
// `/v1/iam/users/get`). Without these aliases a backend swap 404s every console
// IAM page. The aliases are a thin routing + envelope layer over the SAME orm
// store and the SAME schema.Mask redaction the REST handlers use — no CRUD and
// no redaction is reimplemented here.
//
// Authorization is NOT reimplemented either. These paths are not in authz's
// public allowlist, so the Guard (app.Use, registered first) authenticates every
// request AND authorizes the read against the exact (owner, name) it addresses —
// resolved by the same authz.ReadTarget the handlers use, so a handler can never
// reach a row the Guard did not authorize. Each handler then re-scopes the query
// owner through authz.Scope: a SuperAdmin may list any owner (empty = all
// tenants), everyone else is pinned to their own org, so a request parameter can
// never widen a read past the caller's authority.
package compat
import (
"errors"
"strconv"
"strings"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/authz"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
)
// unauthorized is v1's refusal message, verbatim — the envelope a denied caller
// receives from a handler-authorized read (the Guard's own refusals are raw 403s).
const unauthorized = "auth:Unauthorized operation"
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
// Route registers the the legacy surface read-verb aliases. The mask argument is the
// entity's schema.Mask method (the ONE redaction contract) for entities that
// carry secrets, or nil for those that do not — nil means "no field to strip",
// not "skip a needed redaction". Writes ride a companion file.
func Route(app *zip.App, db orm.DB) {
// List reads — `?owner=&p=&pageSize=` (legacy shape). Owner-scoped by authz.
app.Get("/v1/iam/get-organizations", listHandler(db, (*schema.Organization).Mask))
app.Get("/v1/iam/get-users", listHandler(db, (*schema.User).Mask))
app.Get("/v1/iam/get-global-users", listHandler(db, (*schema.User).Mask))
app.Get("/v1/iam/get-applications", listHandler(db, (*schema.Application).Mask))
app.Get("/v1/iam/get-providers", listHandler(db, (*schema.Provider).Mask))
app.Get("/v1/iam/get-certs", listHandler(db, (*schema.Cert).Mask))
app.Get("/v1/iam/get-roles", listHandler[schema.Role](db, nil))
app.Get("/v1/iam/get-permissions", listHandler[schema.Permission](db, nil))
app.Get("/v1/iam/get-invitations", listHandler[schema.Invitation](db, nil))
app.Get("/v1/iam/get-records", listHandler[schema.AuditLog](db, nil))
// Single reads — `?id=<owner>/<name>` (or `?owner=&name=`).
app.Get("/v1/iam/get-organization", getHandler(db, (*schema.Organization).Mask))
app.Get("/v1/iam/get-user", userGetHandler(db))
app.Get("/v1/iam/get-application", getHandler(db, (*schema.Application).Mask))
app.Get("/v1/iam/get-provider", getHandler(db, (*schema.Provider).Mask))
app.Get("/v1/iam/get-cert", getHandler(db, (*schema.Cert).Mask))
app.Get("/v1/iam/get-role", getHandler[schema.Role](db, nil))
app.Get("/v1/iam/get-permission", getHandler[schema.Permission](db, nil))
// resolve-key — the WRITE-ONLY ingest door and the dual of get-user?accessKey. It
// turns a publishable pk- into just the ORG that holds it (never a principal), for
// cloud's ingest boundary. Its target rides in ?accessKey= (no owner/name for the
// Guard to authorize), so it is handler-authorized (authz.handlerAuthorizedExact)
// and the handler authorizes itself behind CapPublishableResolve.
app.Get("/v1/iam/resolve-key", resolveKeyHandler(db))
// get-organization-projects — the console ScopeSwitcher's project list, keyed by
// ?organization= (not ?owner=). Its target rides in ?organization, which the Guard
// does not inspect generically, so this path is handler-authorized (authz's
// handlerAuthorizedPrefixes): the Guard authenticates, and this handler scopes the
// requested org through authz.Scope — a non-super is pinned to its own org, so any
// authenticated member lists exactly its own org's projects (the ScopeSwitcher is
// shown to every user, not only admins, so this read is intentionally not
// admin-gated the way the generic listers are).
app.Get("/v1/iam/get-organization-projects", orgProjectsHandler(db))
// get-organization-workspaces — the console ScopeSwitcher's workspace list, the
// tier above projects in the Organization → Workspace → Project hierarchy. Keyed
// by ?organization= exactly like get-organization-projects, so it is
// handler-authorized (authz's handlerAuthorizedPrefixes) the same way: the Guard
// authenticates, and this handler scopes the requested org through authz.Scope so
// a request parameter can never widen the read past the caller's tenant.
app.Get("/v1/iam/get-organization-workspaces", orgWorkspacesHandler(db))
// The the legacy surface WRITE verbs (companion file), over the same store + authz seam.
routeWrites(app, db)
}
// orgProjectsHandler returns one organization's projects — what a scope switcher
// lists so somebody can move between them.
//
// You see your own organization and no other, whatever the request asks for.
func orgProjectsHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
requested := c.Query("organization")
if requested == "" {
requested = c.Query("owner")
}
owner, err := authz.Scope(ctx, requested)
if err != nil {
return authz.Deny(c, err)
}
q := orm.TypedQuery[schema.Project](db)
if owner != "" {
q = q.Filter("Owner=", owner)
}
rows, err := q.Order("Name").GetAll(ctx)
if err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, rows)
}
}
// orgWorkspacesHandler returns one organization's workspaces — what a scope
// switcher lists so somebody can move between them.
//
// You see your own organization and no other, whatever the request asks for.
func orgWorkspacesHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
requested := c.Query("organization")
if requested == "" {
requested = c.Query("owner")
}
owner, err := authz.Scope(ctx, requested)
if err != nil {
return authz.Deny(c, err)
}
q := orm.TypedQuery[schema.Workspace](db)
if owner != "" {
q = q.Filter("Owner=", owner)
}
rows, err := q.Order("Name").GetAll(ctx)
if err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, rows)
}
}
// listHandler lists one kind of record in your organization — the older spelling
// of the collection reads on the REST surface, over the same data and the same
// permissions.
//
// Secrets are stripped from every row. Send both a page number and a page size to
// page, and the total comes back alongside; send neither and you get the whole
// set. You see your own organization and no other, whatever the request asks for.
//
// Scoping note (intentional, fail-closed): iam's ownership model is mixed —
// users/roles/permissions are owned by their tenant org, while organizations/
// applications/providers/certs are platform-owned (Owner "admin"). A SuperAdmin
// (Scope → the requested owner, empty = all) therefore lists every entity, which
// is the console-admin path. A non-super is pinned by Scope to its own org, so it
// lists its tenant-owned entities correctly and is refused the platform-owned
// lists at the Guard (owner "" or "admin" both deny) — a safe 403, never another
// tenant's rows. Non-super, membership-scoped views of the platform-owned
// entities (e.g. an org console's own app list keyed on Application.Organization)
// are a separate, additive surface, not a silent behavior of this generic lister.
func listHandler[T any](db orm.DB, mask func(*T) *T) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
owner, err := authz.Scope(ctx, c.Query("owner"))
if err != nil {
return authz.Deny(c, err)
}
base := func() *orm.ModelQuery[T] {
q := orm.TypedQuery[T](db)
if owner != "" {
q = q.Filter("Owner=", owner)
}
return q
}
page, size, paginated := pageParams(c)
if !paginated {
rows, err := base().Order("Name").GetAll(ctx)
if err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, maskAll(rows, mask))
}
total, err := base().Count(ctx)
if err != nil {
return httpx.Err(c, err.Error())
}
rows, err := base().Order("Name").Limit(size).Offset((page - 1) * size).GetAll(ctx)
if err != nil {
return httpx.Err(c, err.Error())
}
return c.JSON(200, httpx.Response{Status: "ok", Data: maskAll(rows, mask), Data2: total})
}
}
// getHandler reads one record — the older spelling of the single reads on the
// REST surface, over the same data and the same permissions.
//
// Secrets are stripped. Naming a record in another organization does not reach
// it, however the request spells it.
func getHandler[T any](db orm.DB, mask func(*T) *T) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
owner, name := authz.ReadTarget(c)
if name == "" {
return httpx.Err(c, "id (owner/name) or name is required")
}
scoped, err := authz.ScopeFor(ctx, c.Path(), owner, name)
if err != nil {
return authz.Deny(c, err)
}
row, err := orm.TypedQuery[T](db).Filter("Owner=", scoped).Filter("Name=", name).First()
if errors.Is(err, orm.ErrNotFound) {
return httpx.Err(c, "the entity does not exist")
}
if err != nil {
return httpx.Err(c, err.Error())
}
if mask != nil {
row = mask(row)
}
return httpx.Ok(c, row)
}
}
// userGetHandler reads one person, two ways.
//
// Name them and it is an ordinary read, with secrets stripped. Or hand it a
// SECRET API key and it answers with the person that key belongs to — how a
// service of yours turns a credential on an incoming request into an identity.
//
// A publishable key resolves to nobody here, deliberately: it is safe to ship in
// a browser precisely because it names an organization and never a person.
//
// get-user is handler-authorized (authz.handlerAuthorizedExact) because the key
// variant carries no owner/name for the Guard to authorize; so the owner/name
// variant reinstates the SAME read authorization the Guard applies, through the ONE
// policy function (authz.Can) — identical behavior, a cross-tenant or non-self read
// still refused 403 — then reuses the generic getHandler verbatim for resolution and
// redaction. No authz and no CRUD is reimplemented.
func userGetHandler(db orm.DB) zip.Handler {
byOwnerName := getHandler(db, (*schema.User).Mask)
return func(c *zip.Ctx) error {
if key := strings.TrimSpace(c.Query("accessKey")); key != "" {
return resolveUserByAccessKey(c, db, key)
}
owner, name := authz.ReadTarget(c)
if !authz.Can(c.Context(), "GET", "users", owner, name) {
return zip.ErrForbidden("forbidden")
}
return byOwnerName(c)
}
}
// keyUser is the minimal principal projection get-user?accessKey returns — EXACTLY
// the four fields cloud's key resolver consumes (auth_apikey.go) and no more. It is
// a TIGHTER redaction than schema.User.Mask, deliberately: Mask blanks the secret
// digests and bearer tokens but leaves AccessKey populated, and an sk- resolution
// must never disclose the resolved user's OTHER credential (the value on its User row) to a
// caller that only presented a secret key. A projection carrying no secret field is
// leak-proof by construction.
type keyUser struct {
Owner string `json:"owner"`
Name string `json:"name"`
Email string `json:"email"`
IsAdmin bool `json:"isAdmin"`
}
// resolveUserByAccessKey authenticates the SERVICE caller and resolves an API key to
// its owning principal. The gate is service-only and fail-secure: the caller must be
// a confidential app (p.App != "") holding CapKeyResolve — a human, even a
// SuperAdmin, is refused, because a capability is held vacuously by non-apps and key
// resolution is a machine-identity boundary, never an interactive admin action.
//
// THE `msg` STAYS UNIFORM AND THE `code` SAYS WHY. Every unresolvable key answers the
// same not-exist sentence every other get- verb uses, so nothing that reads the prose
// can tell a missing key from a denied one. The machine-readable reason rides beside
// it because THE GATE ABOVE IS THE BOUNDARY, not the vagueness of this sentence: a
// caller that reaches this line has already proven it is a confidential app holding
// CapKeyResolve, and such a caller can resolve any key it likes to a full principal.
// Telling it which refusal occurred discloses nothing it could not already obtain,
// and withholding it is what made a revoked key indistinguishable from a deleted org
// for every human downstream. There is no anonymous reader of this envelope to
// oracle.
func resolveUserByAccessKey(c *zip.Ctx, db orm.DB, key string) error {
ctx := c.Context()
p, ok := authz.From(ctx)
if !ok || p.App == "" || !authz.Allowed(p, authz.CapKeyResolve) {
return httpx.Err(c, unauthorized)
}
u, err := store.UserByAccessKey(ctx, db, key)
if errors.Is(err, orm.ErrNotFound) {
return httpx.ErrCode(c, "the entity does not exist", string(store.Reason(err)))
}
if err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, keyUser{Owner: u.Owner, Name: u.Name, Email: u.Email, IsAdmin: u.IsAdmin})
}
// maskAll redacts every row through the entity's Mask (a no-op when the entity
// has no secrets, i.e. mask is nil). Mask returns a copy, so the slice is
// rewritten in place with the masked copies.
func maskAll[T any](rows []*T, mask func(*T) *T) []*T {
if mask == nil {
return rows
}
for i, r := range rows {
rows[i] = mask(r)
}
return rows
}
// pageParams returns (page, size, paginated). A list paginates ONLY when BOTH
// `p` and `pageSize` are present and positive; otherwise the caller returns the
// full set (v1 semantics).
func pageParams(c *zip.Ctx) (page, size int, paginated bool) {
pp, ps := c.Query("p"), c.Query("pageSize")
if pp == "" || ps == "" {
return 0, 0, false
}
page, _ = strconv.Atoi(pp)
size, _ = strconv.Atoi(ps)
if page <= 0 || size <= 0 {
return 0, 0, false
}
return page, size, true
}
+375
View File
@@ -0,0 +1,375 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package compat_test
// End-to-end tests for the legacy verb aliases, driven through the REAL registered
// router (routes.Route installs the authz Guard between the public group and the
// authed routes; compat is registered after it, so gated). Every
// case is a HTTP request a live console/gateway client sends. The assertions are
// the three contracts a backend swap depends on: the v1 {status,data,data2}
// envelope shape, owner-scoping that no request parameter can widen, and — the
// security one — that NO secret material ever appears in a response body.
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"io"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/orm"
ormdb "github.com/hanzoai/orm/db"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/routes"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/internal/testhttp"
)
const signingKid = "cert-hanzo"
// Distinctive secret sentinels: if any of these strings appears in ANY response
// body, redaction failed and a real credential leaked.
const (
secretUserHash = "$argon2id$SENTINEL_USER_PW_HASH"
secretOrgMaster = "SENTINEL_ORG_MASTER_PW"
secretAppClient = "SENTINEL_APP_CLIENT_SECRET"
secretProvClient = "SENTINEL_PROVIDER_CLIENT_SECRET"
)
type harness struct {
app *zip.App
key *rsa.PrivateKey
db orm.DB
}
func newHarness(t *testing.T) *harness {
t.Helper()
_ = schema.Kinds()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa: %v", err)
}
dir := t.TempDir()
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(dir, "compat.db"),
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
// Trust anchor (admin-owned RS256 signing cert = JWKS kid).
seedCert(t, db, "admin", signingKid, pemOf(t, key))
// Principals across two orgs: a SuperAdmin, an org-admin, a regular user.
seedUser(t, db, "admin", "root", true) // SuperAdmin (org == admin)
seedUser(t, db, "hanzo", "boss", true) // org-admin of hanzo
seedUser(t, db, "hanzo", "alice", false) // regular user in hanzo
seedUser(t, db, "orgb", "bob", true) // org-admin of a second tenant
// Secret-bearing rows: every one carries a sentinel that must never surface.
// users already seeded carry a password hash sentinel (set in seedUser).
seedOrg(t, db, "hanzo") // Owner="admin", Name="hanzo", MasterPassword sentinel
seedApp(t, db, "hanzo-console") // Owner="admin", ClientSecret sentinel
seedProvider(t, db, "provider-gh") // Owner="admin", ClientSecret sentinel
app := zip.New(zip.Config{AppName: "compat-test", DisableStartupMessage: true})
routes.Route(app, db)
if err := app.Build(); err != nil {
t.Fatalf("build: %v", err)
}
return &harness{app: app, key: key, db: db}
}
func (h *harness) token(t *testing.T, sub string) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"sub": sub,
"iat": time.Now().Add(-time.Minute).Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
})
tok.Header["kid"] = signingKid
s, err := tok.SignedString(h.key)
if err != nil {
t.Fatalf("sign: %v", err)
}
return s
}
// get issues a GET through the real router and returns (status, rawBody).
func (h *harness) get(t *testing.T, path, bearer string) (int, string) {
t.Helper()
req := httptest.NewRequest("GET", path, nil)
req.Host = "hanzo.id"
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := testhttp.Do(h.app, req)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
b, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return resp.StatusCode, string(b)
}
// envelope is the v1 Response shape the clients parse.
type envelope struct {
Status string `json:"status"`
Msg string `json:"msg"`
Data []json.RawMessage `json:"data"`
Data2 json.RawMessage `json:"data2"`
}
// ---- assertions ------------------------------------------------------------
func TestGetUsers_super_envelopeAndNoSecretLeak(t *testing.T) {
h := newHarness(t)
status, body := h.get(t, "/v1/iam/get-users", h.token(t, "admin/root"))
if status != 200 {
t.Fatalf("status = %d, want 200; body=%s", status, body)
}
assertNoSecretLeak(t, body)
var env envelope
if err := json.Unmarshal([]byte(body), &env); err != nil {
t.Fatalf("body is not the v1 envelope: %v; body=%s", err, body)
}
if env.Status != "ok" {
t.Fatalf("status field = %q, want ok", env.Status)
}
// SuperAdmin, no owner filter → every user across every org (4 seeded).
if len(env.Data) != 4 {
t.Fatalf("super get-users returned %d users, want 4", len(env.Data))
}
}
func TestGetUsers_paged_data2IsTotal(t *testing.T) {
h := newHarness(t)
_, body := h.get(t, "/v1/iam/get-users?p=1&pageSize=2", h.token(t, "admin/root"))
assertNoSecretLeak(t, body)
var env envelope
if err := json.Unmarshal([]byte(body), &env); err != nil {
t.Fatalf("not the v1 envelope: %v", err)
}
if len(env.Data) != 2 {
t.Fatalf("page 1 pageSize 2 returned %d rows, want 2", len(env.Data))
}
// data2 carries the FULL owner-scoped total (4), not the page length.
var total int
if err := json.Unmarshal(env.Data2, &total); err != nil {
t.Fatalf("data2 is not an int total: %v (data2=%s)", err, env.Data2)
}
if total != 4 {
t.Fatalf("data2 total = %d, want 4", total)
}
}
func TestGetUsers_unpaged_hasNoData2(t *testing.T) {
h := newHarness(t)
_, body := h.get(t, "/v1/iam/get-users", h.token(t, "admin/root"))
// v1 omits data2 entirely when the list is not paginated.
if strings.Contains(body, "\"data2\"") {
t.Fatalf("unpaged list must omit data2; body=%s", body)
}
}
func TestGetOrganizations_super_listsAll_masked(t *testing.T) {
h := newHarness(t)
status, body := h.get(t, "/v1/iam/get-organizations", h.token(t, "admin/root"))
if status != 200 {
t.Fatalf("status = %d; body=%s", status, body)
}
assertNoSecretLeak(t, body)
// The masked org keeps its "***" sentinel, proving Mask ran (not the raw pw).
if !strings.Contains(body, "***") {
t.Fatalf("expected the masked '***' marker in the org list; body=%s", body)
}
}
func TestGetApplications_super_noClientSecret(t *testing.T) {
h := newHarness(t)
status, body := h.get(t, "/v1/iam/get-applications", h.token(t, "admin/root"))
if status != 200 {
t.Fatalf("status=%d body=%s", status, body)
}
assertNoSecretLeak(t, body)
}
func TestGetProviders_super_noClientSecret(t *testing.T) {
h := newHarness(t)
status, body := h.get(t, "/v1/iam/get-providers", h.token(t, "admin/root"))
if status != 200 {
t.Fatalf("status=%d body=%s", status, body)
}
assertNoSecretLeak(t, body)
}
func TestGetUser_byId_super(t *testing.T) {
h := newHarness(t)
// The the legacy surface `?id=<owner>/<name>` shape — resolved by authz.ReadTarget.
status, body := h.get(t, "/v1/iam/get-user?id=hanzo/alice", h.token(t, "admin/root"))
if status != 200 {
t.Fatalf("status=%d body=%s", status, body)
}
assertNoSecretLeak(t, body)
if !strings.Contains(body, "alice") {
t.Fatalf("get-user?id=hanzo/alice did not return alice; body=%s", body)
}
}
func TestGetUsers_orgAdmin_scopedToOwnOrg(t *testing.T) {
h := newHarness(t)
// An org-admin MUST pass its own owner (the Guard denies an empty owner for a
// non-super); it then sees only its org's users.
status, body := h.get(t, "/v1/iam/get-users?owner=hanzo", h.token(t, "hanzo/boss"))
if status != 200 {
t.Fatalf("status=%d body=%s", status, body)
}
var env envelope
_ = json.Unmarshal([]byte(body), &env)
if len(env.Data) != 2 { // hanzo/boss + hanzo/alice, never orgb/bob
t.Fatalf("org-admin get-users?owner=hanzo returned %d, want 2 (own org only)", len(env.Data))
}
assertNoSecretLeak(t, body)
}
func TestGetUsers_orgAdmin_crossTenantDenied(t *testing.T) {
h := newHarness(t)
// hanzo's admin cannot list orgb's users — the Guard refuses a foreign owner.
status, _ := h.get(t, "/v1/iam/get-users?owner=orgb", h.token(t, "hanzo/boss"))
if status != 403 {
t.Fatalf("cross-tenant get-users status = %d, want 403", status)
}
}
func TestGetUser_byId_crossTenantDenied(t *testing.T) {
h := newHarness(t)
// The `?id=` fallback must not open a cross-tenant hole: hanzo's admin naming
// orgb/bob is refused at the Guard, exactly as the ?owner= form is.
status, _ := h.get(t, "/v1/iam/get-user?id=orgb/bob", h.token(t, "hanzo/boss"))
if status != 403 {
t.Fatalf("cross-tenant get-user?id=orgb/bob status = %d, want 403", status)
}
}
func TestGetUsers_regularUser_cannotList(t *testing.T) {
h := newHarness(t)
// A non-admin user may not enumerate its org's users (the self-service rule is
// a single-record read, never a list).
status, _ := h.get(t, "/v1/iam/get-users?owner=hanzo", h.token(t, "hanzo/alice"))
if status != 403 {
t.Fatalf("regular-user get-users status = %d, want 403", status)
}
}
func TestGetApplications_nonSuper_deniedOnPlatformOwned(t *testing.T) {
h := newHarness(t)
// Applications are platform-owned (Owner "admin"); a non-super gets a safe 403
// at the Guard, never another tenant's app rows.
status, _ := h.get(t, "/v1/iam/get-applications", h.token(t, "hanzo/boss"))
if status != 403 {
t.Fatalf("non-super get-applications status = %d, want 403", status)
}
}
func TestCompatAliases_requireAuth(t *testing.T) {
h := newHarness(t)
// No bearer → the Guard fails closed (compat is registered after the Guard).
if status, _ := h.get(t, "/v1/iam/get-users", ""); status != 401 {
t.Fatalf("unauthenticated get-users status = %d, want 401", status)
}
}
// assertNoSecretLeak fails if any seeded secret sentinel appears in the body —
// the single most important property of the whole layer.
func assertNoSecretLeak(t *testing.T, body string) {
t.Helper()
for _, secret := range []string{secretUserHash, secretOrgMaster, secretAppClient, secretProvClient} {
if strings.Contains(body, secret) {
t.Fatalf("SECRET LEAK: %q appeared in a response body:\n%s", secret, body)
}
}
}
// ---- seed helpers ----------------------------------------------------------
func seedCert(t *testing.T, db orm.DB, owner, name, privPEM string) {
t.Helper()
c := orm.New[schema.Cert](db)
c.Owner, c.Name = owner, name
c.CryptoAlgorithm = "RS256"
c.PrivateKey = privPEM
c.SetId(owner + "/" + name)
if err := c.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed cert: %v", err)
}
}
func seedUser(t *testing.T, db orm.DB, owner, name string, admin bool) {
t.Helper()
u := orm.New[schema.User](db)
u.Owner, u.Name = owner, name
u.IsAdmin = admin
u.PasswordHash = secretUserHash // the sentinel that must never surface
u.PasswordType = "argon2id"
u.SetId(owner + "/" + name)
if err := u.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed user: %v", err)
}
}
func seedOrg(t *testing.T, db orm.DB, name string) {
t.Helper()
o := orm.New[schema.Organization](db)
o.Owner, o.Name = "admin", name // orgs are platform-owned
o.MasterPassword = secretOrgMaster
o.SetId("admin/" + name)
if err := o.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed org: %v", err)
}
}
func seedApp(t *testing.T, db orm.DB, name string) {
t.Helper()
a := orm.New[schema.Application](db)
a.Owner, a.Name = "admin", name
a.Organization = "hanzo"
a.ClientSecret = secretAppClient
a.SetId("admin/" + name)
if err := a.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed app: %v", err)
}
}
func seedProvider(t *testing.T, db orm.DB, name string) {
t.Helper()
p := orm.New[schema.Provider](db)
p.Owner, p.Name = "admin", name
p.ClientSecret = secretProvClient
p.SetId("admin/" + name)
if err := p.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed provider: %v", err)
}
}
func pemOf(t *testing.T, k *rsa.PrivateKey) string {
t.Helper()
return string(pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k),
}))
}
+316
View File
@@ -0,0 +1,316 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package compat_test
// GAP B — get-user?accessKey: cloud's identity boundary resolves an opaque SECRET API
// key (sk-) to {owner,name,email,isAdmin} to authenticate a keyed request. It is
// SECURITY-CRITICAL: the caller presents a secret key and learns who it belongs to,
// so it is gated behind the CapKeyResolve service capability, fails closed on an
// unknown key, and NEVER leaks a secret field — in particular never the resolved
// user's OTHER credential (the value on its User row) on an sk- resolution. A PUBLIC
// pk- is write-only and is REFUSED here (its org-only dual is /v1/iam/resolve-key).
import (
"context"
"encoding/json"
"io"
"net/http/httptest"
"strings"
"testing"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/internal/testhttp"
)
const (
resolverApp = "hanzo-cloud" // admin-owned service app that holds CapKeyResolve
otherApp = "hanzo-noresolve" // admin-owned app WITHOUT the capability
svcSecret = "resolver-secret"
// A value stamped on schema.User.AccessKey. NOTHING resolves that field, so this
// authenticates nobody — it is a sentinel proving both that a user-row value is
// never a credential and that its retired prefix is not a key shape.
userRowKey = "hk-live-KEYUSERHK"
keyUserSecretHash = "SENTINEL_ACCESS_SECRET_HASH"
projPK = "pk-live-KEYUSERPK" // publishable half of a schema.Key
projSK = "sk-live-KEYUSERPKSECRET" // confidential half of the same Key
)
// keyEnv decodes the single-object get-user envelope.
type keyEnv struct {
Status string `json:"status"`
Msg string `json:"msg"`
Code string `json:"code"`
Data struct {
Owner string `json:"owner"`
Name string `json:"name"`
Email string `json:"email"`
IsAdmin bool `json:"isAdmin"`
} `json:"data"`
}
// getBasic drives a get through the real router authenticating as a confidential
// client (client_secret_basic) — how cloud's key resolver authenticates to IAM.
func (h *harness) getBasic(t *testing.T, path, clientID, secret string) (int, string) {
t.Helper()
req := httptest.NewRequest("GET", path, nil)
req.Host = "hanzo.id"
req.SetBasicAuth(clientID, secret)
resp, err := testhttp.Do(h.app, req)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
b, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return resp.StatusCode, string(b)
}
// keyFixtures seeds the two service apps, the target user (with secret sentinels and a
// non-resolving value on its User row), and a schema.Key (pk-/sk-) belonging to that
// user; then arms the CapKeyResolve allowlist with resolverApp only.
func keyFixtures(t *testing.T, h *harness) {
t.Helper()
seedClientApp(t, h.db, resolverApp, svcSecret)
seedClientApp(t, h.db, otherApp, svcSecret)
u := orm.New[schema.User](h.db)
u.Owner, u.Name, u.Email = "hanzo", "keyuser", "keyuser@hanzo.ai"
u.IsAdmin = true
u.AccessKey = userRowKey
u.AccessSecret = projSK // a secret half on the user row too — must never surface
u.AccessSecretHash = keyUserSecretHash
u.PasswordHash = secretUserHash
u.SetId("hanzo/keyuser")
if err := u.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed key user: %v", err)
}
k := orm.New[schema.Key](h.db)
k.Owner, k.Name, k.User = "hanzo", "keyuser-key", "hanzo/keyuser"
k.AccessKey, k.AccessSecret = projPK, projSK
k.SetId("hanzo/keyuser-key")
if err := k.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed key: %v", err)
}
t.Setenv("IAM_KEY_RESOLVE_APPS", resolverApp)
}
func seedClientApp(t *testing.T, db orm.DB, name, secret string) {
t.Helper()
a := orm.New[schema.Application](db)
a.Owner, a.Name = "admin", name // admin-owned → the CapKeyResolve owner-pin holds
a.Organization = "hanzo"
a.ClientId = name
a.ClientSecret = secret
a.SetId("admin/" + name)
if err := a.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed client app: %v", err)
}
}
// A cap-holding service caller resolves the SECRET key shape to the right user, with
// the exact {owner,name,email,isAdmin} cloud consumes — and NO secret ever appears —
// while the PUBLIC publishable pk- is REFUSED, so a public key can never become a read
// principal at cloud's identity boundary.
func TestGetUserByAccessKey_ResolvesSecretsRefusesPublishable(t *testing.T) {
h := newHarness(t)
keyFixtures(t, h)
for _, tc := range []struct{ name, key string }{
{"sk confidential half", projSK},
} {
status, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+tc.key, resolverApp, svcSecret)
if status != 200 {
t.Fatalf("%s: status=%d body=%s", tc.name, status, body)
}
var e keyEnv
if err := json.Unmarshal([]byte(body), &e); err != nil {
t.Fatalf("%s: envelope: %v body=%s", tc.name, err, body)
}
if e.Status != "ok" {
t.Fatalf("%s: status=%q body=%s", tc.name, e.Status, body)
}
if e.Data.Owner != "hanzo" || e.Data.Name != "keyuser" ||
e.Data.Email != "keyuser@hanzo.ai" || !e.Data.IsAdmin {
t.Fatalf("%s: data=%+v, want hanzo/keyuser keyuser@hanzo.ai isAdmin=true", tc.name, e.Data)
}
// No secret material, and — critically — not the user's OTHER credential
// (the value on its User row) when an sk- key was the one presented.
for _, secret := range []string{secretUserHash, keyUserSecretHash, userRowKey} {
if tc.key != secret && strings.Contains(body, secret) {
t.Fatalf("%s: SECRET LEAK %q in body:\n%s", tc.name, secret, body)
}
}
}
// The PUBLIC pk- publishable half is WRITE-ONLY: get-user?accessKey REFUSES it, even
// to the cap-holding service caller, so a public key never becomes a read principal.
status, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+projPK, resolverApp, svcSecret)
var e keyEnv
_ = json.Unmarshal([]byte(body), &e)
if e.Status != "error" || e.Msg != "the entity does not exist" {
t.Fatalf("publishable pk- via get-user?accessKey: status=%d env=%+v — a pk- must never resolve to a principal", status, e)
}
if strings.Contains(body, "keyuser") {
t.Fatalf("publishable pk- leaked the principal identity: %s", body)
}
}
// There are exactly TWO key shapes. A value carrying a retired prefix is not a key —
// not a deprecated one, not an accepted-for-now one — and it authenticates NOBODY even
// when that exact value is stamped on a real, live user's row.
//
// This is the sharp end of the one-way property: keyFixtures puts userRowKey on
// hanzo/keyuser, so a resurrected prefix branch (or any new read of
// schema.User.AccessKey as a credential) would resolve it to an ADMIN principal and
// fail here loudly. The refusal must also carry key_unknown, which is what renders the
// actionable "mint a new one at cloud.hanzo.ai/keys" for the holder — never
// key_wrong_door, whose advice ("use your secret key") would be a lie to someone whose
// credential no longer exists.
func TestGetUserByAccessKey_RetiredPrefixIsNotAKey(t *testing.T) {
h := newHarness(t)
keyFixtures(t, h)
_, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+userRowKey, resolverApp, svcSecret)
var e keyEnv
_ = json.Unmarshal([]byte(body), &e)
if e.Status != "error" {
t.Fatalf("a retired prefix resolved: env=%+v body=%s", e, body)
}
if e.Code != "key_unknown" {
t.Errorf("code = %q, want key_unknown (the actionable 'mint a new one' path)", e.Code)
}
if strings.Contains(body, "keyuser") {
t.Fatalf("a retired prefix leaked the principal identity: %s", body)
}
}
// F1 REGRESSION — end to end: a forged Key (planted in the attacker's own org but
// pointing User at the reserved admin org = SuperAdmin) must yield NO identity
// through the real get-user?accessKey path, even to the cap-holding service caller.
func TestGetUserByAccessKey_CrossTenantForgeryDenied(t *testing.T) {
h := newHarness(t)
keyFixtures(t, h)
// admin/root already exists in the harness (a SuperAdmin). Plant a Key in
// "attackerOrg" whose User names it, with a KNOWN secret. Seeded directly (the
// write-side gate would also reject it via the API).
k := orm.New[schema.Key](h.db)
k.Owner, k.Name, k.User = "attackerOrg", "forge", "admin/root"
k.AccessKey, k.AccessSecret = "pk-live-FORGE", "sk-live-FORGE"
k.SetId("attackerOrg/forge")
if err := k.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed forged key: %v", err)
}
for _, key := range []string{"pk-live-FORGE", "sk-live-FORGE"} {
_, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+key, resolverApp, svcSecret)
var e keyEnv
_ = json.Unmarshal([]byte(body), &e)
if e.Status != "error" || e.Msg != "the entity does not exist" {
t.Fatalf("FORGERY resolved via %q: env=%+v body=%s", key, e, body)
}
if strings.Contains(body, "\"admin\"") || strings.Contains(body, "\"root\"") {
t.Fatalf("FORGERY leaked the SuperAdmin identity via %q: %s", key, body)
}
}
}
// A caller WITHOUT the capability is refused with v1's verbatim message, whether it
// is an app not on the allowlist or (implicitly) a human — never a resolved user.
func TestGetUserByAccessKey_NonCapDenied(t *testing.T) {
h := newHarness(t)
keyFixtures(t, h)
status, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+projSK, otherApp, svcSecret)
var e keyEnv
_ = json.Unmarshal([]byte(body), &e)
if e.Status != "error" || e.Msg != "auth:Unauthorized operation" {
t.Fatalf("non-cap resolve status=%d env=%+v, want error auth:Unauthorized operation", status, e)
}
if strings.Contains(body, "keyuser") {
t.Fatalf("non-cap caller learned the principal: %s", body)
}
}
// An unknown key resolves to the not-exist envelope — fail closed, no principal.
func TestGetUserByAccessKey_UnknownKeyNotFound(t *testing.T) {
h := newHarness(t)
keyFixtures(t, h)
_, body := h.getBasic(t, "/v1/iam/get-user?accessKey=hk-live-NOSUCHKEY", resolverApp, svcSecret)
var e keyEnv
_ = json.Unmarshal([]byte(body), &e)
if e.Status != "error" || e.Msg != "the entity does not exist" {
t.Fatalf("unknown key env=%+v, want error 'the entity does not exist'", e)
}
}
// An EMPTY accessKey does not trigger key resolution — it falls through to the
// ordinary owner/name read, which a SuperAdmin serves as before.
func TestGetUserByAccessKey_EmptyFallsThrough(t *testing.T) {
h := newHarness(t)
keyFixtures(t, h)
// Empty accessKey + an id: the handler must take the owner/name path, not the key
// path (which would demand CapKeyResolve the human super does not hold as an app).
status, body := h.get(t, "/v1/iam/get-user?accessKey=&id=hanzo/alice", h.token(t, "admin/root"))
if status != 200 || !strings.Contains(body, "alice") {
t.Fatalf("empty accessKey did not fall through to owner/name read: status=%d body=%s", status, body)
}
}
// The refusal REASON reaches the wire while the human sentence stays uniform.
//
// "the entity does not exist" is IAM's generic answer, and cloud rendered it verbatim
// to users: a holder whose key had been revoked was told their entity was gone and
// went looking for a deleted organization instead of minting a new key. The prose is
// deliberately unchanged — nothing that reads `msg` can tell the causes apart — and
// the machine-readable `code` carries the reason to the confidential app that already
// passed CapKeyResolve to get here.
func TestGetUserByAccessKey_RefusalCarriesItsReason(t *testing.T) {
h := newHarness(t)
keyFixtures(t, h)
for _, tc := range []struct{ name, key, wantCode string }{
{"revoked / never minted", "sk-live-NOSUCHKEY2", "key_unknown"},
{"unknown secret half", "sk-live-NOSUCHKEY", "key_unknown"},
{"a publishable key at the SECRET door", projPK, "key_wrong_door"},
{"an unrecognized shape", "fw_deadbeef", "key_unknown"},
{"a retired prefix", "hk-live-NOSUCHKEY", "key_unknown"},
} {
_, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+tc.key, resolverApp, svcSecret)
var e keyEnv
_ = json.Unmarshal([]byte(body), &e)
if e.Status != "error" || e.Msg != "the entity does not exist" {
t.Fatalf("%s: env=%+v — the human sentence must stay uniform", tc.name, e)
}
if e.Code != tc.wantCode {
t.Errorf("%s: code = %q, want %q", tc.name, e.Code, tc.wantCode)
}
// The credential must never be echoed back, in any field.
if strings.Contains(body, tc.key) {
t.Errorf("%s: the refusal echoed the presented key: %s", tc.name, body)
}
}
}
// The AUTH refusal is not a key reason. A caller that fails the CapKeyResolve gate
// gets the unauthorized envelope and NO code at all — so a non-cap caller can never
// use `code` as an existence oracle for keys it may not resolve.
func TestGetUserByAccessKey_NonCapCallerLearnsNoReason(t *testing.T) {
h := newHarness(t)
keyFixtures(t, h)
_, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+projSK, otherApp, svcSecret)
var e keyEnv
_ = json.Unmarshal([]byte(body), &e)
if e.Status != "error" || e.Code != "" {
t.Fatalf("non-cap caller env=%+v, want an error with NO code", e)
}
}
+63
View File
@@ -0,0 +1,63 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package compat
import (
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/authz"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/pkg/store"
)
// resolve-key is the WRITE-ONLY ingest door and the exact DUAL of get-user?accessKey:
// where that verb turns a SECRET key into a principal (for cloud's identity boundary),
// this one turns a PUBLIC publishable pk- into just the ORG that holds it (for cloud's
// ingest boundary). The two live side by side because they are the same shape — a
// confidential service caller resolving an opaque key — split by the one property that
// makes a publishable key safe to ship in client JS: a pk- yields an org and NOTHING
// else, never a user, so it can never become a read grant.
// resolveResponse is the ORG-ONLY projection: the tenant a publishable key belongs to
// and its write-only scope, and no more. It carries no user/name/email/admin — no
// principal — so resolving a pk- discloses only WHICH org, never WHO.
type resolveResponse struct {
Org string `json:"org"`
Scope string `json:"scope"`
}
// resolveKeyHandler answers which organization a PUBLISHABLE key belongs to —
// what a service of yours calls to attribute a request that arrived carrying a
// key shipped in a browser.
//
// It names an organization and never a person: no path through it can load or
// return a user, so a key you put in client code cannot become a way to learn
// who anyone is. A key that is expired, secret rather than publishable, or
// simply unknown all answer with the same sentence, and with a `code` saying
// which of those it was. Only a confidential service that already proved it may
// resolve keys at all ever reads that code — there is no anonymous caller here
// to probe for which keys exist — and telling it apart is what lets the holder
// be told to re-mint an expired key instead of hunting a configuration error.
func resolveKeyHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
p, ok := authz.From(ctx)
if !ok || p.App == "" || !authz.Allowed(p, authz.CapPublishableResolve) {
return httpx.Err(c, unauthorized)
}
k, err := store.PublishableKeyByAccessKey(ctx, db, c.Query("accessKey"), time.Now())
if err != nil {
// Not found, not a pk-, not publishable, expired, or a store error — one
// envelope, and `code` distinguishes them for the confidential app that
// already passed CapPublishableResolve above. A store fault yields no
// reason at all (store.Reason returns ""), so infrastructure trouble is
// never reported to the holder as a bad key.
return httpx.ErrCode(c, "the entity does not exist", string(store.Reason(err)))
}
return httpx.Ok(c, resolveResponse{Org: k.Owner, Scope: k.Scope})
}
}
+235
View File
@@ -0,0 +1,235 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package compat_test
// resolve-key: the WRITE-ONLY ingest door and dual of get-user?accessKey. These tests
// drive the REAL mounted router (routes.Route: the authz Guard authenticates the
// confidential client, resolve-key is handler-authorized and cap-gated) and prove the
// load-bearing property — a PUBLIC publishable pk- resolves to just an ORG, never a
// principal, on EVERY door:
// - resolve-key turns it into {org, scope} and nothing else (the org-only projection);
// - a SECRET key's pk- half, an sk-, an expired/unknown key, a non-cap app, and a
// human are all refused;
// - and the same pk- presented to get-user?accessKey (even by a CapKeyResolve holder)
// or as a bearer to a gated route yields NO principal.
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/pkg/schema"
)
const (
pubResolverApp = "hanzo-cloud-ingest" // admin-owned app holding CapPublishableResolve
pubOtherApp = "hanzo-cloud-noingest" // admin-owned app WITHOUT the capability
pubSecret = "ingest-resolver-secret"
sitePK = "pk-live-SITEKEY" // a WRITE-ONLY publishable key (Scope=publish)
secretKeyPK = "pk-live-SERVERHALF" // the pk- half of a SECRET (default) key
secretKeySK = "sk-live-SERVERHALF" // the sk- half of that same secret key
)
// resolveEnv decodes the resolve-key envelope. Org/Scope are the org-only projection;
// Owner/Name/Email/IsAdmin are SENTINELS — if resolve-key ever discloses a principal,
// they surface.
type resolveEnv struct {
Status string `json:"status"`
Msg string `json:"msg"`
Data struct {
Org string `json:"org"`
Scope string `json:"scope"`
Owner string `json:"owner"`
Name string `json:"name"`
Email string `json:"email"`
IsAdmin bool `json:"isAdmin"`
} `json:"data"`
}
// pubKeyFixtures seeds the two ingest-resolver apps, a WRITE-ONLY publishable key and a
// SECRET key (both owned by hanzo), and arms IAM_PUBLISHABLE_RESOLVE_APPS with the
// resolver app only.
func pubKeyFixtures(t *testing.T, h *harness) {
t.Helper()
seedClientApp(t, h.db, pubResolverApp, pubSecret)
seedClientApp(t, h.db, pubOtherApp, pubSecret)
// A publishable (write-only) key: Scope=publish, pk- only, no user, owned by hanzo.
pk := orm.New[schema.Key](h.db)
pk.Owner, pk.Name = "hanzo", "site"
pk.Scope = schema.KeyScopePublish
pk.AccessKey = sitePK
pk.SetId("hanzo/site")
if err := pk.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed publish key: %v", err)
}
// A SECRET (default, Scope="") key: pk- + sk-, referencing hanzo/boss (a real user
// the harness seeds). Its pk- half must NEVER resolve via resolve-key.
sk := orm.New[schema.Key](h.db)
sk.Owner, sk.Name, sk.User = "hanzo", "server", "hanzo/boss"
sk.AccessKey, sk.AccessSecret = secretKeyPK, secretKeySK
sk.SetId("hanzo/server")
if err := sk.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed secret key: %v", err)
}
t.Setenv("IAM_PUBLISHABLE_RESOLVE_APPS", pubResolverApp)
}
// A publishable pk- resolves to just the ORG that holds it — org and scope, and NO
// principal field of any kind.
func TestResolveKey_ResolvesOrgOnly(t *testing.T) {
h := newHarness(t)
pubKeyFixtures(t, h)
status, body := h.getBasic(t, "/v1/iam/resolve-key?accessKey="+sitePK, pubResolverApp, pubSecret)
if status != 200 {
t.Fatalf("status=%d body=%s", status, body)
}
var e resolveEnv
if err := json.Unmarshal([]byte(body), &e); err != nil {
t.Fatalf("envelope: %v body=%s", err, body)
}
if e.Status != "ok" || e.Data.Org != "hanzo" || e.Data.Scope != schema.KeyScopePublish {
t.Fatalf("resolve = %+v, want ok org=hanzo scope=publish; body=%s", e, body)
}
// ORG-ONLY: no principal field is populated, and no principal-only key appears in
// the raw body — resolve-key discloses WHICH org, never WHO.
if e.Data.Owner != "" || e.Data.Name != "" || e.Data.Email != "" || e.Data.IsAdmin {
t.Fatalf("resolve-key disclosed a principal: %+v", e.Data)
}
for _, principalKey := range []string{`"email"`, `"isAdmin"`, `"name"`, `"owner"`} {
if strings.Contains(body, principalKey) {
t.Fatalf("resolve-key body carries a principal field %s: %s", principalKey, body)
}
}
}
// A SECRET key's pk- half (Scope != publish) and its sk- half are BOTH refused: the
// door serves only keys explicitly minted as browser keys, and an sk- never matches the
// pk- prefix.
func TestResolveKey_RefusesNonPublishable(t *testing.T) {
h := newHarness(t)
pubKeyFixtures(t, h)
for _, tc := range []struct{ name, key string }{
{"secret key's pk- half", secretKeyPK},
{"an sk- confidential half", secretKeySK},
{"a retired prefix", "hk-live-anything"},
{"unknown pk-", "pk-live-NOSUCH"},
{"empty", ""},
} {
status, body := h.getBasic(t, "/v1/iam/resolve-key?accessKey="+tc.key, pubResolverApp, pubSecret)
var e resolveEnv
_ = json.Unmarshal([]byte(body), &e)
if e.Status != "error" || e.Msg != "the entity does not exist" {
t.Fatalf("%s: status=%d env=%+v, want error 'the entity does not exist'", tc.name, status, e)
}
if e.Data.Org != "" {
t.Fatalf("%s: leaked org %q on a refusal", tc.name, e.Data.Org)
}
}
}
// An expired publishable key is refused — resolve-key honors only a live key.
func TestResolveKey_RefusesExpired(t *testing.T) {
h := newHarness(t)
pubKeyFixtures(t, h)
k := orm.New[schema.Key](h.db)
k.Owner, k.Name = "hanzo", "expired"
k.Scope = schema.KeyScopePublish
k.AccessKey = "pk-live-EXPIRED"
k.ExpireTime = "2020-01-01T00:00:00Z"
k.SetId("hanzo/expired")
if err := k.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed expired key: %v", err)
}
_, body := h.getBasic(t, "/v1/iam/resolve-key?accessKey=pk-live-EXPIRED", pubResolverApp, pubSecret)
var e resolveEnv
_ = json.Unmarshal([]byte(body), &e)
if e.Status != "error" || e.Data.Org != "" {
t.Fatalf("expired key resolved: env=%+v body=%s", e, body)
}
}
// A confidential app WITHOUT CapPublishableResolve is refused — no org disclosed. This
// is the least-privilege gate: holding some capability does not grant this one.
func TestResolveKey_NonCapAppDenied(t *testing.T) {
h := newHarness(t)
pubKeyFixtures(t, h)
status, body := h.getBasic(t, "/v1/iam/resolve-key?accessKey="+sitePK, pubOtherApp, pubSecret)
var e resolveEnv
_ = json.Unmarshal([]byte(body), &e)
if e.Status != "error" || e.Msg != "auth:Unauthorized operation" {
t.Fatalf("non-cap resolve status=%d env=%+v, want error auth:Unauthorized operation", status, e)
}
if e.Data.Org != "" {
t.Fatalf("non-cap caller learned the org: %s", body)
}
}
// A HUMAN — even a SuperAdmin bearer — is refused: key resolution is a machine-identity
// boundary (a capability is vacuous for a non-app), so resolve-key is app-only.
func TestResolveKey_HumanDenied(t *testing.T) {
h := newHarness(t)
pubKeyFixtures(t, h)
status, body := h.get(t, "/v1/iam/resolve-key?accessKey="+sitePK, h.token(t, "admin/root"))
var e resolveEnv
_ = json.Unmarshal([]byte(body), &e)
if e.Status != "error" || e.Msg != "auth:Unauthorized operation" {
t.Fatalf("human (SuperAdmin) resolve status=%d env=%+v, want error auth:Unauthorized operation", status, e)
}
if e.Data.Org != "" {
t.Fatalf("human caller learned the org: %s", body)
}
}
// THE INVARIANT, end to end: the SAME publishable pk- that resolve-key turns into an
// org can NEVER become a principal — not via get-user?accessKey (even for a caller that
// holds CapKeyResolve and CAN resolve secret keys), and not as a bearer to a gated
// route. A public key authenticates no read, anywhere.
func TestResolveKey_PublishableNeverBecomesPrincipal(t *testing.T) {
h := newHarness(t)
pubKeyFixtures(t, h)
// Grant the resolver app BOTH capabilities: it CAN resolve secret keys to principals
// (CapKeyResolve), yet the publishable pk- is still refused there.
t.Setenv("IAM_KEY_RESOLVE_APPS", pubResolverApp)
// Control: resolve-key DOES turn the publishable pk- into an org.
if _, body := h.getBasic(t, "/v1/iam/resolve-key?accessKey="+sitePK, pubResolverApp, pubSecret); !strings.Contains(body, `"org":"hanzo"`) {
t.Fatalf("control: resolve-key should resolve the publishable pk- to an org: %s", body)
}
// Control: get-user?accessKey DOES resolve a SECRET sk- to its principal.
if _, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+secretKeySK, pubResolverApp, pubSecret); !strings.Contains(body, "boss") {
t.Fatalf("control: get-user?accessKey should resolve the secret sk- to its user: %s", body)
}
// The publishable pk- via get-user?accessKey → NO principal (write-only), even for a
// CapKeyResolve holder.
_, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+sitePK, pubResolverApp, pubSecret)
var ke keyEnv
_ = json.Unmarshal([]byte(body), &ke)
if ke.Status != "error" || ke.Msg != "the entity does not exist" {
t.Fatalf("publishable pk- via get-user?accessKey resolved a principal: env=%+v body=%s", ke, body)
}
if strings.Contains(body, "hanzo/") || strings.Contains(body, `"isAdmin"`) {
t.Fatalf("publishable pk- leaked identity via get-user: %s", body)
}
// The publishable pk- presented as a BEARER to a gated route → 401 (never a
// principal — it is not a token, and it can never become one).
status, _ := h.get(t, "/v1/iam/keys?owner=hanzo", sitePK)
if status != 401 {
t.Fatalf("publishable pk- as bearer to a gated route: status=%d, want 401", status)
}
}
+314
View File
@@ -0,0 +1,314 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package compat
import (
"context"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/applications"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/organizations"
"github.com/hanzoai/iam/internal/projects"
"github.com/hanzoai/iam/internal/providers"
"github.com/hanzoai/iam/internal/roles"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/internal/users"
"github.com/hanzoai/iam/internal/workspaces"
)
// The the legacy surface WRITE verbs (add-organization, add-user, update-user,
// update-application) the console admin BFF hard-codes, served over the SAME entity
// Create/Update logic as the REST surface — no CRUD is reimplemented here. Each is a
// TYPED zip op (not a raw handler), which is what preserves authorization: the ONE
// authz seam (app.Authorize) runs at every typed op's invoke on the DECODED input, so
// a write alias is authorized against the exact (owner, name) it will bind — a super
// for a platform-owned org/app, an org-admin for its own users — identical to the REST
// twin. The result is wrapped in the casibase {status,msg,data} envelope the clients
// parse; the data is the REDACTED entity (each Create/Update returns Mask()).
//
// Read verbs ride aliases.go; these are the "Writes ride a companion file" half.
//
// NONE of these carries an explicit operationId, and that is the rule rather than
// an omission. A legacy verb alias delegates to the canonical op; the only thing
// that distinguishes the two IS the address, so the address names it — zip's
// path-derived default (post_v1_iam_update_provider). Naming them by hand
// restated what the path already says AND collided: five of them
// (add/update/delete-provider, update/delete-organization) claimed the same id as
// the REST twin they delegate to, which OpenAPI forbids — one operationId, one
// operation — so every generated client would bind whichever it read last. The
// canonical REST op keeps the hand-picked SDK name; the alias is named for where
// it is.
// routeWrites registers the the legacy surface write-verb aliases on app. Called from Route
// (aliases.go) so reads and writes share the one Guard/Authorize seam.
//
// Each registration's prose is the comment directly above it, and that comment is
// the ONLY place it is written: zipdoc lifts it into zipdoc_gen.go, zip's spec
// derives the summary from its first sentence, and the OpenAPI description, the
// MCP tool and every generated client and CLI carry it from there. A
// WithSummary("…") beside a comment saying the same thing is two places to change
// and one to forget, so there is none — and a maintainer note in that position is
// not a note, it is what a customer reads. Where the sentence had to say
// something to US rather than to them ("console ScopeSwitcher", "the read rides
// aliases.go"), it is here instead:
//
// - Grouping. Reads ride aliases.go; these are the writes. add-/update-/delete-
// for organizations, users, applications, providers and roles; add-/delete-
// only for projects and workspaces, whose reads ride
// get-organization-projects and get-organization-workspaces.
// - Authorization. Every one is a TYPED op, so app.Authorize runs at invoke on
// the DECODED body — the write is authorized against the exact (owner, name)
// it will bind, identically to its REST twin. For a project or workspace the
// owner IS the organization, so the clause is org-admin of that org.
func routeWrites(app *zip.App, db orm.DB) {
orgs := organizations.NewOrganizationAPI(db)
usersAPI := users.New(db)
appCreate, appUpdate, appDelete := applications.Create(db), applications.Update(db), applications.Delete(db)
rolesH := roles.New(db)
projectsH := projects.New(db)
workspacesH := workspaces.New(db)
provAdd, provUpdate, provDelete := providers.Add(db), providers.Update(db), providers.Delete(db)
// Creates an organization — the account everything else in your directory
// hangs from. Users, applications, roles, projects and workspaces are all
// named inside one organization, so this is the first write in a new tenant.
//
// The older spelling of POST /v1/iam/organizations. Both reach the same
// create, so a name already taken is refused here too.
zip.Post(app, "/v1/iam/add-organization",
func(ctx context.Context, in *organizations.CreateOrganizationInput) (*httpx.Response, error) {
return envelope(orgs.Create(ctx, in))
},
zip.WithTags("compat"))
// Adds a person to your organization and, if you send a password, sets the
// one they will sign in with. The password is hashed before it is stored and
// is never returned to you or to anyone else.
//
// Usernames are checked against one rule wherever an account is created —
// this verb, password signup, a social sign-in, or SCIM — so a name accepted
// here is a name accepted everywhere.
//
// The older spelling of POST /v1/iam/users, and it posts the user's fields at
// the top level rather than wrapped in {user, password}.
zip.Post(app, "/v1/iam/add-user",
func(ctx context.Context, in *userBody) (*httpx.Response, error) {
return envelope(usersAPI.Create(ctx, &users.CreateInput{User: in.User, Password: in.Password}))
},
zip.WithTags("compat"))
// Updates one of your users' profile, roles or credentials. Send a password
// to reset it; leave it out and the current one stands.
//
// The older spelling of POST /v1/iam/users/update, with the user's fields at
// the top level rather than wrapped in {user, password}.
zip.Post(app, "/v1/iam/update-user",
func(ctx context.Context, in *userBody) (*httpx.Response, error) {
return envelope(usersAPI.Update(ctx, &users.UpdateInput{User: in.User, Password: in.Password}))
},
zip.WithTags("compat"))
// Updates one of your applications — its display, its sign-in methods and the
// redirect URIs it is allowed to return to. Which organization and name the
// application has are fixed when it is created and are not editable here.
//
// A redirect URI you add becomes an allowed sign-in origin, so this is the
// call that makes login work from a new host.
//
// The older spelling of PUT /v1/iam/application.
zip.Post(app, "/v1/iam/update-application",
func(ctx context.Context, in *schema.Application) (*httpx.Response, error) {
return envelope(appUpdate(ctx, in))
},
zip.WithTags("compat"))
// Removes a person from your organization. Their sessions stop working and
// the account is gone, not suspended — to keep the record and only stop
// sign-in, update the user instead.
//
// The older spelling of POST /v1/iam/users/delete.
zip.Post(app, "/v1/iam/delete-user",
func(ctx context.Context, in *userBody) (*httpx.Response, error) {
return envelope(usersAPI.Delete(ctx, &users.Ref{Owner: in.Owner, Name: in.Name}))
},
zip.WithTags("compat"))
// Registers an application in your organization — one product or site your
// people sign in to, with its own client credentials, sign-in methods and
// allowed redirect URIs.
//
// The older spelling of POST /v1/iam/application. A name already used in the
// organization is refused rather than overwritten.
zip.Post(app, "/v1/iam/add-application",
func(ctx context.Context, in *schema.Application) (*httpx.Response, error) {
return envelope(appCreate(ctx, in))
},
zip.WithTags("compat"))
// Deletes an application. Anyone mid-sign-in through it is turned away and
// its client credentials stop working, so retire the integration first.
//
// The older spelling of DELETE /v1/iam/application.
zip.Post(app, "/v1/iam/delete-application",
func(ctx context.Context, in *schema.Application) (*httpx.Response, error) {
return envelope(appDelete(ctx, &applications.ApplicationRef{Owner: in.Owner, Name: in.Name}))
},
zip.WithTags("compat"))
// Adds an identity provider your people can sign in with, or a service your
// applications send through — a social or enterprise login, an email or SMS
// sender, a storage or payment connector.
//
// A provider is configured once here and then switched on per application, so
// several applications can share one set of credentials.
//
// The older spelling of POST /v1/iam/providers.
zip.Post(app, "/v1/iam/add-provider",
func(ctx context.Context, in *schema.Provider) (*httpx.Response, error) {
return envelope(provAdd(ctx, in))
},
zip.WithTags("compat"))
// Updates a provider's settings or rotates the credentials it holds. The
// change takes effect on the next sign-in through it — sessions already
// issued are unaffected.
//
// The older spelling of POST /v1/iam/providers/update.
zip.Post(app, "/v1/iam/update-provider",
func(ctx context.Context, in *schema.Provider) (*httpx.Response, error) {
return envelope(provUpdate(ctx, in))
},
zip.WithTags("compat"))
// Removes a provider. Sign-in through it stops for every application that
// used it, so detach those applications first if they have no other method.
//
// The older spelling of POST /v1/iam/providers/delete.
zip.Post(app, "/v1/iam/delete-provider",
func(ctx context.Context, in *schema.Provider) (*httpx.Response, error) {
return envelope(provDelete(ctx, in))
},
zip.WithTags("compat"))
// Creates a role — a named group of people that permissions are granted to.
// Granting to a role rather than to each person is what keeps access correct
// as your team changes: add someone to the role and they inherit everything
// it can do.
//
// The older spelling of POST /v1/iam/roles.
zip.Post(app, "/v1/iam/add-role",
func(ctx context.Context, in *roles.Input) (*httpx.Response, error) {
return envelope(rolesH.Create(ctx, in))
},
zip.WithTags("compat"))
// Updates a role's members or the roles it includes. Access changes for
// everyone in it as soon as the write lands.
//
// The older spelling of POST /v1/iam/roles/update.
zip.Post(app, "/v1/iam/update-role",
func(ctx context.Context, in *roles.Input) (*httpx.Response, error) {
return envelope(rolesH.Update(ctx, in))
},
zip.WithTags("compat"))
// Deletes a role. Everyone in it loses the access it carried; their accounts
// and any other roles they hold are untouched.
//
// The older spelling of POST /v1/iam/roles/delete.
zip.Post(app, "/v1/iam/delete-role",
func(ctx context.Context, in *roles.Ref) (*httpx.Response, error) {
return envelope(rolesH.Delete(ctx, in))
},
zip.WithTags("compat"))
// Creates a project inside your organization — the scope people pick between
// when their work is separated by product or client rather than by team.
//
// The older spelling of POST /v1/iam/projects. Creating one takes an
// administrator of the owning organization.
zip.Post(app, "/v1/iam/add-project",
func(ctx context.Context, in *projects.Input) (*httpx.Response, error) {
return envelope(projectsH.Create(ctx, in))
},
zip.WithTags("compat"))
// Deletes a project. The people and roles in your organization are unchanged;
// what goes is the scope itself, so anything addressed by it must move first.
//
// The older spelling of POST /v1/iam/projects/delete.
zip.Post(app, "/v1/iam/delete-project",
func(ctx context.Context, in *projects.Ref) (*httpx.Response, error) {
return envelope(projectsH.Delete(ctx, in))
},
zip.WithTags("compat"))
// Creates a workspace inside your organization — the scope a team works in,
// alongside projects rather than instead of them.
//
// The older spelling of POST /v1/iam/workspaces. Creating one takes an
// administrator of the owning organization.
zip.Post(app, "/v1/iam/add-workspace",
func(ctx context.Context, in *workspaces.Input) (*httpx.Response, error) {
return envelope(workspacesH.Create(ctx, in))
},
zip.WithTags("compat"))
// Deletes a workspace. The people and roles in your organization are
// unchanged; what goes is the scope itself.
//
// The older spelling of POST /v1/iam/workspaces/delete.
zip.Post(app, "/v1/iam/delete-workspace",
func(ctx context.Context, in *workspaces.Ref) (*httpx.Response, error) {
return envelope(workspacesH.Delete(ctx, in))
},
zip.WithTags("compat"))
// Updates your organization — its display, its default settings and the
// sign-in rules everyone in it inherits.
//
// The older spelling of POST /v1/iam/organizations/update.
zip.Post(app, "/v1/iam/update-organization",
func(ctx context.Context, in *organizations.UpdateOrganizationInput) (*httpx.Response, error) {
return envelope(orgs.Update(ctx, in))
},
zip.WithTags("compat"))
// Deletes an organization and everything named inside it — its users,
// applications, roles, projects and workspaces. There is no undo, and every
// session issued under it stops working.
//
// The older spelling of POST /v1/iam/organizations/delete.
zip.Post(app, "/v1/iam/delete-organization",
func(ctx context.Context, in *organizations.DeleteOrganizationInput) (*httpx.Response, error) {
return envelope(orgs.Delete(ctx, in))
},
zip.WithTags("compat"))
}
// userBody is the bare-user body the the legacy surface add-user/update-user verbs post (the
// user's fields at top level, plus an optional plaintext password), distinct from the
// REST twin's {user,password} envelope. It embeds schema.User so the authz op-seam
// reads the target (Owner, Name) straight off it, then the handler hands the parts to
// the ONE users Create/Update path (which bcrypt-hashes the password — never stored
// plaintext — and returns the redacted row).
type userBody struct {
schema.User
Password string `json:"password,omitempty"`
}
// envelope wraps an entity Create/Update result in the casibase Response the the legacy surface
// clients parse: {status:"ok", data:<masked entity>} on success, or a 200
// {status:"error", msg} on a handler error (the casibase convention — clients branch
// on status, not the HTTP code), never an HTTP error status. An authorization refusal
// happens earlier, at the op-seam, and surfaces as a 403 the clients already handle.
func envelope[T any](entity *T, err error) (*httpx.Response, error) {
if err != nil {
return &httpx.Response{Status: "error", Msg: err.Error()}, nil
}
return &httpx.Response{Status: "ok", Data: entity}, nil
}
+247
View File
@@ -0,0 +1,247 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package compat_test
// End-to-end tests for the the legacy surface WRITE verbs + the structurally-public front
// door, driven through the REAL registered router (routes.Route installs the authz
// Guard + Authorize seam; the front door is registered on the pre-Guard public
// group). They assert the three write contracts a backend swap depends on:
// the {status,ok} envelope every client parses, authorization identical to the REST
// twin (super for platform-owned org/app; org-admin for its own users; cross-tenant
// refused), and that no secret ever surfaces. Plus: the front-door session routes are
// reachable WITHOUT a bearer (the portal/admin-guard call them with a cookie).
import (
"bytes"
"encoding/json"
"io"
"net/http/httptest"
"strings"
"testing"
"github.com/hanzoai/iam/internal/testhttp"
)
// post issues a JSON POST through the real router and returns (status, rawBody).
func (h *harness) post(t *testing.T, path, bearer string, body any) (int, string) {
t.Helper()
b, _ := json.Marshal(body)
req := httptest.NewRequest("POST", path, bytes.NewReader(b))
req.Host = "hanzo.id"
req.Header.Set("Content-Type", "application/json")
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := testhttp.Do(h.app, req)
if err != nil {
t.Fatalf("POST %s: %v", path, err)
}
b2, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return resp.StatusCode, string(b2)
}
// okEnvelope decodes a body and asserts status=="ok".
func okEnvelope(t *testing.T, status int, body string) {
t.Helper()
if status != 200 {
t.Fatalf("status = %d, want 200; body=%s", status, body)
}
var m map[string]any
if err := json.Unmarshal([]byte(body), &m); err != nil {
t.Fatalf("not the v1 envelope: %v; body=%s", err, body)
}
if m["status"] != "ok" {
t.Fatalf("status field = %v, want ok; body=%s", m["status"], body)
}
}
// add-organization is a platform-owned write — only a SuperAdmin may create one,
// through the SAME organizations.Create the REST route uses. The created row is then
// readable via the get-organization read alias.
func TestAddOrganization_super(t *testing.T) {
h := newHarness(t)
status, body := h.post(t, "/v1/iam/add-organization", h.token(t, "admin/root"),
map[string]any{"owner": "admin", "name": "acme", "displayName": "Acme"})
okEnvelope(t, status, body)
assertNoSecretLeak(t, body)
// It hit the real store — the org is now readable through the get alias.
if s, rb := h.get(t, "/v1/iam/get-organization?id=admin/acme", h.token(t, "admin/root")); s != 200 || !strings.Contains(rb, "acme") {
t.Fatalf("created org not readable: status=%d body=%s", s, rb)
}
}
// A non-super is refused at the ONE authz seam (platform-owned resource → super-only),
// exactly as the REST twin is.
func TestAddOrganization_nonSuperForbidden(t *testing.T) {
h := newHarness(t)
status, _ := h.post(t, "/v1/iam/add-organization", h.token(t, "hanzo/boss"),
map[string]any{"owner": "admin", "name": "acme"})
if status != 403 {
t.Fatalf("org-admin add-organization status = %d, want 403", status)
}
}
// add-user: an org-admin creates a user in its OWN org through users.Create — the
// password is bcrypt-hashed (never returned/stored plaintext) and the row comes back
// redacted.
func TestAddUser_orgAdmin(t *testing.T) {
h := newHarness(t)
status, body := h.post(t, "/v1/iam/add-user", h.token(t, "hanzo/boss"),
map[string]any{"owner": "hanzo", "name": "newbie", "password": "S3cret-pw!"})
okEnvelope(t, status, body)
if strings.Contains(body, "S3cret-pw!") {
t.Fatalf("add-user echoed the plaintext password: %s", body)
}
assertNoSecretLeak(t, body)
// Readable through the get alias (same store).
if s, rb := h.get(t, "/v1/iam/get-user?id=hanzo/newbie", h.token(t, "admin/root")); s != 200 || !strings.Contains(rb, "newbie") {
t.Fatalf("created user not readable: status=%d body=%s", s, rb)
}
}
// A cross-tenant create is refused: hanzo's admin cannot add a user under orgb.
func TestAddUser_crossTenantForbidden(t *testing.T) {
h := newHarness(t)
status, _ := h.post(t, "/v1/iam/add-user", h.token(t, "hanzo/boss"),
map[string]any{"owner": "orgb", "name": "intruder", "password": "x"})
if status != 403 {
t.Fatalf("cross-tenant add-user status = %d, want 403", status)
}
}
// update-user overwrites from the body (legacy semantics) through users.Update; the
// change is visible via the get alias and no secret leaks.
func TestUpdateUser_super(t *testing.T) {
h := newHarness(t)
status, body := h.post(t, "/v1/iam/update-user", h.token(t, "admin/root"),
map[string]any{"owner": "hanzo", "name": "alice", "displayName": "Alice Updated"})
okEnvelope(t, status, body)
assertNoSecretLeak(t, body)
if s, rb := h.get(t, "/v1/iam/get-user?id=hanzo/alice", h.token(t, "admin/root")); s != 200 || !strings.Contains(rb, "Alice Updated") {
t.Fatalf("update-user not applied: status=%d body=%s", s, rb)
}
}
// update-application is platform-owned → super-only, through applications.Update.
func TestUpdateApplication_super(t *testing.T) {
h := newHarness(t)
status, body := h.post(t, "/v1/iam/update-application", h.token(t, "admin/root"),
map[string]any{"owner": "admin", "name": "hanzo-console", "displayName": "Console"})
okEnvelope(t, status, body)
assertNoSecretLeak(t, body)
}
// The write verbs are gated — no bearer fails closed at the Guard (they are
// registered after it).
func TestWriteAliases_requireAuth(t *testing.T) {
h := newHarness(t)
if status, _ := h.post(t, "/v1/iam/add-user", "", map[string]any{"owner": "hanzo", "name": "x"}); status != 401 {
t.Fatalf("unauthenticated add-user status = %d, want 401", status)
}
}
// The FRONT-DOOR session routes are structurally PUBLIC — registered on the
// pre-Guard group, so reachable WITHOUT a bearer (the portal + gateway admin-guard
// call them with a session cookie). What proves that is the HANDLER's own envelope
// coming back: the Guard refuses before any handler runs and answers its own
// shape, so a body carrying {"status":"error"} is evidence the request got past
// it. The STATUS is a separate fact, and these routes differ honestly:
//
// - whoami / get-account ASK a question ("who am I?"), and "nobody" is a
// complete answer — 200.
// - linked-accounts asks for a RESOURCE that requires an identity, so an
// anonymous caller is refused — a 4xx carrying CodeLoginRequired, which is
// the machine-readable "sign in" (see internal/httpx on why not 401).
//
// Neither leaks, and neither is the Guard's blanket refusal.
func TestFrontDoorPublic_ReachableWithoutBearer(t *testing.T) {
h := newHarness(t)
for _, tc := range []struct {
method, path string
want int
}{
{"GET", "/v1/iam/get-account", 200},
{"GET", "/v1/iam/whoami", 200},
{"GET", "/v1/iam/linked-accounts", 400},
} {
status, body := h.get(t, tc.path, "")
// Past the Guard: the handler's own envelope, not the Guard's shape.
if !strings.Contains(body, `"status":"error"`) {
t.Fatalf("anonymous %s %s must reach the handler and return its error envelope; status=%d body=%s",
tc.method, tc.path, status, body)
}
if status != tc.want {
t.Fatalf("%s %s without a bearer status=%d, want %d; body=%s", tc.method, tc.path, status, tc.want, body)
}
}
// signin (a POST) is public too: it REACHES its handler and is refused on the
// merits ("code is required"), which is a 4xx — not the Guard's blanket 401.
if status, body := h.post(t, "/v1/iam/signin", "", map[string]any{}); status != 400 || !strings.Contains(body, `"status":"error"`) {
t.Fatalf("anonymous signin status=%d body=%s, want 400 + the handler's envelope (public)", status, body)
}
}
// --- C2 parity write-verb aliases (the console admin mutations) ---
// delete-user: a full lifecycle through the legacy verb (add → delete → gone).
func TestDeleteUser_lifecycle(t *testing.T) {
h := newHarness(t)
root := h.token(t, "admin/root")
if s, b := h.post(t, "/v1/iam/add-user", root, map[string]any{"owner": "hanzo", "name": "tmp", "password": "x"}); s != 200 {
t.Fatalf("add-user status=%d body=%s", s, b)
}
h.postAssertOK(t, "/v1/iam/delete-user", root, map[string]any{"owner": "hanzo", "name": "tmp"})
if s, rb := h.get(t, "/v1/iam/get-user?id=hanzo/tmp", root); s == 200 && strings.Contains(rb, "\"name\":\"tmp\"") {
t.Fatalf("user still present after delete-user: %s", rb)
}
}
// add-provider is platform-owned — only a SuperAdmin creates one, over the SAME
// providers.Add the REST route uses.
func TestAddProvider_super(t *testing.T) {
h := newHarness(t)
root := h.token(t, "admin/root")
h.postAssertOK(t, "/v1/iam/add-provider", root,
map[string]any{"owner": "admin", "name": "provider-test", "category": "OAuth", "type": "GitHub"})
if s, rb := h.get(t, "/v1/iam/get-provider?id=admin/provider-test", root); s != 200 || !strings.Contains(rb, "provider-test") {
t.Fatalf("get-provider after add: status=%d body=%s", s, rb)
}
}
// add-provider by a non-super is refused (platform-owned write).
func TestAddProvider_nonSuperForbidden(t *testing.T) {
h := newHarness(t)
s, _ := h.post(t, "/v1/iam/add-provider", h.token(t, "hanzo/boss"),
map[string]any{"owner": "admin", "name": "evil", "category": "OAuth", "type": "GitHub"})
if s != 403 {
t.Fatalf("non-super add-provider status=%d, want 403", s)
}
}
// add-role is tenant-owned — an org-admin creates one in its OWN org.
func TestAddRole_orgAdmin(t *testing.T) {
h := newHarness(t)
boss := h.token(t, "hanzo/boss")
h.postAssertOK(t, "/v1/iam/add-role", boss,
map[string]any{"owner": "hanzo", "name": "editors", "displayName": "Editors"})
if s, rb := h.get(t, "/v1/iam/get-role?id=hanzo/editors", boss); s != 200 || !strings.Contains(rb, "editors") {
t.Fatalf("get-role after add: status=%d body=%s", s, rb)
}
}
// update-organization is platform-owned — SuperAdmin only.
func TestUpdateOrganization_super(t *testing.T) {
h := newHarness(t)
root := h.token(t, "admin/root")
h.postAssertOK(t, "/v1/iam/update-organization", root,
map[string]any{"owner": "admin", "name": "hanzo", "displayName": "Hanzo Updated"})
}
// postAssertOK posts and asserts the {status:ok} envelope.
func (h *harness) postAssertOK(t *testing.T, path, bearer string, body any) {
s, b := h.post(t, path, bearer, body)
okEnvelope(t, s, b)
}
+276
View File
@@ -0,0 +1,276 @@
// Code generated by zipdoc; DO NOT EDIT.
package compat
import (
"github.com/zap-proto/zip"
)
func init() {
zip.Describe("GET /v1/iam/get-application", zip.Doc{
Description: "Reads one record — the older spelling of the single reads on the\nREST surface, over the same data and the same permissions.\n\nSecrets are stripped. Naming a record in another organization does not reach\nit, however the request spells it.",
})
zip.Describe("GET /v1/iam/get-applications", zip.Doc{
Description: "Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
})
zip.Describe("GET /v1/iam/get-cert", zip.Doc{
Description: "Reads one record — the older spelling of the single reads on the\nREST surface, over the same data and the same permissions.\n\nSecrets are stripped. Naming a record in another organization does not reach\nit, however the request spells it.",
})
zip.Describe("GET /v1/iam/get-certs", zip.Doc{
Description: "Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
})
zip.Describe("GET /v1/iam/get-global-users", zip.Doc{
Description: "Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
})
zip.Describe("GET /v1/iam/get-invitations", zip.Doc{
Description: "Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
})
zip.Describe("GET /v1/iam/get-organization", zip.Doc{
Description: "Reads one record — the older spelling of the single reads on the\nREST surface, over the same data and the same permissions.\n\nSecrets are stripped. Naming a record in another organization does not reach\nit, however the request spells it.",
})
zip.Describe("GET /v1/iam/get-organization-projects", zip.Doc{
Description: "Returns one organization's projects — what a scope switcher\nlists so somebody can move between them.\n\nYou see your own organization and no other, whatever the request asks for.",
})
zip.Describe("GET /v1/iam/get-organization-workspaces", zip.Doc{
Description: "Returns one organization's workspaces — what a scope\nswitcher lists so somebody can move between them.\n\nYou see your own organization and no other, whatever the request asks for.",
})
zip.Describe("GET /v1/iam/get-organizations", zip.Doc{
Description: "Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
})
zip.Describe("GET /v1/iam/get-permission", zip.Doc{
Description: "Reads one record — the older spelling of the single reads on the\nREST surface, over the same data and the same permissions.\n\nSecrets are stripped. Naming a record in another organization does not reach\nit, however the request spells it.",
})
zip.Describe("GET /v1/iam/get-permissions", zip.Doc{
Description: "Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
})
zip.Describe("GET /v1/iam/get-provider", zip.Doc{
Description: "Reads one record — the older spelling of the single reads on the\nREST surface, over the same data and the same permissions.\n\nSecrets are stripped. Naming a record in another organization does not reach\nit, however the request spells it.",
})
zip.Describe("GET /v1/iam/get-providers", zip.Doc{
Description: "Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
})
zip.Describe("GET /v1/iam/get-records", zip.Doc{
Description: "Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
})
zip.Describe("GET /v1/iam/get-role", zip.Doc{
Description: "Reads one record — the older spelling of the single reads on the\nREST surface, over the same data and the same permissions.\n\nSecrets are stripped. Naming a record in another organization does not reach\nit, however the request spells it.",
})
zip.Describe("GET /v1/iam/get-roles", zip.Doc{
Description: "Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
})
zip.Describe("GET /v1/iam/get-user", zip.Doc{
Description: "Reads one person, two ways.\n\nName them and it is an ordinary read, with secrets stripped. Or hand it a\nSECRET API key and it answers with the person that key belongs to — how a\nservice of yours turns a credential on an incoming request into an identity.\n\nA publishable key resolves to nobody here, deliberately: it is safe to ship in\na browser precisely because it names an organization and never a person.\n\nget-user is handler-authorized (authz.handlerAuthorizedExact) because the key\nvariant carries no owner/name for the Guard to authorize; so the owner/name\nvariant reinstates the SAME read authorization the Guard applies, through the ONE\npolicy function (authz.Can) — identical behavior, a cross-tenant or non-self read\nstill refused 403 — then reuses the generic getHandler verbatim for resolution and\nredaction. No authz and no CRUD is reimplemented.",
})
zip.Describe("GET /v1/iam/get-users", zip.Doc{
Description: "Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
})
zip.Describe("GET /v1/iam/resolve-key", zip.Doc{
Description: "Answers which organization a PUBLISHABLE key belongs to —\nwhat a service of yours calls to attribute a request that arrived carrying a\nkey shipped in a browser.\n\nIt names an organization and never a person: no path through it can load or\nreturn a user, so a key you put in client code cannot become a way to learn\nwho anyone is. A key that is expired, secret rather than publishable, or\nsimply unknown all answer with the same sentence, and with a `code` saying\nwhich of those it was. Only a confidential service that already proved it may\nresolve keys at all ever reads that code — there is no anonymous caller here\nto probe for which keys exist — and telling it apart is what lets the holder\nbe told to re-mint an expired key instead of hunting a configuration error.",
})
zip.Describe("POST /v1/iam/add-application", zip.Doc{
Description: "Registers an application in your organization — one product or site your\npeople sign in to, with its own client credentials, sign-in methods and\nallowed redirect URIs.\n\nThe older spelling of POST /v1/iam/application. A name already used in the\norganization is refused rather than overwritten.",
Fields: map[string]string{
"Application.clientId": "ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
"Model[github.com/hanzoai/iam/pkg/schema.Application].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/add-organization", zip.Doc{
Description: "Creates an organization — the account everything else in your directory\nhangs from. Users, applications, roles, projects and workspaces are all\nnamed inside one organization, so this is the first write in a new tenant.\n\nThe older spelling of POST /v1/iam/organizations. Both reach the same\ncreate, so a name already taken is refused here too.",
Fields: map[string]string{
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/add-project", zip.Doc{
Description: "Creates a project inside your organization — the scope people pick between\nwhen their work is separated by product or client rather than by team.\n\nThe older spelling of POST /v1/iam/projects. Creating one takes an\nadministrator of the owning organization.",
Fields: map[string]string{
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/add-provider", zip.Doc{
Description: "Adds an identity provider your people can sign in with, or a service your\napplications send through — a social or enterprise login, an email or SMS\nsender, a storage or payment connector.\n\nA provider is configured once here and then switched on per application, so\nseveral applications can share one set of credentials.\n\nThe older spelling of POST /v1/iam/providers.",
Fields: map[string]string{
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/add-role", zip.Doc{
Description: "Creates a role — a named group of people that permissions are granted to.\nGranting to a role rather than to each person is what keeps access correct\nas your team changes: add someone to the role and they inherit everything\nit can do.\n\nThe older spelling of POST /v1/iam/roles.",
Fields: map[string]string{
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/add-user", zip.Doc{
Description: "Adds a person to your organization and, if you send a password, sets the\none they will sign in with. The password is hashed before it is stored and\nis never returned to you or to anyone else.\n\nUsernames are checked against one rule wherever an account is created —\nthis verb, password signup, a social sign-in, or SCIM — so a name accepted\nhere is a name accepted everywhere.\n\nThe older spelling of POST /v1/iam/users, and it posts the user's fields at\nthe top level rather than wrapped in {user, password}.",
Fields: map[string]string{
"Model[github.com/hanzoai/iam/pkg/schema.Permission].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Role].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.User].id": "Persisted fields",
"Permission.createdTime": "Descriptive metadata.",
"Permission.model": "Authorization model, targets, and decision. AuthzModel carries the v1\n`model` column (the named authz model); it is not the Go identifier\n`Model` because that name is taken by the embedded orm.Model[Permission]\nmixin. The HTTP contract is unchanged — json:\"model\".",
"Permission.owner": "Identity — the (owner, name) natural key.",
"Permission.submitter": "Submission / approval workflow.",
"Permission.users": "Subjects the grant is evaluated for.",
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
"User.accessKey": "API credentials. AccessSecret / AccessSecretHash / the OAuth tokens are\nbearer material. AccessSecretHash MUST persist (orm stores via JSON; a\njson:\"-\" field is never saved), so it carries a real json tag and the\nhandler's redact() strips it (and AccessSecret + the token fields) before\nresponding.",
"User.balance": "Balance mirrors v1 for lossless migration but is authoritative in\nCommerce (billing.hanzo.ai), not here — do not write it from IAM.",
"User.createdIp": "Sign-in provenance.",
"User.displayName": "Profile.",
"User.github": "Linked federated-identity subjects, one column per connector (v1 parity).",
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
"User.isDefaultAvatar": "State flags.",
"User.owner": "Identity / tenancy. (Owner, Name) is the natural key.",
"User.passwordHash": "Credential material. PasswordHash is a one-way bcrypt digest and is\nverify-only. It MUST be persisted (orm serializes the entity to its JSON\ndata column, so a json:\"-\" field would never be stored — that silently\nbroke login), so it carries a real json tag; the users API redact() strips\nit (and every other secret) from every response. PasswordType and\nPasswordSalt describe the digest scheme so rows hashed under the legacy\nargon2id scheme can still be verified and lazily re-hashed to bcrypt.",
"User.roles": "Authorization attachments. Roles and Permissions are computed on read\nfrom the authz store and carried here for API parity with v1.",
"User.webauthnCredentials": "Multi-factor authentication. TotpSecret and RecoveryCodes are secret\nverify-only material — the handler strips them from every response.\nWebauthnCredentials is carried as raw JSON here for lossless migration;\nthe typed passkey model is the sibling WebauthnCredential entity.",
},
})
zip.Describe("POST /v1/iam/add-workspace", zip.Doc{
Description: "Creates a workspace inside your organization — the scope a team works in,\nalongside projects rather than instead of them.\n\nThe older spelling of POST /v1/iam/workspaces. Creating one takes an\nadministrator of the owning organization.",
Fields: map[string]string{
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/delete-application", zip.Doc{
Description: "Deletes an application. Anyone mid-sign-in through it is turned away and\nits client credentials stop working, so retire the integration first.\n\nThe older spelling of DELETE /v1/iam/application.",
Fields: map[string]string{
"Application.clientId": "ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
"Model[github.com/hanzoai/iam/pkg/schema.Application].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/delete-organization", zip.Doc{
Description: "Deletes an organization and everything named inside it — its users,\napplications, roles, projects and workspaces. There is no undo, and every\nsession issued under it stops working.\n\nThe older spelling of POST /v1/iam/organizations/delete.",
Fields: map[string]string{
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/delete-project", zip.Doc{
Description: "Deletes a project. The people and roles in your organization are unchanged;\nwhat goes is the scope itself, so anything addressed by it must move first.\n\nThe older spelling of POST /v1/iam/projects/delete.",
Fields: map[string]string{
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/delete-provider", zip.Doc{
Description: "Removes a provider. Sign-in through it stops for every application that\nused it, so detach those applications first if they have no other method.\n\nThe older spelling of POST /v1/iam/providers/delete.",
Fields: map[string]string{
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/delete-role", zip.Doc{
Description: "Deletes a role. Everyone in it loses the access it carried; their accounts\nand any other roles they hold are untouched.\n\nThe older spelling of POST /v1/iam/roles/delete.",
Fields: map[string]string{
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/delete-user", zip.Doc{
Description: "Removes a person from your organization. Their sessions stop working and\nthe account is gone, not suspended — to keep the record and only stop\nsign-in, update the user instead.\n\nThe older spelling of POST /v1/iam/users/delete.",
Fields: map[string]string{
"Model[github.com/hanzoai/iam/pkg/schema.Permission].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Role].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.User].id": "Persisted fields",
"Permission.createdTime": "Descriptive metadata.",
"Permission.model": "Authorization model, targets, and decision. AuthzModel carries the v1\n`model` column (the named authz model); it is not the Go identifier\n`Model` because that name is taken by the embedded orm.Model[Permission]\nmixin. The HTTP contract is unchanged — json:\"model\".",
"Permission.owner": "Identity — the (owner, name) natural key.",
"Permission.submitter": "Submission / approval workflow.",
"Permission.users": "Subjects the grant is evaluated for.",
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
"User.accessKey": "API credentials. AccessSecret / AccessSecretHash / the OAuth tokens are\nbearer material. AccessSecretHash MUST persist (orm stores via JSON; a\njson:\"-\" field is never saved), so it carries a real json tag and the\nhandler's redact() strips it (and AccessSecret + the token fields) before\nresponding.",
"User.balance": "Balance mirrors v1 for lossless migration but is authoritative in\nCommerce (billing.hanzo.ai), not here — do not write it from IAM.",
"User.createdIp": "Sign-in provenance.",
"User.displayName": "Profile.",
"User.github": "Linked federated-identity subjects, one column per connector (v1 parity).",
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
"User.isDefaultAvatar": "State flags.",
"User.owner": "Identity / tenancy. (Owner, Name) is the natural key.",
"User.passwordHash": "Credential material. PasswordHash is a one-way bcrypt digest and is\nverify-only. It MUST be persisted (orm serializes the entity to its JSON\ndata column, so a json:\"-\" field would never be stored — that silently\nbroke login), so it carries a real json tag; the users API redact() strips\nit (and every other secret) from every response. PasswordType and\nPasswordSalt describe the digest scheme so rows hashed under the legacy\nargon2id scheme can still be verified and lazily re-hashed to bcrypt.",
"User.roles": "Authorization attachments. Roles and Permissions are computed on read\nfrom the authz store and carried here for API parity with v1.",
"User.webauthnCredentials": "Multi-factor authentication. TotpSecret and RecoveryCodes are secret\nverify-only material — the handler strips them from every response.\nWebauthnCredentials is carried as raw JSON here for lossless migration;\nthe typed passkey model is the sibling WebauthnCredential entity.",
},
})
zip.Describe("POST /v1/iam/delete-workspace", zip.Doc{
Description: "Deletes a workspace. The people and roles in your organization are\nunchanged; what goes is the scope itself.\n\nThe older spelling of POST /v1/iam/workspaces/delete.",
Fields: map[string]string{
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/update-application", zip.Doc{
Description: "Updates one of your applications — its display, its sign-in methods and the\nredirect URIs it is allowed to return to. Which organization and name the\napplication has are fixed when it is created and are not editable here.\n\nA redirect URI you add becomes an allowed sign-in origin, so this is the\ncall that makes login work from a new host.\n\nThe older spelling of PUT /v1/iam/application.",
Fields: map[string]string{
"Application.clientId": "ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
"Model[github.com/hanzoai/iam/pkg/schema.Application].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/update-organization", zip.Doc{
Description: "Updates your organization — its display, its default settings and the\nsign-in rules everyone in it inherits.\n\nThe older spelling of POST /v1/iam/organizations/update.",
Fields: map[string]string{
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/update-provider", zip.Doc{
Description: "Updates a provider's settings or rotates the credentials it holds. The\nchange takes effect on the next sign-in through it — sessions already\nissued are unaffected.\n\nThe older spelling of POST /v1/iam/providers/update.",
Fields: map[string]string{
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/update-role", zip.Doc{
Description: "Updates a role's members or the roles it includes. Access changes for\neveryone in it as soon as the write lands.\n\nThe older spelling of POST /v1/iam/roles/update.",
Fields: map[string]string{
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/update-user", zip.Doc{
Description: "Updates one of your users' profile, roles or credentials. Send a password\nto reset it; leave it out and the current one stands.\n\nThe older spelling of POST /v1/iam/users/update, with the user's fields at\nthe top level rather than wrapped in {user, password}.",
Fields: map[string]string{
"Model[github.com/hanzoai/iam/pkg/schema.Permission].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.Role].id": "Persisted fields",
"Model[github.com/hanzoai/iam/pkg/schema.User].id": "Persisted fields",
"Permission.createdTime": "Descriptive metadata.",
"Permission.model": "Authorization model, targets, and decision. AuthzModel carries the v1\n`model` column (the named authz model); it is not the Go identifier\n`Model` because that name is taken by the embedded orm.Model[Permission]\nmixin. The HTTP contract is unchanged — json:\"model\".",
"Permission.owner": "Identity — the (owner, name) natural key.",
"Permission.submitter": "Submission / approval workflow.",
"Permission.users": "Subjects the grant is evaluated for.",
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
"User.accessKey": "API credentials. AccessSecret / AccessSecretHash / the OAuth tokens are\nbearer material. AccessSecretHash MUST persist (orm stores via JSON; a\njson:\"-\" field is never saved), so it carries a real json tag and the\nhandler's redact() strips it (and AccessSecret + the token fields) before\nresponding.",
"User.balance": "Balance mirrors v1 for lossless migration but is authoritative in\nCommerce (billing.hanzo.ai), not here — do not write it from IAM.",
"User.createdIp": "Sign-in provenance.",
"User.displayName": "Profile.",
"User.github": "Linked federated-identity subjects, one column per connector (v1 parity).",
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
"User.isDefaultAvatar": "State flags.",
"User.owner": "Identity / tenancy. (Owner, Name) is the natural key.",
"User.passwordHash": "Credential material. PasswordHash is a one-way bcrypt digest and is\nverify-only. It MUST be persisted (orm serializes the entity to its JSON\ndata column, so a json:\"-\" field would never be stored — that silently\nbroke login), so it carries a real json tag; the users API redact() strips\nit (and every other secret) from every response. PasswordType and\nPasswordSalt describe the digest scheme so rows hashed under the legacy\nargon2id scheme can still be verified and lazily re-hashed to bcrypt.",
"User.roles": "Authorization attachments. Roles and Permissions are computed on read\nfrom the authz store and carried here for API parity with v1.",
"User.webauthnCredentials": "Multi-factor authentication. TotpSecret and RecoveryCodes are secret\nverify-only material — the handler strips them from every response.\nWebauthnCredentials is carried as raw JSON here for lossless migration;\nthe typed passkey model is the sibling WebauthnCredential entity.",
},
})
}
+442
View File
@@ -0,0 +1,442 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
// Package cors lets a registered browser client complete OIDC against this
// IdP from its own origin, and lets a first-party console sign a user in and
// out from its own.
//
// A public (PKCE) client runs the code->token exchange in the BROWSER: the page
// at https://<app-host> fetches https://<idp-host>/v1/iam/oauth/token directly.
// That is cross-origin, so without an Access-Control-Allow-Origin header the
// browser blocks the response and the user parks forever on the callback with
// "Failed to fetch" — authenticated, holding a valid code, unable to spend it.
//
// Only the endpoints a browser legitimately calls cross-origin are opened.
//
// # Two questions, never one
//
// CORS is asked two different things about an Origin, and answering both from
// one list is a privilege escalation:
//
// 1. May this origin READ the answer? Answered by the DERIVED allowlist: an
// origin is permitted iff some registered application already declares a
// redirect_uri on it. That is the same set OAuth itself trusts to receive an
// authorization code, so this grant can never be looser than the redirect
// allowlist, and there is no second list to keep in sync — provision a host
// and login works from it.
//
// 2. May this origin send the request WITH THE USER'S COOKIE and read what
// comes back? Answered by consoles ∩ [cookie]: an exact origin an OPERATOR
// listed in IAM_SESSION_ORIGINS, on a path marked [cookie] in the table
// below.
//
// The second is strictly narrower and CANNOT be derived from the first. A tenant
// admin may register an application in their OWN organization with a
// redirect_uri on a host they control, which puts that host in the derived set.
// Echoing such an origin is harmless while the answer carries no ambient
// authority — a PKCE exchange proves itself in the body, not in a cookie, and a
// Bearer read proves itself in a header an attacker's page does not have.
//
// # What question 2 actually grants, stated plainly
//
// POST /v1/iam/login answers a code request that carries no credential but a
// live session cookie by MINTING AN AUTHORIZATION CODE — the single-sign-on
// branch in internal/oidc/login.go. So an origin on this list can, from a page a
// signed-in user merely visits, mint a code for that user and spend it. That is
// account takeover, not a disclosure. Every entry on the list is that powerful,
// which is why it is exact origins, short, and an operator's deliberate act.
//
// A SUFFIX is the wrong shape for it, even though a brand-suffix config already
// exists elsewhere in the fleet (IAM_TRUSTED_ORIGIN_SUFFIXES): this fleet serves
// *.hanzo.app as customer-published sites, so "hanzo.app" read as a suffix would
// name every customer's published page a first-party console and hand it that
// grant. An entry names the console, not the domain the console sits under.
//
// An origin outside BOTH sets gets no Access-Control-Allow-Origin header at all.
// It is never echoed, and there is no wildcard: `*` with credentials is invalid
// per the Fetch standard, and `*` without them would open every browser path to
// every page on the internet.
//
// # This is not the only answer a browser gets
//
// A reverse proxy in front of this process can append CORS headers of its own,
// and nothing here can undo that: an appended Access-Control-Allow-Origin
// overrides every decision this package makes. This package's job is to answer
// correctly ON ITS OWN, so that such a rule can be narrowed to nothing without
// taking a login down with it.
package cors
import (
"context"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/oidc"
"github.com/hanzoai/iam/internal/wallet"
"github.com/hanzoai/iam/pkg/schema"
)
// credential says which proof a path's caller presents, and therefore whether a
// cross-origin request to it may carry the browser's ambient SSO cookie.
//
// It lives ON the path table rather than in a second set, so the security fact
// sits on the SAME LINE as the path it describes: there is no pair of maps to
// cross-reference, and no way to add a path to one and forget the other.
//
// It is deliberately NOT a bool. The ZERO value has to be the CLOSED one, so a
// lookup that misses answers `absent` rather than the safest-looking of two real
// states — and `if browserPaths[p]` does not compile, so nobody can read a
// three-state fact as a two-state one.
type credential uint8
const (
// absent: not a browser path. The zero value, so a map miss says this.
absent credential = iota
// bearer: the caller proves itself IN the request — a Bearer token, a PKCE
// verifier, a client secret. The ambient cookie adds nothing, so it is not
// allowed, and an attacker's page holds none of those proofs.
bearer
// cookie: a first-party console's request to this path is sent with
// credentials, so the answer must say the credential was allowed or the
// browser discards it and the console breaks.
cookie
)
// browserPaths are the endpoints a browser-side client must reach cross-origin,
// each marked with the proof its caller presents. Everything else stays
// same-origin only — an endpoint no browser client calls has no reason to
// advertise itself to one.
//
// The [cookie] entries are exactly the five sites the shipped SDK
// (hanzoai/js-iam, src/browser.ts) sends `credentials: "include"` to. That is
// the whole criterion, and it is a CLIENT fact rather than a server one: a fetch
// made with credentials is discarded by the browser unless the response carries
// Access-Control-Allow-Credentials, whether or not the handler reads a cookie.
// Withholding the header on one of them withholds no privilege — it breaks the
// call.
//
// The five are named by their ROUTE CONSTANT rather than a literal, because they
// are the powerful ones: a path that drifted out of sync with its route would
// fail open at a proxy and closed here, and the compiler catches that.
var browserPaths = map[string]credential{
"/.well-known/openid-configuration": bearer,
"/v1/iam/.well-known/openid-configuration": bearer,
"/.well-known/oauth-authorization-server": bearer,
"/v1/iam/.well-known/oauth-authorization-server": bearer,
"/.well-known/jwks": bearer,
"/v1/iam/.well-known/jwks": bearer,
"/v1/iam/oauth/token": bearer,
"/v1/iam/oauth/userinfo": bearer,
// The org surface an authenticated SPA reads about ITSELF. A console shows
// "which org am I acting as" and lets the user switch; that answer lives
// here, so without these a registered app either cannot render its own org
// switcher or has to route the read through its own backend — a second copy
// of an identity read, which is how backends end up re-implementing IAM.
//
// Opening a path here does NOT open the data: the Guard still requires a
// verified bearer and authorizes the exact (owner, name) addressed, so a
// caller sees only what its principal could already see. CORS decides which
// ORIGIN may read the answer; authz decides WHO. Same shape as userinfo
// above, which is already open and already Bearer-protected.
//
// get-account is the one that ALSO answers from the SSO cookie, and it stays
// [bearer] deliberately: it is the account object, it is exactly what the
// live proxy defect disclosed, and no console asks for it with credentials.
// A console reads it with the Bearer it already holds.
"/v1/iam/get-organizations": bearer,
"/v1/iam/get-organization": bearer,
"/v1/iam/get-users": bearer,
"/v1/iam/get-account": bearer,
// The two writes a first-party console performs on the user's OWN behalf:
// create an org, invite someone to it. Both are Guard-authorized against the
// caller's principal, so the browser can only do what that user could
// already do. Listed as the NATIVE REST paths, not the legacy verbs — those
// are a compatibility surface for existing backends, not something a new
// browser client should learn.
"/v1/iam/organizations": bearer,
"/v1/iam/invitations": bearer,
// Sign IN with a typed credential. browser.ts credentialLogin (reached by
// loginWithPassword and loginWithCode) posts here with credentials, and the
// single-sign-on branch answers a bare code request from the cookie alone.
// This is the account-takeover grant described in the package comment, and
// it is the reason the list is exact origins.
oidc.PathLogin: cookie,
// Sign in with a WALLET: browser.ts loginWithWallet, the admin-console
// SuperAdmin path. Both legs are sent with credentials. NEITHER handler
// reads the SSO cookie today — the header is required because the SDK asks
// for one, not because the server spends one.
wallet.PathNonce: cookie,
wallet.PathVerify: cookie,
// Sign OUT: revoke the tokens (RFC 7009), then end the session (OIDC
// RP-initiated logout). browser.ts logout() sends both with credentials.
//
// Neither handler reads or clears the SSO cookie either — revoke
// authenticates the CLIENT and deletes a token row, and logout validates a
// signature-verified id_token_hint to decide a redirect. So the SDK's
// comment that credentials are "the difference between ending the session
// and appearing to" describes an intent the server does not implement: the
// portal session outlives an RP-initiated logout. That is a defect in the
// PAIR, and its fix belongs in the handler. Until then this header is only
// what keeps the shipped call from failing.
oidc.PathRevoke: cookie,
oidc.PathLogout: cookie,
}
// env names the operator's list of first-party console origins.
const env = "IAM_SESSION_ORIGINS"
// consoles is a set of exact serialized origins — a value, not a place: built
// once when the middleware is constructed and read by every request goroutine
// without a lock.
type consoles map[string]bool
// has reports membership by EXACT string equality against a canonical origin,
// never a suffix, prefix or pattern. "https://hanzo.ai.evil.com",
// "https://evil-hanzo.ai", "https://HANZO.AI", "https://hanzo.ai." and
// "https://hanzo.ai:8443" are all misses rather than near-hits.
func (c consoles) has(origin string) bool { return c[origin] }
// exact reports whether raw is ALREADY the serialized origin RFC 6454 defines —
// scheme://host[:port] and nothing else.
//
// It is a reconstruct-and-compare, so ONE comparison rejects a path, a query, a
// fragment, userinfo, a trailing slash, an upper-case scheme and (via url.Parse,
// which refuses them outright) any embedded control character. Applied to the
// REQUEST header this is what makes echoing it safe: the only strings that can
// reach the response already equal their own canonical serialization, so there
// is nothing left to smuggle. Applied to CONFIG it is what keeps a bare domain,
// a suffix or a wildcard out of an exact list.
func exact(raw string) bool {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return false
}
if u.Scheme != "https" && u.Scheme != "http" {
return false
}
if raw != u.Scheme+"://"+u.Host {
return false
}
return host(u.Hostname())
}
// host reports whether h is a plain DNS name: letters, digits and hyphens in
// non-empty labels separated by dots.
//
// It is what rejects "*.hanzo.ai" — an operator writing the suffix they MEANT,
// which url.Parse is happy to call a host and which would then sit in the list
// matching nothing, the silent misconfiguration this package exists to refuse.
// It also rejects a TRAILING DOT: "hanzo.ai." resolves the same but is a
// different cookie scope and a different origin, so it is not our console.
func host(h string) bool {
if h == "" || strings.HasPrefix(h, ".") || strings.HasSuffix(h, ".") || strings.Contains(h, "..") {
return false
}
for i := 0; i < len(h); i++ {
switch c := h[i]; {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '.':
default:
return false
}
}
return true
}
// parse reads the comma-separated IAM_SESSION_ORIGINS list. Each entry must be
// an https origin and nothing more.
//
// A malformed entry is an ERROR, not a skip: silently dropping one would deny a
// single brand's console its sign-in while every other brand kept working — the
// failure mode that is hardest to notice and slowest to diagnose. Host case IS
// forgiven, because a browser always lower-cases it and refusing an operator's
// capitalization would fail a boot over nothing.
func parse(list string) (consoles, error) {
out := consoles{}
for _, raw := range strings.Split(list, ",") {
v := strings.TrimSpace(raw)
if v == "" {
continue
}
// Case is forgiven by LOWERCASING, never by re-parsing: rebuilding the
// entry from url.Parse's scheme and host would silently DISCARD a path, a
// query or userinfo and accept an entry the operator got wrong.
v = strings.ToLower(v)
if !strings.HasPrefix(v, "https://") || !exact(v) {
return nil, fmt.Errorf(
"%s: %q is not an https origin: want scheme://host[:port] — an exact "+
"console origin such as https://console.hanzo.ai, never a bare domain, "+
"a suffix or a wildcard", env, raw)
}
out[v] = true
}
return out, nil
}
// registry answers "is this origin registered?" from the application rows,
// cached because the answer changes only when an application does, and the
// alternative is a full scan on every preflight.
type registry struct {
db orm.DB
ttl time.Duration
mu sync.RWMutex
origins map[string]bool
loaded time.Time
}
func (r *registry) allowed(ctx context.Context, origin string) bool {
r.mu.RLock()
fresh := time.Since(r.loaded) < r.ttl && r.origins != nil
if fresh {
ok := r.origins[origin]
r.mu.RUnlock()
// A hit is authoritative. A miss on a fresh cache is only authoritative
// once we know the cache is not stale — it is, so the miss stands.
return ok
}
r.mu.RUnlock()
set := load(ctx, r.db)
if set == nil {
// A storage error must not silently open the IdP to every origin, nor
// permanently close it: keep whatever we had and answer from that.
r.mu.RLock()
defer r.mu.RUnlock()
return r.origins[origin]
}
r.mu.Lock()
r.origins, r.loaded = set, time.Now()
r.mu.Unlock()
return set[origin]
}
// load collects the origin of every registered redirect URI.
func load(ctx context.Context, db orm.DB) map[string]bool {
apps, err := orm.TypedQuery[schema.Application](db).GetAll(ctx)
if err != nil {
return nil
}
set := make(map[string]bool, len(apps)*2)
for _, a := range apps {
if a == nil {
continue
}
for _, raw := range a.RedirectUris {
if o := originOf(raw); o != "" {
set[o] = true
}
}
}
return set
}
// originOf reduces a redirect URI to its serialized origin (scheme://host[:port]),
// which is exactly the form a browser puts in the Origin header. Loopback and
// custom-scheme redirects (cli/desktop clients) have no browser origin and are
// skipped — they never send one.
func originOf(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Host == "" {
return ""
}
if u.Scheme != "https" && u.Scheme != "http" {
return ""
}
return u.Scheme + "://" + u.Host
}
// Allow returns the middleware. It runs before the route table, so it covers
// the public OIDC group without any route needing to know about it.
//
// A malformed IAM_SESSION_ORIGINS PANICS here rather than degrading, and here is
// the ONE place that runs in every deployment: routes.Route calls Allow, and
// both the standalone `iam serve` and the cloud binary that embeds IAM
// (iamserver.Route -> routes.Route) reach it before either opens a listener. A
// gate wired into one main() is a gate the other deployment does not have. Same
// shape, and the same reasoning, as the feature-module registration panic one
// call up in iam/server.
func Allow(db orm.DB) zip.Handler {
listed, err := parse(os.Getenv(env))
if err != nil {
panic("iam/cors: " + err.Error())
}
return allow(db, listed)
}
// allow is Allow over an explicit set — the seam a test drives without the
// environment.
func allow(db orm.DB, listed consoles) zip.Handler {
reg := &registry{db: db, ttl: 60 * time.Second}
return func(c *zip.Ctx) error {
mode := browserPaths[c.Path()]
if mode == absent {
return c.Next()
}
// An Origin header that is empty (same-origin, or a non-browser client) or
// that is not a serialized origin at all — "null", a bare domain, something
// carrying a path, anything padded with whitespace — leaves echo empty, and
// nothing is echoed. The header is read RAW: exact() is a total rule, and a
// trim would be a second one carved out beside it.
echo, credentialed := "", false
if origin := c.Header("Origin"); exact(origin) {
// Question 1 — may it read at all? A console an operator listed is
// first-party and always may; anyone else must have registered.
console := listed.has(origin)
if console || reg.allowed(c.Context(), origin) {
echo = origin
}
// Question 2 — may it spend the user's cookie? Only a listed console,
// and only on a path a console sends credentials to. Answered SEPARATELY
// from question 1: widening what an origin may read must never widen
// what it may spend.
credentialed = console && mode == cookie
}
if echo != "" {
c.SetHeader("Access-Control-Allow-Origin", echo)
if credentialed {
// On the preflight AND on the actual response. A preflight that
// allows credentials and a response that does not is a request the
// browser sends and then refuses to hand to the page.
c.SetHeader("Access-Control-Allow-Credentials", "true")
}
c.SetHeader("Access-Control-Allow-Headers", "Authorization, Content-Type")
c.SetHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
c.SetHeader("Access-Control-Max-Age", "600")
}
if c.Method() == http.MethodOptions {
vary(c)
return c.NoContent(http.StatusNoContent) // preflight ends here
}
err := c.Next()
vary(c)
return err
}
}
// vary appends Origin to the response's Vary header.
//
// AFTER the handler, and by APPENDING. Every answer on a browser path depends on
// Origin — INCLUDING the answer that carries no CORS header at all — so a shared
// cache must never hand one origin the response computed for another. Setting it
// BEFORE the handler loses the race: a handler that sets its own Vary
// (Accept-Encoding, on any negotiated response) REPLACES the header and the
// protection silently disappears. Appending afterwards keeps both, and this
// append is idempotent, so a handler that already varied on Origin does not get
// it twice.
func vary(c *zip.Ctx) { c.Fiber().Vary("Origin") }
+164
View File
@@ -0,0 +1,164 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package cors
import "testing"
// A redirect URI reduces to exactly the string a browser sends in Origin —
// scheme + host + port, nothing else. Getting this wrong means a registered
// app still gets blocked, which is the bug this package exists to fix.
func TestOriginOf_MatchesWhatABrowserSends(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"https://lux.cloud/auth/callback", "https://lux.cloud"},
{"https://console.lux.cloud/auth/callback", "https://console.lux.cloud"},
{"https://lux.cloud:8443/auth/callback", "https://lux.cloud:8443"}, // port is part of the origin
{"https://lux.cloud", "https://lux.cloud"}, // no path
{" https://lux.cloud/auth/callback ", "https://lux.cloud"}, // document whitespace
} {
if got := originOf(tc.in); got != tc.want {
t.Errorf("originOf(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// cli and desktop clients register loopback and custom-scheme redirects. They
// never send an Origin, so they must not widen the allowlist — in particular a
// deep-link scheme must never become an allowed web origin.
func TestOriginOf_SkipsNonBrowserRedirects(t *testing.T) {
for _, in := range []string{
"lux://oauth/desk",
"http://127.0.0.1/callback", // loopback IS http, but see below
"",
"://malformed",
} {
got := originOf(in)
if in == "http://127.0.0.1/callback" {
// Loopback is a real http origin and is returned; it is harmless
// (no site is served there) and keeps a local dev client working.
if got != "http://127.0.0.1" {
t.Errorf("originOf(%q) = %q, want the loopback origin", in, got)
}
continue
}
if got != "" {
t.Errorf("originOf(%q) = %q, want empty", in, got)
}
}
}
// Only endpoints a browser-side client actually calls are opened. Widening this
// set is a security decision, so the set is asserted rather than assumed.
func TestBrowserPaths_ExactlyTheOIDCBrowserSurface(t *testing.T) {
// These MUST be open — the failure that motivated this package was the
// token endpoint and discovery being blocked.
for _, p := range []string{
"/v1/iam/oauth/token",
"/.well-known/openid-configuration",
"/v1/iam/.well-known/jwks",
"/v1/iam/oauth/userinfo",
} {
if browserPaths[p] != bearer {
t.Errorf("%s must be reachable cross-origin, proving itself with a Bearer", p)
}
}
// The sign-in and sign-out surface the shipped SDK calls with credentials.
// It is open AND cookie-bearing, to an exact console origin only.
for _, p := range []string{
"/v1/iam/login",
"/v1/iam/web3/nonce",
"/v1/iam/web3/verify",
"/v1/iam/oauth/revoke",
"/v1/iam/oauth/logout",
} {
if browserPaths[p] != cookie {
t.Errorf("%s must be reachable cross-origin WITH credentials: hanzoai/js-iam "+
"sends it with credentials:\"include\" and a browser discards the answer "+
"unless the credential is allowed", p)
}
}
// These MUST NOT be reachable at all: admin/bootstrap surfaces, and a
// top-level redirect that is never a fetch.
for _, p := range []string{
"/v1/iam/admin/applications/upsert",
"/v1/iam/admin/users/upsert",
"/v1/iam/oauth/authorize", // a top-level redirect, not a fetch
} {
if browserPaths[p] != absent {
t.Errorf("%s must NOT be opened cross-origin", p)
}
}
}
// The zero value of the table is the CLOSED state. A path nobody listed must
// read as `absent`, never as the safest-looking of the two real answers — that
// is what makes a typo in a path fail closed instead of quietly becoming a
// Bearer-readable endpoint.
func TestBrowserPaths_AMissIsClosedNotBearer(t *testing.T) {
for _, p := range []string{"", "/", "/v1/iam/lo gin", "/v1/iam/LOGIN", "/v1/iam/login/"} {
if got := browserPaths[p]; got != absent {
t.Errorf("browserPaths[%q] = %v, want absent — a miss must be closed", p, got)
}
}
}
// The allowlist is derived from application rows; an origin nobody registered
// is not allowed, and one that is registered is.
func TestLoadDerivesTheAllowlistFromRedirectUris(t *testing.T) {
set := map[string]bool{}
for _, u := range []string{
"https://lux.cloud/auth/callback",
"https://www.lux.cloud/auth/callback",
"lux://oauth/desk",
} {
if o := originOf(u); o != "" {
set[o] = true
}
}
if !set["https://lux.cloud"] || !set["https://www.lux.cloud"] {
t.Errorf("registered hosts missing from the allowlist: %v", set)
}
if set["https://evil.example"] {
t.Error("an unregistered origin must never be allowed")
}
if len(set) != 2 {
t.Errorf("deep-link scheme leaked into the web allowlist: %v", set)
}
}
// The org surface a console reads about itself must be reachable cross-origin,
// or a registered SPA cannot render its own org switcher and its backend ends up
// re-implementing an identity read. These sit beside the OIDC endpoints because
// they are the same shape: a Bearer-protected read whose ORIGIN is decided here
// and whose PRINCIPAL is decided by the Guard.
func TestBrowserPaths_CoverTheConsoleOrgSurface(t *testing.T) {
for _, p := range []string{
"/v1/iam/get-organizations",
"/v1/iam/get-organization",
"/v1/iam/get-users",
"/v1/iam/get-account",
} {
if browserPaths[p] != bearer {
t.Errorf("%s must be reachable cross-origin with a Bearer: a console reads it to "+
"show which org the user is acting as, and never with the ambient cookie", p)
}
}
}
// Opening a path to an origin is not the same as opening the data. Anything a
// browser never calls stays closed, so this list can only grow deliberately.
func TestBrowserPaths_StayClosedByDefault(t *testing.T) {
for _, p := range []string{
"/v1/iam/users", // typed CRUD — server-to-server
"/v1/iam/get-certs", // signing material
"/v1/iam/get-providers", // provider secrets
"/v1/iam/delete-user", // a write
"/v1/iam/registry/token", // docker client, not a browser
"/v1/iam/signin", // code->session exchange; a top-level navigation
"/v1/iam/signup", // the SDK posts it same-origin from the IdP's own SPA
} {
if browserPaths[p] != absent {
t.Errorf("%s is open to browsers but nothing browser-side calls it", p)
}
}
}
+572
View File
@@ -0,0 +1,572 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package cors
// The credentialed-CORS gate, driven as HTTP through the real middleware.
//
// The defect these cover: a proxy in front of this IdP answered an arbitrary
// Origin with Access-Control-Allow-Origin PLUS Access-Control-Allow-Credentials,
// on every path — including POST /v1/iam/login, whose single-sign-on branch mints
// an authorization code from the SSO cookie alone. The cookie is host-only and
// SameSite=Lax, so the origins that could actually spend it were the SAME-SITE
// ones: a page on any *.hanzo.ai host reading iam.hanzo.ai. Every case below is a
// request an attacker can actually send, and the whole contract is which headers
// come back.
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/hanzoai/orm"
ormdb "github.com/hanzoai/orm/db"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/pkg/schema"
)
// The one origin an operator listed, and the one a tenant registered. They are
// deliberately different hosts: the whole point of the split is that the second
// never inherits what the first has.
const (
ours = "https://console.hanzo.ai" // IAM_SESSION_ORIGINS — may use the cookie
theirs = "https://theirs.example" // a registered redirect_uri — may read only
hostile = "https://evil.example.com"
readPath = "/v1/iam/get-account" // reads the account: cookie NEVER admitted
)
// signIn and signOut are the five sites hanzoai/js-iam src/browser.ts sends
// `credentials: "include"` to. They are the contract this package answers, so
// the test names them from the CLIENT, not from the server's path table.
var (
signIn = []string{
"/v1/iam/login", // browser.ts credentialLogin (loginWithPassword/loginWithCode)
"/v1/iam/web3/nonce", // browser.ts loginWithWallet, leg 1
"/v1/iam/web3/verify", // browser.ts loginWithWallet, leg 2
}
signOut = []string{
"/v1/iam/oauth/revoke", // browser.ts revoke (RFC 7009)
"/v1/iam/oauth/logout", // browser.ts logout (end_session)
}
credentialed = append(append([]string{}, signIn...), signOut...)
)
// sameSite are origins that are SAME-SITE with the IdP host iam.hanzo.ai, so
// SameSite=Lax does NOT stop the browser attaching the SSO cookie to a request
// they make. Nothing else stops them either — except this package refusing to
// name them. *.hanzo.app is the customer-publishing plane (cloud/apps/projects
// serves <slug>.hanzo.app); *.hanzo.ai is a live wildcard on the same registrable
// domain as the IdP.
var sameSite = []string{
"https://zzz.hanzo.app",
"https://zzz-random-9k2.hanzo.ai",
"https://customer.hanzo.ai",
"https://hanzo.ai",
"https://hanzo.app",
}
// probe drives one request through the middleware and reports the CORS headers
// that came back.
type probe struct {
status int
origin string // Access-Control-Allow-Origin
credentials string // Access-Control-Allow-Credentials
vary string
}
// harness registers the middleware over a store holding ONE tenant-registered
// application, so the derived allowlist is real rather than stubbed.
//
// Its terminal handler sets `Vary: Accept-Encoding` on every path, because that
// is what a real handler does on a negotiated response and it is exactly what a
// Vary written BEFORE the chain would lose.
func harness(t *testing.T, listed consoles) func(method, path, origin string) probe {
t.Helper()
_ = schema.Kinds()
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(t.TempDir(), "cors.db"),
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
a := orm.New[schema.Application](db)
a.Owner, a.Name = "theirs", "theirs-app"
a.RedirectUris = []string{theirs + "/callback"}
a.SetId("theirs/theirs-app")
if err := a.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed application: %v", err)
}
app := zip.New(zip.Config{AppName: "cors-test", DisableStartupMessage: true})
app.Use(allow(db, listed))
terminal := func(c *zip.Ctx) error {
c.SetHeader("Vary", "Accept-Encoding")
return c.String(http.StatusOK, "ok")
}
for p := range browserPaths {
app.Get(p, terminal)
app.Post(p, terminal)
}
return func(method, path, origin string) probe {
t.Helper()
req := httptest.NewRequest(method, path, nil)
if origin != "" {
req.Header.Set("Origin", origin)
}
if method == http.MethodOptions {
req.Header.Set("Access-Control-Request-Method", "POST")
}
res, err := app.Test(req, zip.TestConfig{Timeout: 0, FailOnTimeout: false})
if err != nil {
// The transport refused to parse the header (a control character, say).
// The request never reached the middleware, so nothing was echoed —
// which is the same miss, arrived at one layer earlier.
return probe{status: http.StatusBadRequest}
}
defer res.Body.Close()
return probe{
status: res.StatusCode,
origin: res.Header.Get("Access-Control-Allow-Origin"),
credentials: res.Header.Get("Access-Control-Allow-Credentials"),
vary: res.Header.Get("Vary"),
}
}
}
// every path under test, credentialed and read alike.
func allPaths() []string { return append(append([]string{}, credentialed...), readPath) }
// THE SHIPPED LOGINS. All five sites the SDK sends with credentials must answer
// a listed console with BOTH the echoed origin and the credential allowance, on
// the preflight AND on the actual response. A browser drops a
// credentials:"include" response that lacks either — so a gate written as a pure
// removal signs every console out of every brand.
func TestTheFiveCredentialedSitesKeepWorkingForAListedConsole(t *testing.T) {
do := harness(t, consoles{ours: true})
for _, path := range credentialed {
pre := do(http.MethodOptions, path, ours)
if pre.origin != ours || pre.credentials != "true" {
t.Errorf("preflight %s: allow-origin=%q credentials=%q, want the origin echoed with credentials",
path, pre.origin, pre.credentials)
}
if pre.status != http.StatusNoContent {
t.Errorf("preflight %s: status %d, want 204", path, pre.status)
}
for _, method := range []string{http.MethodGet, http.MethodPost} {
got := do(method, path, ours)
if got.origin != ours || got.credentials != "true" {
t.Errorf("%s %s: allow-origin=%q credentials=%q, want the origin echoed with credentials",
method, path, got.origin, got.credentials)
}
}
}
}
// THE VULNERABILITY, in the shape that was actually reachable. These origins are
// SAME-SITE with the IdP host, so the browser WILL attach the SSO cookie; the
// only thing between them and a signed-in user's account is this middleware
// declining to name them. They must get no Access-Control-Allow-Origin header at
// all — not the origin echoed back, not a wildcard — and above all no credential
// allowance on the login endpoint, which mints an authorization code from that
// cookie.
func TestSameSiteCustomerContentOriginGetsNothing(t *testing.T) {
do := harness(t, consoles{ours: true})
for _, origin := range sameSite {
for _, path := range allPaths() {
for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodOptions} {
got := do(method, path, origin)
if got.origin != "" {
t.Errorf("%s %s from same-site %q echoed Allow-Origin %q — customer-published "+
"content is not a first-party console", method, path, origin, got.origin)
}
if got.credentials != "" {
t.Errorf("%s %s from same-site %q allowed credentials — this is the "+
"account-takeover path", method, path, origin)
}
}
}
}
}
// A hostile CROSS-site origin gets the same nothing. It could not spend the Lax
// cookie even if it were echoed, which is exactly why it must not be echoed: the
// grant must not depend on a cookie attribute a future change could relax.
func TestHostileOriginGetsNoHeaderAtAll(t *testing.T) {
do := harness(t, consoles{ours: true})
for _, path := range allPaths() {
for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodOptions} {
got := do(method, path, hostile)
if got.origin != "" {
t.Errorf("%s %s from a hostile origin echoed Allow-Origin %q", method, path, got.origin)
}
if got.credentials != "" {
t.Errorf("%s %s from a hostile origin allowed credentials", method, path)
}
}
}
}
// attacks are every near-miss of a real console origin an attacker can put in an
// Origin header, plus the parser tricks that turn a sloppy comparison into a
// match. Exact equality admits none of them; a suffix, prefix, contains,
// case-folded or "parse it and compare only the host" check admits at least one.
func attacks() []string {
var out []string
for _, base := range []string{"console.hanzo.ai", "hanzo.ai"} {
out = append(out,
// The brand as a PREFIX of the attacker's own host.
"https://"+base+".evil.com",
"https://"+base+".evil.com:443",
"https://"+base+"-evil.com",
"https://"+base+"%2eevil.com",
// The brand as a SUFFIX of the attacker's own host — no dot boundary.
"https://evil"+base,
"https://evil-"+base,
"https://x"+base,
"https://."+base,
// Case.
"https://"+strings.ToUpper(base),
"https://"+strings.ToUpper(base[:1])+base[1:],
// Trailing dot: resolves the same, different origin and cookie scope.
"https://"+base+".",
"https://"+base+".:443",
// Ports.
"https://"+base+":8443",
"https://"+base+":443",
"https://"+base+":0",
"https://"+base+":",
// Scheme.
"http://"+base,
"HTTPS://"+base,
"Https://"+base,
"ftp://"+base,
"ws://"+base,
"wss://"+base,
"//"+base,
base,
// Not a bare serialized origin any more.
"https://"+base+"/",
"https://"+base+"/callback",
"https://"+base+"?a=b",
"https://"+base+"#f",
"https://user@"+base,
"https://user:pass@"+base,
"https://"+base+"\\@evil.com",
"https://"+base+"\x00",
// Header injection: the transport FOLDS a CRLF into the value rather
// than splitting it, so the smuggled field arrives inside the Origin
// string and only the reconstruct-and-compare stops it being echoed.
"https://"+base+"\r\nX-Injected: 1",
"https://"+base+"\r\n\r\n<script>",
"https://"+base+"%0d%0aX-Injected:%201",
"https://"+base+"\r\nAccess-Control-Allow-Credentials: true",
// Two origins in one header.
"https://"+base+" https://evil.example.com",
"https://"+base+",https://evil.example.com",
"https://evil.example.com,https://"+base,
// Encoded and unicode confusables.
"https://%63onsole.hanzo.ai",
"https://"+base+"",
"https://"+strings.Replace(base, "a", "а", 1), // cyrillic а
// Wildcards an operator might have meant.
"https://*."+base,
"*."+base,
"*",
)
}
return append(out,
"null",
"",
" ",
"undefined",
"file://",
"data:text/html,x",
"https://",
"https://:443",
"https://[::1]",
"https://127.0.0.1",
"https://localhost",
"http://localhost:3000",
"https://hanzo.ai.evil.com",
"https://evil-hanzo.ai",
"https://hanzoai.ai",
"https://hanzo.a",
"https://hanzo.aii",
)
}
// Every attack string, on the most dangerous path there is. None may be echoed
// and none may carry a credential.
func TestParserAttacksAreAllMisses(t *testing.T) {
do := harness(t, consoles{ours: true, "https://hanzo.ai": true})
list := attacks()
if len(list) < 70 {
t.Fatalf("the attack corpus shrank to %d; it is the regression net", len(list))
}
for _, o := range list {
for _, path := range []string{"/v1/iam/login", readPath} {
got := do(http.MethodPost, path, o)
if got.origin != "" || got.credentials != "" {
t.Errorf("%s: origin %q was admitted (allow-origin=%q credentials=%q); it must be a miss",
path, o, got.origin, got.credentials)
}
}
}
}
// WHITESPACE IS THE TRANSPORT'S JOB, NOT OURS — asserted, because the middleware
// deliberately does NOT trim and a reviewer will ask why.
//
// RFC 9110 §5.5 says leading and trailing OWS is not part of a field value, and
// the HTTP parser strips it before any handler runs (verified: "https://x ",
// " https://x", "https://x\t" and "https://x\n" all reach the middleware as
// "https://x"). So a padded header IS the canonical origin by the time we see it,
// the value echoed back is canonical, and there is nothing left to smuggle. A
// trim in this package would be a second normalisation rule carved out beside
// exact(), which is the total one.
func TestPaddedOriginIsCanonicalisedByTheTransportNotByUs(t *testing.T) {
do := harness(t, consoles{ours: true})
for _, padded := range []string{ours + " ", " " + ours, ours + "\t", ours + "\n", "\t" + ours + " "} {
got := do(http.MethodPost, "/v1/iam/login", padded)
if got.origin != ours {
t.Errorf("Origin %q: allow-origin = %q, want the canonical %q — the transport strips OWS",
padded, got.origin, ours)
}
}
}
// HEADER INJECTION through the echoed origin. A CRLF is FOLDED into the field
// value by the transport rather than splitting it, so the smuggled field arrives
// as part of the Origin string — and the only thing that stops it being written
// back into the response is exact() refusing anything that is not already its own
// canonical serialization. Nothing may be echoed, and no smuggled header may
// appear.
func TestACRLFInTheOriginIsNeverEchoedBack(t *testing.T) {
_ = schema.Kinds()
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(t.TempDir(), "cors.db"),
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
app := zip.New(zip.Config{AppName: "cors-injection", DisableStartupMessage: true})
app.Use(allow(db, consoles{ours: true}))
app.Post("/v1/iam/login", func(c *zip.Ctx) error { return c.String(http.StatusOK, "ok") })
for _, o := range []string{
ours + "\r\nX-Injected: 1",
ours + "\r\nAccess-Control-Allow-Credentials: true",
} {
req := httptest.NewRequest(http.MethodPost, "/v1/iam/login", nil)
req.Header.Set("Origin", o)
res, err := app.Test(req, zip.TestConfig{Timeout: 0, FailOnTimeout: false})
if err != nil {
continue // the transport refused it outright; the same miss, one layer earlier
}
if got := res.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Origin %q was echoed as %q", o, got)
}
if got := res.Header.Get("X-Injected"); got != "" {
t.Errorf("Origin %q smuggled X-Injected: %q into the response", o, got)
}
if got := res.Header.Get("Access-Control-Allow-Credentials"); got != "" {
t.Errorf("Origin %q smuggled Allow-Credentials: %q into the response", o, got)
}
_ = res.Body.Close()
}
}
// LEAST PRIVILEGE, and the crown jewel. A listed console may sign a user in and
// out; it may NOT read the account object with the ambient cookie. get-account is
// exactly what the live proxy defect disclosed, so it stays readable only by a
// caller holding a Bearer token.
func TestListedConsoleStillCannotReadTheAccountWithTheCookie(t *testing.T) {
do := harness(t, consoles{ours: true})
for _, path := range []string{
readPath, "/v1/iam/oauth/userinfo", "/v1/iam/get-users",
"/v1/iam/get-organizations", "/v1/iam/oauth/token",
} {
got := do(http.MethodGet, path, ours)
if got.credentials != "" {
t.Errorf("%s allowed credentials for a listed console (%q); a read must never be "+
"answerable from the SSO cookie cross-origin", path, got.credentials)
}
if got.origin != ours {
t.Errorf("%s allow-origin = %q, want the console echoed (a Bearer read is still allowed)",
path, got.origin)
}
}
}
// The two lists answer two different questions. A tenant that registers a
// redirect_uri on a host it controls lands in the DERIVED set — it may read a
// PKCE answer, and it must never thereby be able to spend the user's cookie.
func TestRegisteredTenantReadsButNeverCarriesTheCookie(t *testing.T) {
do := harness(t, consoles{ours: true})
if got := do(http.MethodPost, "/v1/iam/oauth/token", theirs); got.origin != theirs {
t.Errorf("a registered redirect origin was refused the token exchange: allow-origin=%q", got.origin)
}
for _, path := range allPaths() {
got := do(http.MethodPost, path, theirs)
if got.credentials != "" {
t.Errorf("%s: a merely REGISTERED origin was allowed credentials — the derived allowlist "+
"is tenant-writable, so this hands every signed-in user's session to a tenant", path)
}
}
}
// An empty list is the behaviour that predates it: nothing carries the cookie.
// Configuration widens the grant; it is never assumed.
func TestUnsetListGrantsNoCredentials(t *testing.T) {
do := harness(t, nil)
for _, path := range credentialed {
if got := do(http.MethodPost, path, ours); got.credentials != "" {
t.Errorf("%s: credentials allowed with an unset list: %q", path, got.credentials)
}
}
}
// Vary: Origin must ride EVERY answer on a browser path, including the refusals
// and the no-Origin request. A Vary set only on the allowed branch lets a shared
// cache learn "this URL is readable by anyone" from one console's request and
// replay it to the next origin — the cache-poisoning half of this bug.
func TestVaryOnOriginRidesEveryAnswer(t *testing.T) {
do := harness(t, consoles{ours: true})
for _, o := range []string{ours, theirs, hostile, "https://zzz.hanzo.app", "https://console.hanzo.ai.", ""} {
for _, path := range allPaths() {
for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodOptions} {
got := do(method, path, o)
if !varies(got.vary, "Origin") {
t.Errorf("%s %s from %q: Vary = %q, want it to include Origin", method, path, o, got.vary)
}
}
}
}
}
// THE CLOBBER. The terminal handler sets its own Vary, which is what a real
// handler does on any negotiated response. A Vary written BEFORE c.Next() is
// simply replaced by it and the cache protection silently disappears — the
// response looks correct in a unit test that never runs a handler. Both fields
// must survive, and Origin must appear exactly once.
func TestVarySurvivesAHandlerThatSetsItsOwnVary(t *testing.T) {
do := harness(t, consoles{ours: true})
for _, o := range []string{ours, hostile, ""} {
got := do(http.MethodGet, "/v1/iam/login", o)
if !varies(got.vary, "Origin") {
t.Errorf("from %q: Vary = %q — the handler's own Vary clobbered ours", o, got.vary)
}
if !varies(got.vary, "Accept-Encoding") {
t.Errorf("from %q: Vary = %q — we clobbered the handler's", o, got.vary)
}
if strings.Count(strings.ToLower(got.vary), "origin") != 1 {
t.Errorf("from %q: Vary = %q — Origin listed more than once", o, got.vary)
}
}
}
// varies reports whether field is one of the comma-separated Vary members.
func varies(header, field string) bool {
for _, f := range strings.Split(header, ",") {
if strings.EqualFold(strings.TrimSpace(f), field) {
return true
}
}
return false
}
// Config parsing. A suffix, a bare domain or a wildcard is an ERROR, not a
// silently dropped entry: this fleet serves *.hanzo.app as customer-published
// sites, so a suffix read of a brand list would name every customer site a
// first-party console.
func TestParseRefusesAnythingThatIsNotAnExactHTTPSOrigin(t *testing.T) {
for _, bad := range []string{
"hanzo.ai", // bare domain
".hanzo.ai", // suffix
"*.hanzo.ai", // wildcard
"https://*.hanzo.ai", // wildcard with a scheme
"http://console.hanzo.ai", // plaintext
"https://console.hanzo.ai/", // trailing slash
"https://console.hanzo.ai/path", // carries a path
"https://u:p@console.hanzo.ai", // userinfo
"https://console.hanzo.ai?a=b", // query
"https://console.hanzo.ai.", // trailing dot
"https://console..hanzo.ai", // empty label
"console.hanzo.ai:443", // no scheme
"*", // the wildcard that would end the world
"null",
} {
if _, err := parse(bad); err == nil {
t.Errorf("parse(%q) was accepted; a malformed entry must fail the boot loud", bad)
}
}
// And one bad entry among good ones still fails: a partial parse would deny
// exactly one brand its login while the rest kept working.
if _, err := parse("https://console.hanzo.ai,*.hanzo.app,https://cloud.lux.network"); err == nil {
t.Error("a list with one bad entry parsed; it must fail the boot loud")
}
}
// What an operator legitimately writes must parse, including several brands in
// one list and a capitalisation a browser would send lower-cased.
func TestParseAcceptsTheRealConsoleList(t *testing.T) {
set, err := parse(" https://console.hanzo.ai, https://Cloud.Lux.Network ,https://cloud.zoo.network, ")
if err != nil {
t.Fatalf("parse: %v", err)
}
for _, want := range []string{
"https://console.hanzo.ai", "https://cloud.lux.network", "https://cloud.zoo.network",
} {
if !set.has(want) {
t.Errorf("%s missing from %v", want, set)
}
}
if len(set) != 3 {
t.Errorf("set = %v, want exactly the three listed origins", set)
}
if empty, err := parse(""); err != nil || len(empty) != 0 {
t.Errorf("an unset list must parse to the empty set, got %v, %v", empty, err)
}
}
// The cookie surface is a security decision, so it is asserted rather than
// assumed: exactly the five sites hanzoai/js-iam sends `credentials: "include"`
// to, and nothing that answers a READ.
func TestCookieSurfaceIsExactlyTheShippedSDKsCredentialedSites(t *testing.T) {
got := map[string]bool{}
for p, mode := range browserPaths {
if mode == cookie {
got[p] = true
}
}
for _, p := range credentialed {
if !got[p] {
t.Errorf("%s must admit the cookie: hanzoai/js-iam sends it with credentials, and a "+
"browser discards a credentialed response that does not allow the credential", p)
}
delete(got, p)
}
for p := range got {
t.Errorf("%s admits the cookie but no shipped client sends credentials to it", p)
}
for _, p := range []string{
"/v1/iam/get-account", "/v1/iam/oauth/userinfo", "/v1/iam/get-users",
"/v1/iam/get-organizations", "/v1/iam/oauth/token", "/v1/iam/organizations",
"/v1/iam/invitations", "/v1/iam/get-organization",
} {
if browserPaths[p] == cookie {
t.Errorf("%s must NOT admit the cookie: it answers a READ, which is the disclosure this closes", p)
}
}
}
+108
View File
@@ -0,0 +1,108 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
// Package cred verifies a stored password digest against a plaintext, resolving
// the algorithm FROM THE STORED ROW — never from a constant.
//
// Why this exists: v1 stamps `argon2id` on effectively every live row (the org's
// PasswordType is rewritten to argon2id on create/update, and UpdateUserPassword
// stamps it per user). A bcrypt-only verifier handed an argon2id PHC string
// returns ErrHashTooShort, so a bcrypt-only login fails 100% of real users at
// cutover. v1 resolves per row — user.PasswordType, falling back to the
// organization's — and dispatches to the matching manager. iam does the same.
//
// Hashing is argon2id ONLY (SOTA). Verify stays scheme-aware so pre-existing
// bcrypt and v1 argon2id rows keep validating, but every NEW or updated digest
// this package mints is argon2id — one way to hash, the strongest one. Re-hashing
// a verified bcrypt row to argon2id (upgrade-on-login) is a separate, deliberate
// decision, not a side effect of a read.
package cred
import (
"crypto/subtle"
"github.com/alexedwards/argon2id"
"golang.org/x/crypto/bcrypt"
)
// Supported password types. These are the two schemes Hanzo actually stores:
// argon2id (every live v1 row) and bcrypt (what iam mints for new users).
// Anything else fails CLOSED — a silent "true" on an unrecognized scheme would
// be an auth bypass, and a silent "false" we can't explain is a support
// nightmare, so Verify reports Unsupported distinctly.
const (
TypeArgon2id = "argon2id"
TypeBcrypt = "bcrypt"
)
// Resolve returns the password type for a row: the user's own, else the
// organization's, else "" (caller decides — never guess a default, since a wrong
// guess is either a failed login or, worse, a bypass).
func Resolve(userType, orgType string) string {
if userType != "" {
return userType
}
return orgType
}
// Supported reports whether Verify can handle this password type.
func Supported(passwordType string) bool {
switch passwordType {
case TypeArgon2id, TypeBcrypt:
return true
}
return false
}
// Verify reports whether plaintext matches the stored digest under passwordType.
// Both supported schemes carry their own parameters in the digest (bcrypt's
// $2a$… and argon2id's $argon2id$v=19$… PHC string), so no external salt is
// needed; salt is accepted for the legacy per-row salt schemes v1 also supports
// and is currently unused.
//
// Fails closed: an unknown/empty type, an empty hash, or a malformed digest
// returns false.
func Verify(passwordType, plaintext, hashed string) bool {
if hashed == "" || !Supported(passwordType) {
return false
}
switch passwordType {
case TypeArgon2id:
// ComparePasswordAndHash is constant-time internally and parses the PHC
// parameters from the digest itself; a malformed digest returns an error,
// which we treat as "no match" (never a panic, never a pass).
match, err := argon2id.ComparePasswordAndHash(plaintext, hashed)
return err == nil && match
case TypeBcrypt:
return bcrypt.CompareHashAndPassword([]byte(hashed), []byte(plaintext)) == nil
}
return false
}
// hashParams are the argon2id cost parameters for every new digest — OWASP-aligned
// SOTA (64 MiB memory, 2 passes, parallelism 1), tuned so a login stays well under
// ~100ms while resisting GPU/ASIC cracking. The parameters + a per-hash random salt
// ride INSIDE the PHC string, so Verify reads them from the digest itself — changing
// these never invalidates an already-stored hash.
var hashParams = &argon2id.Params{
Memory: 64 * 1024, // 64 MiB
Iterations: 2,
Parallelism: 1,
SaltLength: 16,
KeyLength: 32,
}
// Hash derives a one-way argon2id (PHC) digest from a plaintext password — the
// SOTA scheme every new/updated Hanzo password uses, stamped TypeArgon2id. The
// cost parameters and a per-hash random salt are embedded in the returned string,
// so Verify needs no external salt or config. The plaintext is never logged or
// stored; only this one-way digest is.
func Hash(plaintext string) (string, error) {
return argon2id.CreateHash(plaintext, hashParams)
}
// ConstantTimeEqual is a small helper for comparing non-hash secrets (e.g. a
// verification code) without leaking length/position through timing.
func ConstantTimeEqual(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
+106
View File
@@ -0,0 +1,106 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package cred
import (
"testing"
"github.com/alexedwards/argon2id"
"golang.org/x/crypto/bcrypt"
)
// TestVerify_Argon2id_RealV1FormatHash is the regression for the cutover
// blocker: every live v1 row is argon2id, and a bcrypt-only verifier fails all
// of them. This proves iam verifies a genuine argon2id PHC digest — the exact
// shape v1's Argon2idCredManager writes (github.com/alexedwards/argon2id,
// DefaultParams).
func TestVerify_Argon2id_RealV1FormatHash(t *testing.T) {
pw := "correct horse battery staple"
hash, err := argon2id.CreateHash(pw, argon2id.DefaultParams)
if err != nil {
t.Fatal(err)
}
// Sanity: it really is the PHC shape a live row carries.
if len(hash) < 20 || hash[:9] != "$argon2id" {
t.Fatalf("not an argon2id PHC digest: %q", hash)
}
if !Verify(TypeArgon2id, pw, hash) {
t.Fatal("argon2id: correct password REJECTED — this is the cutover blocker")
}
if Verify(TypeArgon2id, "wrong password", hash) {
t.Fatal("argon2id: wrong password ACCEPTED")
}
}
// TestVerify_BcryptStillWorks — new iam-minted users are bcrypt; don't regress.
func TestVerify_Bcrypt(t *testing.T) {
pw := "s3cret-pw"
h, _ := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.MinCost)
if !Verify(TypeBcrypt, pw, string(h)) {
t.Fatal("bcrypt: correct password rejected")
}
if Verify(TypeBcrypt, "nope", string(h)) {
t.Fatal("bcrypt: wrong password accepted")
}
}
// TestVerify_CrossSchemeFailsClosed — the actual bug: an argon2id digest handed
// to the bcrypt path (or vice versa) must NOT pass, and must not panic.
func TestVerify_CrossSchemeFailsClosed(t *testing.T) {
pw := "x"
argon, _ := argon2id.CreateHash(pw, argon2id.DefaultParams)
bc, _ := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.MinCost)
if Verify(TypeBcrypt, pw, argon) {
t.Fatal("argon2id digest verified under bcrypt — auth bypass")
}
if Verify(TypeArgon2id, pw, string(bc)) {
t.Fatal("bcrypt digest verified under argon2id — auth bypass")
}
}
// TestVerify_FailsClosedOnGarbage — unknown type, empty hash, malformed digest.
func TestVerify_FailsClosedOnGarbage(t *testing.T) {
cases := []struct{ typ, pw, hash string }{
{"", "pw", "$argon2id$v=19$whatever"}, // no type
{"sha256-salt", "pw", "deadbeef"}, // unsupported legacy type
{"plain", "pw", "pw"}, // plaintext scheme: refused
{TypeArgon2id, "pw", ""}, // empty hash
{TypeArgon2id, "pw", "not-a-phc-string"}, // malformed
{TypeBcrypt, "pw", "$2a$garbage"}, // malformed bcrypt
{"ARGON2ID", "pw", "$argon2id$v=19$x"}, // case-sensitive: not supported
}
for _, c := range cases {
if Verify(c.typ, c.pw, c.hash) {
t.Fatalf("verify(%q, hash=%q) returned TRUE — must fail closed", c.typ, c.hash)
}
}
}
// TestResolve_PerRowThenOrgFallback — v1's contract: the user's own type wins;
// an empty user type falls back to the org's; never a hardcoded default.
func TestResolve(t *testing.T) {
if got := Resolve("bcrypt", "argon2id"); got != "bcrypt" {
t.Fatalf("user type must win: got %q", got)
}
if got := Resolve("", "argon2id"); got != "argon2id" {
t.Fatalf("empty user type must fall back to org: got %q", got)
}
if got := Resolve("", ""); got != "" {
t.Fatalf("both empty must stay empty (caller decides), got %q", got)
}
}
func TestSupported(t *testing.T) {
for _, ok := range []string{TypeArgon2id, TypeBcrypt} {
if !Supported(ok) {
t.Fatalf("%s must be supported", ok)
}
}
for _, no := range []string{"", "plain", "salt", "sha512-salt", "md5-salt", "pbkdf2-salt"} {
if Supported(no) {
t.Fatalf("%q must NOT be supported (fail closed)", no)
}
}
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package cred
import "testing"
// Golden vectors: PHC digests produced by **v1's own Argon2idCredManager**
// (hanzoai/iam `cred.NewArgon2idCredManager().GetHashedPassword`, DefaultParams),
// captured verbatim. This is the parity proof that matters — iam must verify the
// exact bytes v1 wrote, not merely a digest iam generated itself.
//
// It also pins a REAL cross-version risk: v1 resolves
// `github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387` while iam
// pins `v1.0.0`. The PHC string is self-describing (m/t/p + salt + key), so a
// digest from either version must verify under the other — this test is what
// proves that, and what fails loudly if a future bump ever breaks it.
//
// These are throwaway TEST passwords. No live user's digest is ever committed —
// a real hash is an offline-attackable secret and does not belong in a repo.
const (
// v1 Argon2idCredManager.GetHashedPassword("golden-test-password-1", "")
goldenV1Password = "golden-test-password-1"
goldenV1Digest = "$argon2id$v=19$m=65536,t=1,p=2$oOen09XtFBqKnv2/K4q5mQ$iZKRwt09CdXDXr4E1CQtRoF/nWzgI810tMFUUiKHugo"
)
// TestGolden_V1Argon2idDigestVerifies is the cutover-parity assertion: a digest
// written by the LIVE v1 code path verifies under iam's cred.Verify.
func TestGolden_V1Argon2idDigestVerifies(t *testing.T) {
if !Verify(TypeArgon2id, goldenV1Password, goldenV1Digest) {
t.Fatal("iam REJECTED a digest produced by v1's Argon2idCredManager — " +
"credential parity is broken; every live login would fail at cutover")
}
if Verify(TypeArgon2id, "not-the-password", goldenV1Digest) {
t.Fatal("wrong password ACCEPTED against the v1 golden digest")
}
}
// TestGolden_V1DigestShape documents the exact PHC shape v1 emits, so a change in
// v1's params (or a lib bump on either side) is caught here rather than in prod.
func TestGolden_V1DigestShape(t *testing.T) {
// $argon2id$v=19$m=65536,t=1,p=2$<salt>$<key>
const wantPrefix = "$argon2id$v=19$m=65536,t=1,p="
if len(goldenV1Digest) < len(wantPrefix) || goldenV1Digest[:len(wantPrefix)] != wantPrefix {
t.Fatalf("v1 digest shape changed: %q", goldenV1Digest)
}
}
// TestGolden_ResolvedThroughRowType proves the full row→algorithm path a real
// login takes: the row says "argon2id" (what every live v1 row says), the org
// fallback is irrelevant, and the v1 digest verifies.
func TestGolden_ResolvedThroughRowType(t *testing.T) {
typ := Resolve("argon2id", "bcrypt") // user's own type must win
if typ != TypeArgon2id {
t.Fatalf("resolve: got %q", typ)
}
if !Verify(typ, goldenV1Password, goldenV1Digest) {
t.Fatal("row-resolved argon2id failed to verify the v1 golden digest")
}
// And the bug that shipped: resolving to bcrypt against this digest must FAIL,
// never pass.
if Verify(TypeBcrypt, goldenV1Password, goldenV1Digest) {
t.Fatal("v1 argon2id digest verified under bcrypt — auth bypass")
}
}
+370
View File
@@ -0,0 +1,370 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
// Package e2e_test drives the WHOLE iam surface through the real registered router
// (routes.Route) as one integrated journey — the behavioral parity proof that the
// old the legacy surface IAM's clients work against iam. Unlike the per-package unit tests,
// this chains the real flows a live client runs in sequence: OIDC discovery →
// PKCE login → code→token → userinfo → introspect → revoke; the admin console's
// get-account → get-organizations → get-users (the legacy compat surface); SCIM
// 2.0 provisioning; and RFC 8693 token exchange. Every step asserts the response
// CONTRACT the client depends on.
package e2e_test
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"io"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"github.com/hanzoai/orm"
ormdb "github.com/hanzoai/orm/db"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/routes"
"github.com/hanzoai/iam/pkg/pkce"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/internal/testhttp"
)
const (
kid = "cert-hanzo"
redirectURI = "https://console.hanzo.ai/auth/callback"
)
type env struct {
app *zip.App
key *rsa.PrivateKey
db orm.DB
}
func boot(t *testing.T) *env {
t.Helper()
_ = schema.Kinds()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa: %v", err)
}
dir := t.TempDir()
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(dir, "e2e.db"),
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
seedCert(t, db, key)
// A confidential console app: password login + PKCE, in the hanzo org.
seedApp(t, db)
seedOrg(t, db, "admin")
seedOrg(t, db, "hanzo")
seedUser(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw", false)
seedUser(t, db, "admin", "root", "root@hanzo.ai", "pw", true) // SuperAdmin
app := zip.New(zip.Config{AppName: "iam-e2e", DisableStartupMessage: true})
routes.Route(app, db)
if err := app.Build(); err != nil {
t.Fatalf("build: %v", err)
}
return &env{app: app, key: key, db: db}
}
// TestJourney_OIDCFlow is the full OAuth2/OIDC round trip a client SDK runs.
func TestJourney_OIDCFlow(t *testing.T) {
e := boot(t)
// 1) Discovery is self-consistent (one issuer, the endpoints a strict client pins).
disc := e.getJSON(t, "/.well-known/openid-configuration", "")
if disc["issuer"] == "" || disc["token_endpoint"] == "" || disc["jwks_uri"] == "" {
t.Fatalf("discovery incomplete: %v", disc)
}
if disc["introspection_endpoint"] == "" || disc["revocation_endpoint"] == "" {
t.Fatalf("discovery missing RFC 7662/7009 endpoints: %v", disc)
}
// RFC 8414 AS metadata served at its own well-known.
if as := e.getJSON(t, "/.well-known/oauth-authorization-server", ""); as["issuer"] == "" {
t.Fatalf("RFC 8414 AS metadata missing")
}
// 2) JWKS publishes a verification key.
jwks := e.getJSON(t, "/v1/iam/.well-known/jwks", "")
if keys, _ := jwks["keys"].([]any); len(keys) == 0 {
t.Fatalf("JWKS has no keys: %v", jwks)
}
// 3) PKCE login → single-use code.
verifier := "e2e-verifier-0000000000000000000000000000000000000"
code := e.login(t, verifier)
// 4) Redeem the code → access token (+ id_token on openid, refresh on offline).
tok := e.token(t, url.Values{
"grant_type": {"authorization_code"}, "code": {code},
"client_id": {"hanzo-console"}, "client_secret": {"top-secret"},
"redirect_uri": {redirectURI}, "code_verifier": {verifier},
})
access, _ := tok["access_token"].(string)
if access == "" {
t.Fatalf("no access_token: %v", tok)
}
// 5) UserInfo carries the identity + the admin-guard contract (owner, isAdmin).
info := e.getJSON(t, "/v1/iam/oauth/userinfo", access)
if info["sub"] != "hanzo/alice" || info["owner"] != "hanzo" {
t.Fatalf("userinfo sub/owner wrong: %v", info)
}
if _, ok := info["isAdmin"]; !ok {
t.Fatalf("userinfo missing the isAdmin claim (admin-guard contract): %v", info)
}
// 6) Introspection (RFC 7662): active, with the standard claims.
ir := e.form(t, "/v1/iam/oauth/introspect", "hanzo-console", "top-secret", url.Values{"token": {access}})
if ir["active"] != true || ir["sub"] != "hanzo/alice" {
t.Fatalf("introspect not active/wrong sub: %v", ir)
}
// 7) Revocation (RFC 7009): the token dies — introspect flips to inactive.
e.form(t, "/v1/iam/oauth/revoke", "hanzo-console", "top-secret", url.Values{"token": {access}})
if after := e.form(t, "/v1/iam/oauth/introspect", "hanzo-console", "top-secret", url.Values{"token": {access}}); after["active"] != false {
t.Fatalf("token still active after revoke: %v", after)
}
}
// TestJourney_PasswordGrant_and_TokenExchange proves the two non-interactive grants
// the console/BFF rely on.
func TestJourney_PasswordGrant_and_TokenExchange(t *testing.T) {
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
e := boot(t)
// Password grant → a first-party session token for alice.
pw := e.token(t, url.Values{
"grant_type": {"password"}, "client_id": {"hanzo-console"}, "client_secret": {"top-secret"},
"username": {"alice@hanzo.ai"}, "password": {"pw"}, "scope": {"openid profile"},
})
subjectToken, _ := pw["access_token"].(string)
if subjectToken == "" {
t.Fatalf("password grant failed: %v", pw)
}
// RFC 8693 token exchange: the BFF exchanges alice's token for one scoped to a
// downstream resource, still bound to alice.
xe := e.token(t, url.Values{
"grant_type": {"urn:ietf:params:oauth:grant-type:token-exchange"},
"client_id": {"hanzo-console"}, "client_secret": {"top-secret"},
"subject_token": {subjectToken}, "resource": {"hanzo-cloud"},
})
if xe["issued_token_type"] != "urn:ietf:params:oauth:token-type:access_token" || xe["access_token"] == "" {
t.Fatalf("token exchange failed: %v", xe)
}
}
// TestJourney_AdminConsole_LegacySurface proves the old admin console's calls work:
// get-account (the security contract), get-organizations (OrgSwitcher), get-users.
func TestJourney_AdminConsole_LegacySurface(t *testing.T) {
e := boot(t)
root := e.mint(t, "admin/root") // a SuperAdmin bearer
// get-account — {status:ok, data:<masked user>} with owner + isAdmin.
acct := e.getJSON(t, "/v1/iam/get-account", root)
if acct["status"] != "ok" {
t.Fatalf("get-account status: %v", acct)
}
// get-organizations — the OrgSwitcher workhorse; SuperAdmin sees all.
orgs := e.getJSON(t, "/v1/iam/get-organizations", root)
if orgs["status"] != "ok" {
t.Fatalf("get-organizations status: %v", orgs)
}
if data, _ := orgs["data"].([]any); len(data) < 2 {
t.Fatalf("get-organizations returned %d orgs, want >=2 (admin+hanzo)", len(data))
}
// get-users scoped to an org — no secret leaks.
usersBody := e.getRaw(t, "/v1/iam/get-users?owner=hanzo", root)
if strings.Contains(usersBody, "passwordHash") || strings.Contains(usersBody, "\"password\"") {
t.Fatalf("get-users leaked a secret: %s", usersBody)
}
}
// TestJourney_SCIMProvisioning proves the RFC-standard provisioning path an IdP uses.
func TestJourney_SCIMProvisioning(t *testing.T) {
e := boot(t)
root := e.mint(t, "admin/root")
create := `{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"newhire",` +
`"active":true,"password":"pw","urn:ietf:params:scim:schemas:extension:hanzo:2.0:User":{"owner":"hanzo"}}`
st, body := e.req(t, "POST", "/v1/iam/scim/v2/Users", root, create, "application/scim+json")
if st != 201 {
t.Fatalf("SCIM create status = %d: %s", st, body)
}
if st, _ := e.req(t, "GET", "/v1/iam/scim/v2/Users/hanzo/newhire", root, "", ""); st != 200 {
t.Fatalf("SCIM get status = %d", st)
}
if st, _ := e.req(t, "DELETE", "/v1/iam/scim/v2/Users/hanzo/newhire", root, "", ""); st != 204 {
t.Fatalf("SCIM delete status = %d", st)
}
}
// ---- flow helpers ----
func (e *env) login(t *testing.T, verifier string) string {
t.Helper()
body, _ := json.Marshal(map[string]string{
"type": "code", "organization": "hanzo", "username": "alice@hanzo.ai", "password": "pw",
"clientId": "hanzo-console", "redirectUri": redirectURI, "scope": "openid profile email offline_access",
"codeChallenge": pkce.Challenge(verifier), "codeChallengeMethod": "S256",
})
st, resp := e.req(t, "POST", "/v1/iam/login", "", string(body), "application/json")
if st != 200 {
t.Fatalf("login status = %d: %s", st, resp)
}
var m map[string]any
_ = json.Unmarshal([]byte(resp), &m)
code, _ := m["data"].(string)
if code == "" {
t.Fatalf("login returned no code: %s", resp)
}
return code
}
func (e *env) token(t *testing.T, form url.Values) map[string]any {
t.Helper()
st, body := e.req(t, "POST", "/v1/iam/oauth/token", "", form.Encode(), "application/x-www-form-urlencoded")
_ = st
var m map[string]any
_ = json.Unmarshal([]byte(body), &m)
return m
}
func (e *env) form(t *testing.T, path, clientID, secret string, form url.Values) map[string]any {
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
req.Host = "hanzo.id"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(clientID+":"+secret)))
resp, err := testhttp.Do(e.app, req)
if err != nil {
t.Fatalf("form %s: %v", path, err)
}
b, _ := io.ReadAll(resp.Body)
var m map[string]any
_ = json.Unmarshal(b, &m)
return m
}
func (e *env) req(t *testing.T, method, path, bearer, body, contentType string) (int, string) {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, r)
req.Host = "hanzo.id"
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := testhttp.Do(e.app, req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
func (e *env) getJSON(t *testing.T, path, bearer string) map[string]any {
t.Helper()
_, body := e.req(t, "GET", path, bearer, "", "")
var m map[string]any
_ = json.Unmarshal([]byte(body), &m)
return m
}
func (e *env) getRaw(t *testing.T, path, bearer string) string {
t.Helper()
_, body := e.req(t, "GET", path, bearer, "", "")
return body
}
// mint signs an RS256 bearer for sub under the seeded cert — a valid principal the
// Guard admits (used for the compat/SCIM admin calls, which need a verified bearer
// but not a persisted grant row).
func (e *env) mint(t *testing.T, sub string) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"sub": sub, "iat": time.Now().Add(-time.Minute).Unix(), "exp": time.Now().Add(time.Hour).Unix(),
})
tok.Header["kid"] = kid
s, err := tok.SignedString(e.key)
if err != nil {
t.Fatalf("sign: %v", err)
}
return s
}
// ---- seed helpers ----
func seedCert(t *testing.T, db orm.DB, key *rsa.PrivateKey) {
t.Helper()
c := orm.New[schema.Cert](db)
c.Owner, c.Name, c.CryptoAlgorithm = "admin", kid, "RS256"
c.PrivateKey = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
c.SetId("admin/" + kid)
if err := c.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed cert: %v", err)
}
}
func seedApp(t *testing.T, db orm.DB) {
t.Helper()
a := orm.New[schema.Application](db)
a.Owner, a.Name, a.ClientId, a.ClientSecret = "admin", "hanzo-console", "hanzo-console", "top-secret"
a.Organization, a.Cert, a.EnablePassword = "hanzo", kid, true
a.RedirectUris = []string{redirectURI}
a.ExpireInHours = 1
a.SetId("admin/hanzo-console")
if err := a.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed app: %v", err)
}
}
func seedOrg(t *testing.T, db orm.DB, name string) {
t.Helper()
o := orm.New[schema.Organization](db)
o.Owner, o.Name = "admin", name
o.SetId("admin/" + name)
if err := o.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed org %s: %v", name, err)
}
}
func seedUser(t *testing.T, db orm.DB, owner, name, email, password string, admin bool) {
t.Helper()
u := orm.New[schema.User](db)
u.Owner, u.Name, u.Email, u.IsAdmin = owner, name, email, admin
hash, herr := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if herr != nil {
t.Fatalf("hash: %v", herr)
}
u.PasswordHash, u.PasswordType = string(hash), "bcrypt"
u.SetId(owner + "/" + name)
if err := u.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed user %s/%s: %v", owner, name, err)
}
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
// Package featurestore implements feature.Store over the iam orm store, so the
// hanzoiam/* enterprise modules read/write the SAME identity data as the core.
// Internal: the core (server.Route) constructs it and hands the interface to
// feature.RouteAll — modules never see this package, only the feature.Store seam.
package featurestore
import (
"context"
"time"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/feature"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/users"
"github.com/hanzoai/iam/pkg/model"
)
type ormStore struct {
db orm.DB
u *users.API
}
// New returns a feature.Store backed by db (the core's one identity store).
func New(db orm.DB) feature.Store { return &ormStore{db: db, u: users.New(db)} }
func (s *ormStore) GetUser(ctx context.Context, owner, name string) (*model.User, error) {
return store.GetUserByName(ctx, s.db, owner, name)
}
// GetUserByID resolves the seam's user id — schema.User.Id, the stable opaque
// UUID the OIDC `sub` carries — through store.GetUserById, the ONE subject
// resolver, so a module and the core name a user the same way.
//
// It must NOT be orm.Get, which keys on the orm STORAGE id: that is a different
// value (a v2-native row's surrogate, a migrated row's "owner/name"), so the id
// AddUser assigns would not resolve here, and the "owner/name" shape is both
// mutable and slash-bearing — unusable as an opaque single-segment resource id.
// Going through store also keeps the fail-closed check on a duplicated subject.
func (s *ormStore) GetUserByID(ctx context.Context, id string) (*model.User, error) {
return store.GetUserById(ctx, s.db, id)
}
func (s *ormStore) GetGlobalUsers(ctx context.Context, offset, limit int) ([]*model.User, int, error) {
total, err := orm.TypedQuery[schema.User](s.db).Count(ctx)
if err != nil {
return nil, 0, err
}
q := orm.TypedQuery[schema.User](s.db).Order("Name")
if offset > 0 {
q = q.Offset(offset)
}
if limit > 0 {
q = q.Limit(limit)
}
list, err := q.GetAll(ctx)
return list, total, err
}
func (s *ormStore) AddUser(ctx context.Context, u *model.User) (bool, error) {
if _, err := s.u.Create(ctx, &users.CreateInput{User: *u}); err != nil {
return false, err
}
return true, nil
}
func (s *ormStore) UpdateUser(ctx context.Context, u *model.User) (bool, error) {
if _, err := s.u.Update(ctx, &users.UpdateInput{User: *u}); err != nil {
return false, err
}
return true, nil
}
func (s *ormStore) DeleteUser(ctx context.Context, owner, name string) (bool, error) {
out, err := s.u.Delete(ctx, &users.Ref{Owner: owner, Name: name})
if err != nil {
return false, err
}
return out.Deleted, nil
}
func (s *ormStore) GetApplication(ctx context.Context, id string) (*model.Application, error) {
if app, err := store.GetApplicationByName(ctx, s.db, "admin", id); err == nil && app != nil {
return app, nil
}
return store.GetApplicationByClientId(ctx, s.db, id)
}
func (s *ormStore) GetOrganization(ctx context.Context, name string) (*model.Organization, error) {
return store.GetOrganizationByName(ctx, s.db, name)
}
func (s *ormStore) GetProvider(ctx context.Context, owner, name string) (*model.Provider, error) {
return store.GetProvider(ctx, s.db, owner, name)
}
func (s *ormStore) GetCert(ctx context.Context, owner, name string) (*model.Cert, error) {
return store.GetCert(ctx, s.db, owner, name)
}
// SetPassword loads the canonical row and re-saves it with the plaintext, which
// users.Update hashes exactly once (empty leaves the digest untouched). Passing
// the full existing row means no other field is zeroed by the update.
func (s *ormStore) SetPassword(ctx context.Context, owner, name, plaintext string) (bool, error) {
u, err := store.GetUserByName(ctx, s.db, owner, name)
if err != nil {
return false, err
}
if u == nil {
return false, nil
}
if _, err := s.u.Update(ctx, &users.UpdateInput{User: *u, Password: plaintext}); err != nil {
return false, err
}
return true, nil
}
// VerifyPassword authenticates a human credential for the LDAP-bind feature seam. It
// goes through users.Authenticate — the ONE lockout-enforcing choke point the login
// form, the ROPC grant, and the registry token endpoint share — so an LDAP bind is
// rate-limited (argon2id v1 / bcrypt v2, keyed by the org's password type) exactly
// like every other human-credential path; no hash ever leaves the core. A locked
// account returns false (the bind fails), folding lockout into the same negative
// result as a wrong password — LDAP has no distinct "locked" signal.
func (s *ormStore) VerifyPassword(ctx context.Context, owner, name, plaintext string) (bool, error) {
u, err := store.GetUserByName(ctx, s.db, owner, name)
if err != nil {
return false, err
}
if u == nil {
return false, nil
}
pwType := ""
if org, oerr := store.GetOrganizationByName(ctx, s.db, owner); oerr == nil && org != nil {
pwType = org.PasswordType
}
ok, _ := users.Authenticate(ctx, s.db, u, plaintext, pwType, time.Now())
return ok, nil
}
+119
View File
@@ -0,0 +1,119 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package featurestore
import (
"context"
"path/filepath"
"strings"
"testing"
"github.com/google/uuid"
"github.com/hanzoai/iam/feature"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/pkg/model"
)
func openFeatureStore(t *testing.T) feature.Store {
t.Helper()
db, err := store.Open("sqlite", filepath.Join(t.TempDir(), "iam.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { db.Close() })
return New(db)
}
// The seam's user id is the stable opaque subject: AddUser mints it server-side,
// and the id a module reads back MUST be the one GetUserByID resolves. A module
// hands that id to a client as the user's handle and gets it back on the next
// request, so a mismatch here means every lookup by id misses.
func TestAddUserThenGetUserByID(t *testing.T) {
ctx := context.Background()
s := openFeatureStore(t)
in := &model.User{Owner: "acme", Name: "alice", Email: "alice@acme.example"}
ok, err := s.AddUser(ctx, in)
if err != nil || !ok {
t.Fatalf("AddUser = %v, %v", ok, err)
}
// AddUser takes the user by value: the caller's struct is never stamped, so the
// id is learned by re-reading the row.
if in.Id != "" {
t.Fatalf("AddUser stamped the caller's struct with %q; callers must re-read", in.Id)
}
row, err := s.GetUser(ctx, "acme", "alice")
if err != nil || row == nil {
t.Fatalf("GetUser = %v, %v", row, err)
}
if _, err := uuid.Parse(row.Id); err != nil {
t.Fatalf("assigned id %q is not the opaque UUID subject: %v", row.Id, err)
}
if strings.Contains(row.Id, "/") {
t.Fatalf("id %q carries a slash; unusable as a /Users/{id} path segment", row.Id)
}
got, err := s.GetUserByID(ctx, row.Id)
if err != nil {
t.Fatalf("GetUserByID(%q): %v", row.Id, err)
}
if got == nil {
t.Fatalf("GetUserByID(%q) found nothing — the id AddUser assigned does not resolve", row.Id)
}
if got.Owner != "acme" || got.Name != "alice" {
t.Fatalf("GetUserByID resolved %s/%s, want acme/alice", got.Owner, got.Name)
}
}
// The id survives an update unchanged, so a module's stored resource id stays
// valid: UpdateUser carries Id (and CreatedTime) forward and ignores a body value.
func TestUpdateUserPreservesTheSubject(t *testing.T) {
ctx := context.Background()
s := openFeatureStore(t)
if _, err := s.AddUser(ctx, &model.User{Owner: "acme", Name: "bob"}); err != nil {
t.Fatalf("AddUser: %v", err)
}
before, _ := s.GetUser(ctx, "acme", "bob")
if before == nil {
t.Fatal("GetUser after AddUser: nil")
}
// A body that tries to move the subject must be ignored.
edit := *before
edit.Id = uuid.NewString()
edit.DisplayName = "Bob"
if _, err := s.UpdateUser(ctx, &edit); err != nil {
t.Fatalf("UpdateUser: %v", err)
}
after, _ := s.GetUser(ctx, "acme", "bob")
if after == nil {
t.Fatal("GetUser after UpdateUser: nil")
}
if after.Id != before.Id {
t.Fatalf("update moved the subject: %q -> %q", before.Id, after.Id)
}
if after.DisplayName != "Bob" {
t.Fatalf("DisplayName = %q, want Bob", after.DisplayName)
}
if got, err := s.GetUserByID(ctx, before.Id); err != nil || got == nil {
t.Fatalf("GetUserByID after update = %v, %v; the original id must still resolve", got, err)
}
}
// An unmatched id is (nil, nil), not an error — callers turn it into a 404.
func TestGetUserByIDUnknown(t *testing.T) {
ctx := context.Background()
s := openFeatureStore(t)
got, err := s.GetUserByID(ctx, uuid.NewString())
if err != nil {
t.Fatalf("GetUserByID(unknown) errored: %v", err)
}
if got != nil {
t.Fatalf("GetUserByID(unknown) = %v, want nil", got)
}
}
+215
View File
@@ -0,0 +1,215 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
// Package httpx is the shared HTTP layer for the IAM v2 handlers: the
// the legacy surface-compatible Response envelope that the @hanzo/iam SDK and the hanzo.id
// portal consume, plus small helpers over zip.Ctx. Every front-door JSON
// endpoint (get-app-login, login, signup) returns this shape; the OIDC
// endpoints (token/authorize/userinfo) use their own RFC 6749 shapes.
package httpx
import (
"crypto/subtle"
"encoding/base64"
"os"
"strings"
"github.com/zap-proto/zip"
)
// Response is the the legacy surface-compatible envelope. status is "ok" or
// "error", and it stays the field an SDK branches on for the REASON a call
// failed. The HTTP status says whether it failed at all, and the two agree:
// a refusal is a 4xx carrying status:"error".
//
// It used to ride on a 200. That inherited the upstream's habit of using the
// envelope as the only channel, and it made every refusal indistinguishable from
// a success to the layer that checks first — `res.ok` in fetch,
// `raise_for_status()` in requests, `StatusCode/100 == 2` in Go. A signup that was
// refused therefore READ as a signup that had happened, and the caller went on to
// the next step of an onboarding that did not exist.
type Response struct {
Status string `json:"status"`
Msg string `json:"msg"`
// Code is a STABLE machine-readable reason, where the human `msg` is
// deliberately generic. `msg` is prose for a person and several distinct causes
// legitimately share one sentence; a caller that must BRANCH on the cause — or
// tell its own user which of them happened — cannot parse prose. Optional, so
// every existing envelope is byte-identical and no SDK changes.
Code string `json:"code,omitempty"`
Sub string `json:"sub,omitempty"`
Name string `json:"name,omitempty"`
Data any `json:"data"`
Data2 any `json:"data2,omitempty"`
Data3 any `json:"data3,omitempty"`
}
// ServiceToken returns the configured unified service token — the first non-empty
// of HANZO_API_KEY / KMS_SERVICE_TOKEN / IAM_SERVICE_TOKEN — or "" (fail closed).
// This is the ONE system credential the service-token surfaces (operator bootstrap
// and admin provisioning) authenticate against.
func ServiceToken() string {
for _, key := range []string{"HANZO_API_KEY", "KMS_SERVICE_TOKEN", "IAM_SERVICE_TOKEN"} {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
}
return ""
}
// ServiceAuth reports whether an `Authorization` header VALUE carries the unified
// service token, compared in constant time. An unset expected token, or any
// mismatch, is false — fail closed: no token configured means no service surface.
//
// It takes the header rather than the request because a TYPED op never sees a
// *zip.Ctx: the credential arrives on its input, declared `header:"Authorization"`,
// and the check has to run on that value. So this is the ONE implementation and
// ServiceTokenAuth is the same check on a raw handler's request — the same split
// as Good/Bad against Ok/Fail below, a value and a place.
func ServiceAuth(h string) bool {
expected := ServiceToken()
if expected == "" {
return false
}
got := token(h)
return got != "" && subtle.ConstantTimeCompare([]byte(got), []byte(expected)) == 1
}
// ServiceTokenAuth reports whether the request carries the unified service token as
// a Bearer credential.
func ServiceTokenAuth(c *zip.Ctx) bool { return ServiceAuth(c.Header("Authorization")) }
// Answer is a Response together with the status it rides on — the envelope as a
// VALUE, for a handler that returns its reply instead of writing it.
//
// A typed op is a function, so its answer has to BE a value: zip renders what the
// handler returns and there is no *zip.Ctx to write through. The status has to
// ride with it because this envelope's whole contract is that the two agree — a
// refusal is a 4xx carrying status:"error" — and a typed op that returned a bare
// Response would answer every refusal 200 and break exactly that.
//
// The wire shape is Response's and only Response's: the embedding promotes its
// fields, `code` is unexported, so an Answer and the Response inside it marshal
// to the same bytes. One envelope, two ways of holding it, no second shape to
// keep in sync.
//
// It is a distinct type rather than a method on Response because zip reads
// [zip.StatusCoder] off the value an op returns and refuses any status the op did
// not declare with zip.WithStatus. Response is already returned by typed ops that
// declare none (internal/compat), so teaching Response to state a status would
// make every one of them answer a status zip then refuses.
type Answer struct {
Response
code int
}
// StatusCode is [zip.StatusCoder]: the status this answer rides on. Zero means
// the answer never named one, and 200 is what an unnamed answer has always been.
func (a *Answer) StatusCode() int {
if a.code == 0 {
return 200
}
return a.code
}
// Good is the 200 { status:"ok", data } envelope. The success half of the pair,
// as a value.
func Good(data any, more ...any) *Answer {
a := &Answer{Response: Response{Status: "ok", Data: data}, code: 200}
if len(more) > 0 {
a.Data2 = more[0]
}
return a
}
// Bad is the { status:"error", msg, code } envelope under the status that
// matches it. The refusal half of the pair, as a value.
func Bad(status int, msg, code string) *Answer {
return &Answer{Response: Response{Status: "error", Msg: msg, Code: code}, code: status}
}
// Ok writes 200 { status:"ok", data }.
func Ok(c *zip.Ctx, data any, more ...any) error {
return write(c, Good(data, more...))
}
// Fail writes { status:"error", msg, code } under an HTTP status that MATCHES it.
// ONE implementation writes the error envelope; everything below names a status
// for it, and nothing else in this package may write one.
func Fail(c *zip.Ctx, status int, msg, code string) error {
return write(c, Bad(status, msg, code))
}
// write sends an Answer through a raw handler's Ctx. Unexported: a typed op
// RETURNS its answer and never needs this, so the only callers are the two
// writers above — which is what makes Good/Bad the one place each variant of the
// envelope is built, whether it is returned or written.
func write(c *zip.Ctx, a *Answer) error {
return c.JSON(a.StatusCode(), a.Response)
}
// Err writes a refusal the CALLER can act on: bad input, a credential we would
// not take, a name already spoken for. 400 is the honest default for this
// surface — these are front-door validation and authentication failures, and the
// caller is the one holding the thing that was wrong. A handler that knows better
// says so by calling Fail with the status it means.
func Err(c *zip.Ctx, msg string) error {
return ErrCode(c, msg, "")
}
// ErrCode is Err carrying a machine-readable reason alongside the human message.
func ErrCode(c *zip.Ctx, msg, code string) error {
return Fail(c, 400, msg, code)
}
// A note on 401. Several refusals here are authentication failures ("please sign
// in first", CodeLoginRequired) and 401 is their honest status. They are NOT
// spelled that way, deliberately: these handlers sit on the PRE-GUARD group, and
// the Guard's own refusal is a 401 too, so a handler that answered 401 would
// become indistinguishable from a route that was never public — which is exactly
// what internal/authz's public-route tests assert on. Separating those two needs
// the Guard to be told apart from a handler by something other than the status,
// which is a change to the authz surface and not to this envelope. Until then the
// machine-readable `code` carries the distinction, which is what it is for.
// Bearer returns the token from an `Authorization: Bearer <token>` header, or "".
func Bearer(c *zip.Ctx) string { return token(c.Header("Authorization")) }
// token is the credential an `Authorization: Bearer <token>` header VALUE carries,
// or "". The parse lives here once, for the request half and the value half alike.
func token(h string) string {
const p = "Bearer "
if len(h) > len(p) && h[:len(p)] == p {
return h[len(p):]
}
return ""
}
// Basic returns the (id, secret) an `Authorization: Basic <base64>` header carries,
// and whether it carried one — RFC 7617: base64 of "<id>:<secret>", split on the
// FIRST colon so a secret may contain one. This is the ONE Basic parser; a caller
// bound by RFC 6749 §2.3.1 (client_secret_basic, whose halves are form-urlencoded
// before the base64) form-decodes the two values afterwards.
func Basic(c *zip.Ctx) (id, secret string, ok bool) {
const p = "Basic "
h := c.Header("Authorization")
if len(h) <= len(p) || !strings.EqualFold(h[:len(p)], p) {
return "", "", false
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(h[len(p):]))
if err != nil {
return "", "", false
}
id, secret, found := strings.Cut(string(raw), ":")
if !found {
return "", "", false
}
return id, secret, true
}
// The request host is read through the ONE header-immune accessor, zip.Ctx.Host()
// — the same seam the OIDC issuer resolver uses. It ignores X-Forwarded-Host (zip
// has no trusted-proxy knob), so the brand host a client authenticates to cannot
// be spoofed by a request header. There is deliberately no EffectiveHost helper
// here: a second accessor that honored X-Forwarded-Host would reopen that spoof.
+216
View File
@@ -0,0 +1,216 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
// Package invitations serves the IAM v2 CRUD surface for the `invitations`
// entity: a pending org-membership invite owner-scoped by (owner, name). Every
// operation is a typed zip handler over hanzoai/orm; the orm string key is
// "owner/name". Reads scope to one owner (organization); writes address one
// invitation by its (owner, name) key.
package invitations
import (
"context"
"errors"
"github.com/hanzoai/iam/internal/authz"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/pkg/schema"
)
// Handler binds the invitations operations to one orm store.
type Handler struct {
db orm.DB
}
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
// Route registers the invitations CRUD routes on app against db.
func Route(app *zip.App, db orm.DB) {
h := &Handler{db: db}
zip.Get(app, "/v1/iam/invitations", h.List, zip.WithTags("invitations"))
zip.Post(app, "/v1/iam/invitations", h.Create, zip.WithTags("invitations"))
zip.Post(app, "/v1/iam/invitations/get", h.Get, zip.WithTags("invitations"))
zip.Post(app, "/v1/iam/invitations/update", h.Update, zip.WithTags("invitations"))
zip.Post(app, "/v1/iam/invitations/delete", h.Delete, zip.WithTags("invitations"))
}
// Ref addresses one invitation by its owner-scoped natural key.
type Ref struct {
Owner string `json:"owner"`
Name string `json:"name"`
}
// Input is the writable projection of an invitation (the v1 add/update-invitation
// body). It keeps the HTTP contract clean of the orm.Model bookkeeping fields.
type Input struct {
Owner string `json:"owner"`
Name string `json:"name"`
CreatedTime string `json:"createdTime"`
UpdatedTime string `json:"updatedTime"`
DisplayName string `json:"displayName"`
Code string `json:"code"`
IsRegexp bool `json:"isRegexp"`
Quota int `json:"quota"`
UsedCount int `json:"usedCount"`
Application string `json:"application"`
Username string `json:"username"`
Email string `json:"email"`
Phone string `json:"phone"`
SignupGroup string `json:"signupGroup"`
DefaultCode string `json:"defaultCode"`
State string `json:"state"`
}
// ListInput scopes a listing to one owner (organization).
type ListInput struct {
Owner string `json:"owner"`
}
// ListOutput is the owner-scoped page of invitations.
type ListOutput struct {
Invitations []*schema.Invitation `json:"invitations"`
Total int `json:"total"`
}
// DeleteOutput reports the delete result.
type DeleteOutput struct {
Deleted bool `json:"deleted"`
}
// key builds the orm string key from the (owner, name) natural key.
func key(owner, name string) string { return owner + "/" + name }
// apply copies the mutable domain fields of an Input onto an invitation. The
// identity fields (owner, name) and the created stamp are set only on Create,
// never overwritten by an update.
func apply(dst *schema.Invitation, in *Input) {
dst.UpdatedTime = in.UpdatedTime
dst.DisplayName = in.DisplayName
dst.Code = in.Code
dst.IsRegexp = in.IsRegexp
dst.Quota = in.Quota
dst.UsedCount = in.UsedCount
dst.Application = in.Application
dst.Username = in.Username
dst.Email = in.Email
dst.Phone = in.Phone
dst.SignupGroup = in.SignupGroup
dst.DefaultCode = in.DefaultCode
dst.State = in.State
}
// List returns your organization's invitations, newest first — who has
// been asked to join, on what terms, and how many seats each invitation still
// has left.
//
// You see your own organization's invitations and no one else's; which organization that
// is comes from your credentials, not from the request.
func (h *Handler) List(ctx context.Context, in *ListInput) (*ListOutput, error) {
// The owner is resolved by authz.Scope from the authenticated principal,
// never taken from the input: a tenant reads only its own org, a SuperAdmin
// reads the owner it asks for. Filtering on in.Owner instead was a confused
// deputy — the Guard authorizes on the query string, then a typed GET binds
// NOTHING from it (zip typed.go reads a body only for non-GET), so in.Owner
// arrived empty on every REST call and the "empty owner lists everything"
// branch returned every tenant.
owner, err := authz.Scope(ctx, in.Owner)
if err != nil {
return nil, err
}
q := orm.TypedQuery[schema.Invitation](h.db)
if owner != "" {
q = q.Filter("owner", owner)
}
invitations, err := q.Order("-createdTime").GetAll(ctx)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &ListOutput{Invitations: invitations, Total: len(invitations)}, nil
}
// Get returns one invitation: who it is for, what it grants on acceptance, and
// when it expires.
func (h *Handler) Get(ctx context.Context, in *Ref) (*schema.Invitation, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
invitation, err := orm.Get[schema.Invitation](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
return invitation, nil
}
// Create issues an invitation to join your organization — the code or link a new
// member redeems, with the role they arrive holding and the date it stops
// working. A name already used in the organization is refused.
func (h *Handler) Create(ctx context.Context, in *Input) (*schema.Invitation, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
switch _, err := orm.Get[schema.Invitation](h.db, key(in.Owner, in.Name)); {
case err == nil:
return nil, zip.ErrConflict("invitation already exists")
case !errors.Is(err, orm.ErrNotFound):
return nil, zip.ErrInternal(err.Error())
}
invitation := orm.New[schema.Invitation](h.db)
invitation.Owner = in.Owner
invitation.Name = in.Name
invitation.CreatedTime = in.CreatedTime
if invitation.CreatedTime == "" {
invitation.CreatedTime = time.Now().UTC().Format(time.RFC3339)
}
apply(invitation, in)
invitation.SetId(key(in.Owner, in.Name))
if err := invitation.CreateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return invitation, nil
}
// Update changes an invitation's terms — the role it grants, how many may redeem
// it, or when it expires. What it is called does not change.
func (h *Handler) Update(ctx context.Context, in *Input) (*schema.Invitation, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
invitation, err := orm.Get[schema.Invitation](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
apply(invitation, in)
if err := invitation.UpdateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return invitation, nil
}
// Delete withdraws an invitation. It stops being redeemable at once; anyone who
// already joined through it keeps their account.
func (h *Handler) Delete(ctx context.Context, in *Ref) (*DeleteOutput, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
invitation, err := orm.Get[schema.Invitation](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
if err := invitation.DeleteCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &DeleteOutput{Deleted: true}, nil
}
// mapErr translates an orm lookup error into the matching HTTP status.
func mapErr(err error) error {
if errors.Is(err, orm.ErrNotFound) {
return zip.ErrNotFound("invitation not found")
}
return zip.ErrInternal(err.Error())
}
+37
View File
@@ -0,0 +1,37 @@
// Code generated by zipdoc; DO NOT EDIT.
package invitations
import (
"github.com/zap-proto/zip"
)
func init() {
zip.Describe("GET /v1/iam/invitations", zip.Doc{
Description: "Returns your organization's invitations, newest first — who has\nbeen asked to join, on what terms, and how many seats each invitation still\nhas left.\n\nYou see your own organization's invitations and no one else's; which organization that\nis comes from your credentials, not from the request.",
Fields: map[string]string{
"Model[github.com/hanzoai/iam/pkg/schema.Invitation].id": "Persisted fields",
},
})
zip.Describe("POST /v1/iam/invitations", zip.Doc{
Description: "Issues an invitation to join your organization — the code or link a new\nmember redeems, with the role they arrive holding and the date it stops\nworking. A name already used in the organization is refused.",
Fields: map[string]string{
"Model[github.com/hanzoai/iam/pkg/schema.Invitation].id": "Persisted fields",
},
})
zip.Describe("POST /v1/iam/invitations/delete", zip.Doc{
Description: "Withdraws an invitation. It stops being redeemable at once; anyone who\nalready joined through it keeps their account.",
})
zip.Describe("POST /v1/iam/invitations/get", zip.Doc{
Description: "Returns one invitation: who it is for, what it grants on acceptance, and\nwhen it expires.",
Fields: map[string]string{
"Model[github.com/hanzoai/iam/pkg/schema.Invitation].id": "Persisted fields",
},
})
zip.Describe("POST /v1/iam/invitations/update", zip.Doc{
Description: "Changes an invitation's terms — the role it grants, how many may redeem\nit, or when it expires. What it is called does not change.",
Fields: map[string]string{
"Model[github.com/hanzoai/iam/pkg/schema.Invitation].id": "Persisted fields",
},
})
}
+380
View File
@@ -0,0 +1,380 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
// Package keys serves the owner-scoped CRUD surface for the `keys` entity
// (v1 the legacy surface `key`) as typed zip handlers over hanzoai/orm.
//
// Identity is the (owner, name) pair; it maps onto the orm storage id as
// "owner/name", exactly as the v1 record addressed itself. Reads are
// zip.Get[In,Out], writes are zip.Post[In,Out]; every handler closes over the
// one orm.DB entity store so the typed signatures carry no transport or
// storage plumbing.
package keys
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/pkg/schema"
)
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
// Route registers the key CRUD routes on app, binding each handler to db.
// Called from routes.Route once it is threaded the entity store.
//
// ONE noun, plural, for every op — the same shape users.Route uses
// (/v1/iam/users, /v1/iam/users/get, …). It used to be two nouns, `keys` for the
// list and `key` for everything else, and that was not merely inconsistent:
// authz.entityOf reads the FIRST path segment as the entity, so the list
// authorized on "keys" and every write on "key". Two entity strings for one
// entity means every capability keyed on it is dead on one of the two surfaces —
// the same defect entityNoun was written to fix for the legacy verb spellings.
func Route(app *zip.App, db orm.DB) {
zip.Get(app, "/v1/iam/keys", list(db), zip.WithTags("keys"))
zip.Post(app, "/v1/iam/keys", create(db), zip.WithTags("keys"))
zip.Get(app, "/v1/iam/keys/get", get(db), zip.WithTags("keys"))
zip.Post(app, "/v1/iam/keys/update", update(db), zip.WithTags("keys"))
zip.Post(app, "/v1/iam/keys/delete", del(db), zip.WithTags("keys"))
}
// ListRequest scopes a listing to one owner.
type ListRequest struct {
Owner string `json:"owner"`
}
// ListResponse is the owner-scoped key set, newest first.
type ListResponse struct {
Keys []schema.Key `json:"keys"`
}
// Ref addresses one key by its (owner, name) identity.
type Ref struct {
Owner string `json:"owner"`
Name string `json:"name"`
}
// DeleteResponse reports whether the key was removed.
type DeleteResponse struct {
Deleted bool `json:"deleted"`
}
// id joins the owner-scoped natural key into the orm storage id — the same
// "owner/name" identity the v1 record used.
func id(owner, name string) string { return owner + "/" + name }
// list returns your organization's API keys, newest first — what each is called,
// what it may reach, and its publishable half. Secret halves are never listed.
func list(db orm.DB) zip.TypedHandler[ListRequest, ListResponse] {
return func(ctx context.Context, in *ListRequest) (*ListResponse, error) {
if in.Owner == "" {
return nil, zip.ErrBadRequest("owner is required")
}
items, err := orm.TypedQuery[schema.Key](db).
Filter("Owner=", in.Owner).
Order("-CreatedTime").
GetAll(ctx)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
out := &ListResponse{Keys: make([]schema.Key, 0, len(items))}
for _, k := range items {
out.Keys = append(out.Keys, *k.Mask())
}
return out, nil
}
}
// get returns one API key: what it is called, what it may reach, and when it was
// issued.
func get(db orm.DB) zip.TypedHandler[Ref, schema.Key] {
return func(_ context.Context, in *Ref) (*schema.Key, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
k, err := orm.Get[schema.Key](db, id(in.Owner, in.Name))
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("key not found: " + id(in.Owner, in.Name))
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
return k.Mask(), nil
}
}
// create issues an API key. A standard key comes back as a publishable half you
// may ship in client code and a secret half you must not — the secret is shown
// once, at creation, and cannot be retrieved afterwards. A publish-scoped key is
// issued with the publishable half only, so there is no secret to leak.
//
// A name already used in your organization is refused rather than reissued, so
// creating twice never silently invalidates a key that is in production.
func create(db orm.DB) zip.TypedHandler[schema.Key, schema.Key] {
return func(ctx context.Context, in *schema.Key) (*schema.Key, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
if err := sameTenantUser(in); err != nil {
return nil, err
}
if _, err := orm.Get[schema.Key](db, id(in.Owner, in.Name)); err == nil {
return nil, zip.ErrConflict("key already exists: " + id(in.Owner, in.Name))
} else if !errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrInternal(err.Error())
}
k := orm.New[schema.Key](db)
k.SetId(id(in.Owner, in.Name))
k.Owner, k.Name = in.Owner, in.Name
apply(k, in)
// Scope is settable HERE and only here: it is the key's access class, chosen
// when the key is minted and fixed thereafter (apply deliberately does not
// carry it, so an update cannot flip a secret key to publish scope and blank
// its secret).
k.Scope = in.Scope
if k.AccessKey == "" {
k.AccessKey = Mint("pk", k.State)
}
if k.Scope == schema.KeyScopePublish {
// A publishable key is WRITE-ONLY: a pk- publishable half and NEVER a
// confidential sk- secret — even if the caller supplied one — so it can
// carry no full-access material. Its authority is resolved org-only at the
// ingest door (compat resolve-key → /v1/iam/resolve-key), never as a principal.
k.AccessSecret = ""
} else if k.AccessSecret == "" {
k.AccessSecret = Mint("sk", k.State)
}
now := time.Now().UTC().Format(time.RFC3339)
k.CreatedTime, k.UpdatedTime = now, now
if err := k.CreateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return k, nil
}
}
// update changes what a key is called or what it may reach. The credential
// itself is not reissued — the key in your deployment keeps working.
func update(db orm.DB) zip.TypedHandler[schema.Key, schema.Key] {
return func(ctx context.Context, in *schema.Key) (*schema.Key, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
k, err := orm.Get[schema.Key](db, id(in.Owner, in.Name))
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("key not found: " + id(in.Owner, in.Name))
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
if err := sameTenantUser(in); err != nil {
return nil, err
}
apply(k, in)
if k.Scope == schema.KeyScopePublish {
// Keep a publishable key write-only for its whole lifecycle: an update can
// never attach a confidential sk- secret to a pk--only browser key.
k.AccessSecret = ""
}
k.UpdatedTime = time.Now().UTC().Format(time.RFC3339)
if err := k.UpdateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
// An edit is not a mint: the secret is revealed ONCE, by create. Echoing it
// from every update would turn "rename this key" into "re-read its secret".
return k.Mask(), nil
}
}
// del revokes an API key. Anything still presenting it stops being authorized at
// once, so roll the replacement out before you revoke.
func del(db orm.DB) zip.TypedHandler[Ref, DeleteResponse] {
return func(ctx context.Context, in *Ref) (*DeleteResponse, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
k, err := orm.Get[schema.Key](db, id(in.Owner, in.Name))
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("key not found: " + id(in.Owner, in.Name))
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
if err := k.DeleteCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &DeleteResponse{Deleted: true}, nil
}
}
// sameTenantUser rejects a Key whose User field names a DIFFERENT owner than the key
// itself — the write-side half of the F1 credential-forgery gate (store.userOwningKey
// is the authoritative half). Key.User and the credential halves are all
// caller-supplied, and the key write is authorized only on (Owner, Name), so a
// "/"-qualified User naming "admin/z" or a victim tenant would otherwise persist and
// let a presented sk- secret resolve — via get-user?accessKey — to that foreign /
// SuperAdmin identity. (The public pk- half never resolves to a principal at all, so
// this gate protects the sk- read path.) A bare username or an empty User is fine
// (both resolve within the key's own owner); a cross-tenant qualified reference is
// refused, so no forged row is ever written.
func sameTenantUser(k *schema.Key) error {
if o, _, ok := strings.Cut(k.User, "/"); ok && o != k.Owner {
return zip.ErrBadRequest("key user must belong to the key's owner")
}
return nil
}
// apply copies the caller-settable fields from src onto dst, leaving the
// (owner, name) identity, storage id, audit stamps AND THE CREDENTIAL ITSELF under
// handler control.
//
// AccessKey/AccessSecret are deliberately NOT copied. They authenticate: a secret
// key's sk- half resolves its owning user by exact match (store.userOwningKey), so
// copying a caller-supplied value lets the sender choose a credential it already
// knows and then present it as that key's principal. Minting is the only writer.
// This matters more the moment the secret is stored as a digest rather than
// verbatim — a chosen digest is a forgery, not merely a chosen password.
//
// Scope is not copied either: it is the key's ACCESS CLASS, fixed at create. Letting
// an update flip a secret key to publish scope would blank its AccessSecret and make
// its pk- half org-resolvable at the ingest door — a privilege change disguised as an
// edit. Rotation and re-scoping are mint operations, not field writes.
func apply(dst, src *schema.Key) {
dst.DisplayName = src.DisplayName
dst.Type = src.Type
dst.Organization = src.Organization
dst.Application = src.Application
dst.User = src.User
dst.ExpireTime = src.ExpireTime
dst.State = src.State
}
// mint generates a prefixed credential half — "{pk|sk}-{live|test}-{random}"
// — mirroring the v1 key format. State == "test" selects the test env.
func Mint(prefix, state string) string {
env := "live"
if state == "test" {
env = "test"
}
var b [16]byte
_, _ = rand.Read(b[:])
return fmt.Sprintf("%s-%s-%s", prefix, env, hex.EncodeToString(b[:]))
}
// UserKeyName and PublishKeyName are the deterministic Names of the ONE key a user
// holds AT EACH SCOPE. Deterministic so a re-mint REPLACES the previous credential
// instead of leaving a second live one behind — a user has one key per scope, and
// revoking it revokes them at that scope.
//
// Two rows, not one, because the two scopes are different credentials with opposite
// exposure: the secret key authenticates its holder as the user, the publishable key
// resolves to an org and is shipped in client JS. Holding both is the normal case (a
// server SDK and a browser beacon), and rotating the browser key must not sign the
// user out of their own API.
const (
UserKeyName = "cloud-api"
PublishKeyName = "publishable"
)
// NameFor is the deterministic key Name for a scope: the ONE mapping from a key's
// access class to the row that holds it, so mint, revoke and read can never
// disagree about which row a scope means.
func NameFor(scope string) string {
if scope == schema.KeyScopePublish {
return PublishKeyName
}
return UserKeyName
}
// MintUserKey (re)mints the single credential a user holds at `scope` and returns the
// half its holder presents — revealed once:
//
// - "" (the default, secret): the confidential sk- half. Resolves to the USER
// (store.userOwningKey queries schema.Key.AccessSecret), so it is session-
// equivalent and must never be shipped to a browser.
// - schema.KeyScopePublish: the publishable pk- half, and NO secret is stored at
// all. Resolves to just the ORG (store.PublishableKeyByAccessKey), never a
// principal, which is exactly what makes it safe in client JS. This is the ONLY
// path that mints one, and its absence is why every surface configured its own
// ingest credential.
//
// It writes a schema.Key row because that is the ONLY thing the resolvers read. The
// previous implementation stamped the sk- onto schema.User.AccessKey, which NOTHING
// resolves: every key minted that way authenticated nobody. Writing the row the
// resolver actually reads is the fix.
//
// Idempotent by (Owner, NameFor(scope)): re-minting replaces the credential in place.
func MintUserKey(ctx context.Context, db orm.DB, owner, user, scope string) (string, error) {
if strings.TrimSpace(owner) == "" || strings.TrimSpace(user) == "" {
return "", fmt.Errorf("keys: owner and user are required")
}
publish := scope == schema.KeyScopePublish
// The credential the holder presents, and the ONE value returned. A publishable
// key has no secret half — not an empty one, none — so there is nothing else it
// could return and nothing a leak of the row could reveal.
access, secret := Mint("pk", ""), Mint("sk", "")
presented := secret
if publish {
secret = ""
presented = access
}
name := NameFor(scope)
now := time.Now().UTC().Format(time.RFC3339)
existing, err := orm.TypedQuery[schema.Key](db).Filter("Id=", id(owner, name)).First()
if err != nil && !errors.Is(err, orm.ErrNotFound) {
return "", err
}
if existing != nil {
existing.AccessKey, existing.AccessSecret = access, secret
existing.User, existing.Type, existing.Scope = user, "User", scope
existing.UpdatedTime = now
if err := existing.UpdateCtx(ctx); err != nil {
return "", err
}
return presented, nil
}
k := orm.New[schema.Key](db)
k.SetId(id(owner, name))
k.Owner, k.Name = owner, name
k.DisplayName = "Cloud API key"
if publish {
k.DisplayName = "Publishable key"
}
k.Type, k.User = "User", user
k.AccessKey = access
k.AccessSecret = secret
k.Scope = scope
k.State = "Active"
k.CreatedTime, k.UpdatedTime = now, now
if err := k.CreateCtx(ctx); err != nil {
return "", err
}
return presented, nil
}
// RevokeUserKey deletes the user's key row at `scope`. Absent is success — revoke is
// a statement about the END state, so a caller can always assert "this user holds no
// credential" without racing a prior revoke. Scoped, so revoking the browser key
// leaves the server key working and vice versa.
func RevokeUserKey(ctx context.Context, db orm.DB, owner, scope string) error {
k, err := orm.TypedQuery[schema.Key](db).Filter("Id=", id(owner, NameFor(scope))).First()
if errors.Is(err, orm.ErrNotFound) || k == nil {
return nil
}
if err != nil {
return err
}
return k.DeleteCtx(ctx)
}
+394
View File
@@ -0,0 +1,394 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package keys
import (
"context"
"path/filepath"
"strings"
"testing"
"github.com/hanzoai/orm"
ormdb "github.com/hanzoai/orm/db"
"github.com/hanzoai/iam/pkg/schema"
)
func memDB(t *testing.T) orm.DB {
t.Helper()
_ = schema.Kinds()
dir := t.TempDir()
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(dir, "keys.db"),
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
// F1 write-side gate: keys.create and keys.update must REJECT a Key whose User field
// names a different owner than the key — the row that would let get-user?accessKey
// forge a cross-tenant / SuperAdmin identity can never be persisted. A same-owner or
// bare User is accepted.
func TestKeys_RejectCrossTenantUserOnWrite(t *testing.T) {
db := memDB(t)
ctx := context.Background()
c := create(db)
// Cross-tenant qualified User → rejected (attacker in "a" pointing at admin/z).
if _, err := c(ctx, &schema.Key{Owner: "a", Name: "forge", User: "admin/z"}); err == nil {
t.Fatal("create accepted a cross-tenant User reference (forgery row)")
}
// Same-owner qualified User → accepted.
ok, err := c(ctx, &schema.Key{Owner: "a", Name: "own", User: "a/alice"})
if err != nil || ok == nil {
t.Fatalf("create rejected a same-owner User: %v", err)
}
// Bare username → accepted (resolves within the key's own owner).
if _, err := c(ctx, &schema.Key{Owner: "a", Name: "bare", User: "bob"}); err != nil {
t.Fatalf("create rejected a bare username: %v", err)
}
// update must enforce it too: flipping an existing key's User cross-tenant fails.
u := update(db)
if _, err := u(ctx, &schema.Key{Owner: "a", Name: "own", User: "victimorg/ceo"}); err == nil {
t.Fatal("update accepted a cross-tenant User reference (forgery row)")
}
// A same-owner update still works.
if _, err := u(ctx, &schema.Key{Owner: "a", Name: "own", User: "a/carol"}); err != nil {
t.Fatalf("update rejected a same-owner User: %v", err)
}
}
// A publishable key (Scope=publish) mints a pk- publishable half ONLY — never a
// confidential sk- secret — so it can carry no full-access material. A default key
// still mints BOTH halves (its sk- is the reader-authenticating credential).
func TestKeys_PublishableMintsNoSecret(t *testing.T) {
db := memDB(t)
ctx := context.Background()
c := create(db)
pub, err := c(ctx, &schema.Key{Owner: "hanzo", Name: "site", Scope: schema.KeyScopePublish})
if err != nil {
t.Fatalf("create publish key: %v", err)
}
if !strings.HasPrefix(pub.AccessKey, "pk-") {
t.Fatalf("publish key AccessKey = %q, want a pk-", pub.AccessKey)
}
if pub.AccessSecret != "" {
t.Fatalf("publish key minted a secret %q, want none (write-only)", pub.AccessSecret)
}
def, err := c(ctx, &schema.Key{Owner: "hanzo", Name: "server"})
if err != nil {
t.Fatalf("create default key: %v", err)
}
if !strings.HasPrefix(def.AccessKey, "pk-") || !strings.HasPrefix(def.AccessSecret, "sk-") {
t.Fatalf("default key halves = %q/%q, want pk-/sk-", def.AccessKey, def.AccessSecret)
}
}
// A publishable key is write-only even if the caller SUPPLIES a secret: create and
// update both force AccessSecret empty, so a browser key can never carry a confidential
// half for its whole lifecycle.
func TestKeys_PublishableForcesSecretEmpty(t *testing.T) {
db := memDB(t)
ctx := context.Background()
// Caller tries to smuggle a secret onto a publish key at create.
pub, err := create(db)(ctx, &schema.Key{
Owner: "hanzo", Name: "site", Scope: schema.KeyScopePublish,
AccessKey: "pk-live-CHOSEN", AccessSecret: "sk-live-SMUGGLED",
})
if err != nil {
t.Fatalf("create: %v", err)
}
if pub.AccessSecret != "" {
t.Fatalf("create let a publish key keep a supplied secret %q", pub.AccessSecret)
}
// And again at update — the invariant holds across the key's lifecycle.
upd, err := update(db)(ctx, &schema.Key{
Owner: "hanzo", Name: "site", Scope: schema.KeyScopePublish,
AccessSecret: "sk-live-SMUGGLED2",
})
if err != nil {
t.Fatalf("update: %v", err)
}
if upd.AccessSecret != "" {
t.Fatalf("update let a publish key gain a secret %q", upd.AccessSecret)
}
}
// The round-trip that did not exist, and whose absence let a dead credential ship:
// a key minted by mint-user-keys MUST resolve back to the user it was minted for.
//
// It did not. mintUserKeysHandler stamped the sk- onto schema.User.AccessKey, while
// store.UserByAccessKey's sk- branch reads schema.Key.AccessSecret — the write and
// the read never met, so every minted key authenticated nobody.
func TestMintUserKey_ResolvesBackToItsUser(t *testing.T) {
db := memDB(t)
ctx := context.Background()
secret, err := MintUserKey(ctx, db, "acme", "ada", "")
if err != nil {
t.Fatalf("MintUserKey: %v", err)
}
if !strings.HasPrefix(secret, "sk-") {
t.Fatalf("minted secret = %q, want an sk- confidential half", secret[:3])
}
// The row the resolver reads must exist, name its user, and hold the secret.
k, err := orm.TypedQuery[schema.Key](db).Filter("AccessSecret=", secret).First()
if err != nil || k == nil {
t.Fatalf("no schema.Key row resolves the minted secret (err=%v) — this is the bug", err)
}
if k.User != "ada" || k.Owner != "acme" {
t.Fatalf("key resolves to %s/%s, want acme/ada", k.Owner, k.User)
}
if !strings.HasPrefix(k.AccessKey, "pk-") {
t.Fatalf("publishable half = %q, want pk-", k.AccessKey)
}
if k.Scope == schema.KeyScopePublish {
t.Fatal("a user's authenticating key must NOT be publish-scoped")
}
}
// Re-minting REPLACES the credential rather than leaving a second live secret: a
// user holds one key, so revoking it revokes them.
func TestMintUserKey_RemintReplacesRatherThanAccumulates(t *testing.T) {
db := memDB(t)
ctx := context.Background()
first, err := MintUserKey(ctx, db, "acme", "ada", "")
if err != nil {
t.Fatalf("first mint: %v", err)
}
second, err := MintUserKey(ctx, db, "acme", "ada", "")
if err != nil {
t.Fatalf("re-mint: %v", err)
}
if first == second {
t.Fatal("re-mint returned the same secret; it must rotate")
}
if old, _ := orm.TypedQuery[schema.Key](db).Filter("AccessSecret=", first).First(); old != nil {
t.Fatal("the superseded secret still resolves — a revoked key would stay live")
}
if cur, err := orm.TypedQuery[schema.Key](db).Filter("AccessSecret=", second).First(); err != nil || cur == nil {
t.Fatalf("the current secret does not resolve: %v", err)
}
}
// Revoke is a statement about the END state: after it, the user holds nothing, and
// revoking again is still success (a caller may always assert "holds no credential").
func TestRevokeUserKey_EndStateAndIdempotent(t *testing.T) {
db := memDB(t)
ctx := context.Background()
secret, err := MintUserKey(ctx, db, "acme", "ada", "")
if err != nil {
t.Fatalf("mint: %v", err)
}
if err := RevokeUserKey(ctx, db, "acme", ""); err != nil {
t.Fatalf("revoke: %v", err)
}
if k, _ := orm.TypedQuery[schema.Key](db).Filter("AccessSecret=", secret).First(); k != nil {
t.Fatal("secret still resolves after revoke")
}
if err := RevokeUserKey(ctx, db, "acme", ""); err != nil {
t.Fatalf("revoke on an already-revoked user must succeed, got %v", err)
}
}
// A caller must not be able to CHOOSE a key's credential. The sk- half resolves its
// owning user by exact match (store.userOwningKey), so a body that carries one lets
// the sender pick a secret it already knows and then present it as that principal.
// This is a forgery primitive the moment the secret is stored as a digest.
func TestKeys_CallerCannotChooseTheCredential(t *testing.T) {
db := memDB(t)
ctx := context.Background()
k, err := create(db)(ctx, &schema.Key{
Owner: "acme", Name: "planted",
AccessKey: "pk-live-chosen-by-caller",
AccessSecret: "sk-live-chosen-by-caller",
})
if err != nil {
t.Fatalf("create: %v", err)
}
if k.AccessSecret == "sk-live-chosen-by-caller" {
t.Fatal("caller-supplied AccessSecret was persisted — a chosen credential is a forgery")
}
if k.AccessKey == "pk-live-chosen-by-caller" {
t.Fatal("caller-supplied AccessKey was persisted")
}
if !strings.HasPrefix(k.AccessSecret, "sk-") || !strings.HasPrefix(k.AccessKey, "pk-") {
t.Fatalf("both halves must be minted; got key=%q secret-prefix=%q", k.AccessKey, k.AccessSecret[:3])
}
}
// Scope is the ACCESS CLASS and is fixed at mint. An update that could flip a secret
// key to publish scope would blank its secret and make its pk- half org-resolvable at
// the ingest door — a privilege change wearing the clothes of a profile edit.
func TestKeys_UpdateCannotReScopeOrRotate(t *testing.T) {
db := memDB(t)
ctx := context.Background()
made, err := create(db)(ctx, &schema.Key{Owner: "acme", Name: "svc"})
if err != nil {
t.Fatalf("create: %v", err)
}
secret, access := made.AccessSecret, made.AccessKey
got, err := update(db)(ctx, &schema.Key{
Owner: "acme", Name: "svc",
DisplayName: "renamed",
Scope: schema.KeyScopePublish,
AccessSecret: "sk-live-attacker",
AccessKey: "pk-live-attacker",
})
if err != nil {
t.Fatalf("update: %v", err)
}
if got.Scope == schema.KeyScopePublish {
t.Fatal("update re-scoped a secret key to publish — that blanks the secret and opens the ingest door")
}
// Assert on the STORED row, not the response: the response is masked (an edit is
// not a mint), so reading the secret back out of it would only ever prove the mask.
stored, err := orm.Get[schema.Key](db, "acme/svc")
if err != nil {
t.Fatalf("read back: %v", err)
}
if stored.AccessSecret != secret || stored.AccessKey != access {
t.Fatal("update rotated the credential to caller-supplied values")
}
if got.AccessSecret != "" {
t.Fatalf("update echoed the confidential secret %q — the secret is revealed once, by create", got.AccessSecret)
}
if got.DisplayName != "renamed" {
t.Fatalf("update failed to apply a legitimately mutable field: %q", got.DisplayName)
}
}
// The gap that made a publishable key unmintable: there was NO path anywhere that
// produced one for a user. IAM owned the model (schema.KeyScopePublish), the resolver
// (store.PublishableKeyByAccessKey) and the ingest door (compat resolve-key), and
// nothing minted the credential they were written for — so every surface configured
// its own thing. Type is a field on the ONE mint, and this is the proof it works.
func TestMintUserKey_PublishableTypeMintsAPublicKeyAndNoSecret(t *testing.T) {
db := memDB(t)
ctx := context.Background()
got, err := MintUserKey(ctx, db, "acme", "ada", schema.KeyScopePublish)
if err != nil {
t.Fatalf("MintUserKey(publish): %v", err)
}
if !strings.HasPrefix(got, "pk-") {
t.Fatalf("publishable mint returned %q, want the pk- half — a browser key is the value you ship", got)
}
k, err := orm.Get[schema.Key](db, "acme/"+PublishKeyName)
if err != nil {
t.Fatalf("no publishable key row: %v", err)
}
if k.Scope != schema.KeyScopePublish {
t.Fatalf("row scope = %q, want %q — the resolver refuses anything else", k.Scope, schema.KeyScopePublish)
}
if k.AccessSecret != "" {
t.Fatalf("a publishable key stored a confidential secret %q — it must have no secret half at all", k.AccessSecret)
}
if k.AccessKey != got {
t.Fatalf("returned %q but stored %q; the value handed out must be the value that resolves", got, k.AccessKey)
}
if k.User != "ada" || k.Owner != "acme" {
t.Fatalf("publishable key filed under %s/%s, want acme/ada", k.Owner, k.User)
}
}
// The two scopes are two rows, so a user holds both at once and rotating one does not
// touch the other. One row would make "rotate the key in my browser bundle" also sign
// the holder out of their own API.
func TestMintUserKey_ScopesAreIndependentCredentials(t *testing.T) {
db := memDB(t)
ctx := context.Background()
secret, err := MintUserKey(ctx, db, "acme", "ada", "")
if err != nil {
t.Fatalf("mint secret: %v", err)
}
pub, err := MintUserKey(ctx, db, "acme", "ada", schema.KeyScopePublish)
if err != nil {
t.Fatalf("mint publishable: %v", err)
}
if k, _ := orm.TypedQuery[schema.Key](db).Filter("AccessSecret=", secret).First(); k == nil {
t.Fatal("minting the publishable key destroyed the secret key")
}
// Rotating the publishable key leaves the secret key alone…
pub2, err := MintUserKey(ctx, db, "acme", "ada", schema.KeyScopePublish)
if err != nil {
t.Fatalf("re-mint publishable: %v", err)
}
if pub2 == pub {
t.Fatal("re-minting the publishable key did not rotate it")
}
if k, _ := orm.TypedQuery[schema.Key](db).Filter("AccessSecret=", secret).First(); k == nil {
t.Fatal("rotating the publishable key revoked the secret key")
}
// …and revoking it likewise.
if err := RevokeUserKey(ctx, db, "acme", schema.KeyScopePublish); err != nil {
t.Fatalf("revoke publishable: %v", err)
}
if _, err := orm.Get[schema.Key](db, "acme/"+PublishKeyName); err == nil {
t.Fatal("publishable key survived its own revoke")
}
if k, _ := orm.TypedQuery[schema.Key](db).Filter("AccessSecret=", secret).First(); k == nil {
t.Fatal("revoking the publishable key revoked the secret key — the whole reason they are separate rows")
}
}
// A key LIST must never carry a confidential secret. Before schema.Key.Mask the list
// handed every reader every sk- in the org verbatim, which meant read AUTHORIZATION
// was standing in for redaction — and so widening who may see their own keys could
// not be done safely. The publishable half survives the mask on purpose: it is the
// value the holder needs, and it authenticates nobody.
func TestKeys_ReadsMaskTheSecretAndKeepThePublishableHalf(t *testing.T) {
db := memDB(t)
ctx := context.Background()
made, err := create(db)(ctx, &schema.Key{Owner: "acme", Name: "svc", User: "ada"})
if err != nil {
t.Fatalf("create: %v", err)
}
if made.AccessSecret == "" {
t.Fatal("create must reveal the secret ONCE, or a minted key is unusable")
}
listed, err := list(db)(ctx, &ListRequest{Owner: "acme"})
if err != nil || len(listed.Keys) != 1 {
t.Fatalf("list: %v (%d keys)", err, len(listed.Keys))
}
if listed.Keys[0].AccessSecret != "" {
t.Fatalf("list disclosed the confidential secret %q", listed.Keys[0].AccessSecret)
}
if listed.Keys[0].AccessKey != made.AccessKey {
t.Fatalf("list blanked the publishable half (%q); the holder needs it", listed.Keys[0].AccessKey)
}
one, err := get(db)(ctx, &Ref{Owner: "acme", Name: "svc"})
if err != nil {
t.Fatalf("get: %v", err)
}
if one.AccessSecret != "" {
t.Fatalf("get disclosed the confidential secret %q", one.AccessSecret)
}
// And the STORED secret is untouched — the mask is a projection, not a deletion.
stored, err := orm.Get[schema.Key](db, "acme/svc")
if err != nil || stored.AccessSecret != made.AccessSecret {
t.Fatal("masking a read mutated the stored credential")
}
}
+65
View File
@@ -0,0 +1,65 @@
// Code generated by zipdoc; DO NOT EDIT.
package keys
import (
"github.com/zap-proto/zip"
)
func init() {
zip.Describe("GET /v1/iam/keys", zip.Doc{
Description: "Returns your organization's API keys, newest first — what each is called,\nwhat it may reach, and its publishable half. Secret halves are never listed.",
Fields: map[string]string{
"Key.accessKey": "AccessKey (pk-*) is the publishable identifier and lookup index;\nAccessSecret (sk-*) is the confidential secret.",
"Key.createdTime": "CreatedTime and UpdatedTime are RFC3339 audit stamps carried as strings\nfor byte-parity with the v1 row (orm.Model separately tracks CreatedAt /\nUpdatedAt as time.Time for the store's own lifecycle).",
"Key.displayName": "DisplayName is the human-facing label.",
"Key.expireTime": "ExpireTime is when the key stops being honored (empty = never). State is\nthe lifecycle flag (\"Active\", \"test\", …); \"test\" mints test-env\ncredentials instead of live ones.",
"Key.owner": "Owner is the tenant that holds the key; Name is unique within Owner.",
"Key.scope": "Scope is the key's ACCESS CLASS, orthogonal to Type (which names the bound\nprincipal). Empty (the default, \"secret\") is a full key: a pk- publishable\nhalf AND a confidential sk- half, the sk- authenticating a server-side reader.\nKeyScopePublish is a WRITE-ONLY publishable key — a pk- half only, no secret —\nthat resolves to just an ORG (never a principal) at the ingest door and is safe\nto ship in client JS. A missing value on an existing row reads as the default,\nso every pre-Scope key is a secret key unchanged.",
"Key.type": "Type is the scope the key is bound to — \"Organization\", \"Application\",\n\"User\", or \"General\" — and Organization / Application / User name the\nconcrete principal for whichever scope Type selects.",
"Model[github.com/hanzoai/iam/pkg/schema.Key].id": "Persisted fields",
},
})
zip.Describe("GET /v1/iam/keys/get", zip.Doc{
Description: "Returns one API key: what it is called, what it may reach, and when it was\nissued.",
Fields: map[string]string{
"Key.accessKey": "AccessKey (pk-*) is the publishable identifier and lookup index;\nAccessSecret (sk-*) is the confidential secret.",
"Key.createdTime": "CreatedTime and UpdatedTime are RFC3339 audit stamps carried as strings\nfor byte-parity with the v1 row (orm.Model separately tracks CreatedAt /\nUpdatedAt as time.Time for the store's own lifecycle).",
"Key.displayName": "DisplayName is the human-facing label.",
"Key.expireTime": "ExpireTime is when the key stops being honored (empty = never). State is\nthe lifecycle flag (\"Active\", \"test\", …); \"test\" mints test-env\ncredentials instead of live ones.",
"Key.owner": "Owner is the tenant that holds the key; Name is unique within Owner.",
"Key.scope": "Scope is the key's ACCESS CLASS, orthogonal to Type (which names the bound\nprincipal). Empty (the default, \"secret\") is a full key: a pk- publishable\nhalf AND a confidential sk- half, the sk- authenticating a server-side reader.\nKeyScopePublish is a WRITE-ONLY publishable key — a pk- half only, no secret —\nthat resolves to just an ORG (never a principal) at the ingest door and is safe\nto ship in client JS. A missing value on an existing row reads as the default,\nso every pre-Scope key is a secret key unchanged.",
"Key.type": "Type is the scope the key is bound to — \"Organization\", \"Application\",\n\"User\", or \"General\" — and Organization / Application / User name the\nconcrete principal for whichever scope Type selects.",
"Model[github.com/hanzoai/iam/pkg/schema.Key].id": "Persisted fields",
},
})
zip.Describe("POST /v1/iam/keys", zip.Doc{
Description: "Issues an API key. A standard key comes back as a publishable half you\nmay ship in client code and a secret half you must not — the secret is shown\nonce, at creation, and cannot be retrieved afterwards. A publish-scoped key is\nissued with the publishable half only, so there is no secret to leak.\n\nA name already used in your organization is refused rather than reissued, so\ncreating twice never silently invalidates a key that is in production.",
Fields: map[string]string{
"Key.accessKey": "AccessKey (pk-*) is the publishable identifier and lookup index;\nAccessSecret (sk-*) is the confidential secret.",
"Key.createdTime": "CreatedTime and UpdatedTime are RFC3339 audit stamps carried as strings\nfor byte-parity with the v1 row (orm.Model separately tracks CreatedAt /\nUpdatedAt as time.Time for the store's own lifecycle).",
"Key.displayName": "DisplayName is the human-facing label.",
"Key.expireTime": "ExpireTime is when the key stops being honored (empty = never). State is\nthe lifecycle flag (\"Active\", \"test\", …); \"test\" mints test-env\ncredentials instead of live ones.",
"Key.owner": "Owner is the tenant that holds the key; Name is unique within Owner.",
"Key.scope": "Scope is the key's ACCESS CLASS, orthogonal to Type (which names the bound\nprincipal). Empty (the default, \"secret\") is a full key: a pk- publishable\nhalf AND a confidential sk- half, the sk- authenticating a server-side reader.\nKeyScopePublish is a WRITE-ONLY publishable key — a pk- half only, no secret —\nthat resolves to just an ORG (never a principal) at the ingest door and is safe\nto ship in client JS. A missing value on an existing row reads as the default,\nso every pre-Scope key is a secret key unchanged.",
"Key.type": "Type is the scope the key is bound to — \"Organization\", \"Application\",\n\"User\", or \"General\" — and Organization / Application / User name the\nconcrete principal for whichever scope Type selects.",
"Model[github.com/hanzoai/iam/pkg/schema.Key].id": "Persisted fields",
},
})
zip.Describe("POST /v1/iam/keys/delete", zip.Doc{
Description: "Revokes an API key. Anything still presenting it stops being authorized at\nonce, so roll the replacement out before you revoke.",
})
zip.Describe("POST /v1/iam/keys/update", zip.Doc{
Description: "Changes what a key is called or what it may reach. The credential\nitself is not reissued — the key in your deployment keeps working.",
Fields: map[string]string{
"Key.accessKey": "AccessKey (pk-*) is the publishable identifier and lookup index;\nAccessSecret (sk-*) is the confidential secret.",
"Key.createdTime": "CreatedTime and UpdatedTime are RFC3339 audit stamps carried as strings\nfor byte-parity with the v1 row (orm.Model separately tracks CreatedAt /\nUpdatedAt as time.Time for the store's own lifecycle).",
"Key.displayName": "DisplayName is the human-facing label.",
"Key.expireTime": "ExpireTime is when the key stops being honored (empty = never). State is\nthe lifecycle flag (\"Active\", \"test\", …); \"test\" mints test-env\ncredentials instead of live ones.",
"Key.owner": "Owner is the tenant that holds the key; Name is unique within Owner.",
"Key.scope": "Scope is the key's ACCESS CLASS, orthogonal to Type (which names the bound\nprincipal). Empty (the default, \"secret\") is a full key: a pk- publishable\nhalf AND a confidential sk- half, the sk- authenticating a server-side reader.\nKeyScopePublish is a WRITE-ONLY publishable key — a pk- half only, no secret —\nthat resolves to just an ORG (never a principal) at the ingest door and is safe\nto ship in client JS. A missing value on an existing row reads as the default,\nso every pre-Scope key is a secret key unchanged.",
"Key.type": "Type is the scope the key is bound to — \"Organization\", \"Application\",\n\"User\", or \"General\" — and Organization / Application / User name the\nconcrete principal for whichever scope Type selects.",
"Model[github.com/hanzoai/iam/pkg/schema.Key].id": "Persisted fields",
},
})
}
+232
View File
@@ -0,0 +1,232 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
// Package memberships serves the (User × Org × Role) tenancy relation — which
// orgs an identity may act in, and with what coarse role. It is the set a token
// carries as the `orgs` claim, which is what lets the edge authorize an
// org-switch statelessly (X-Org-Id ∈ orgs).
//
// A user's HOME org (User.Owner) is always an implicit membership — the token
// consumer treats it as one — so an explicit row is only ever needed for a TEAM
// org the identity was invited into. The boot backfill seeds the home row anyway,
// so an org's roster is complete from one query.
//
// This is the transport face. The relation's operations are store's
// (EnsureMembership, MembershipsByUser/ByOrg), because the token mint needs them
// too and it sits below the authorization seam this face sits above.
package memberships
import (
"context"
"strings"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/authz"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
)
// Path is the REST verb face: GET lists by ?user= or ?org=, POST ensures one.
//
// PathGet/PathAdd/PathDelete are the legacy VERB spellings the cloud team-invite
// path (clients/team/invite.go) hard-codes — get-memberships / add-membership /
// delete-membership. They are aliases, not a second implementation: get/add reuse
// the very handlers the REST face registers, and delete is the one handler REST
// does not expose. So a backend swap serves the cloud verbs with the SAME store and
// the SAME authz gates as the native REST surface.
const (
Path = "/v1/iam/memberships"
PathGet = "/v1/iam/get-memberships"
PathAdd = "/v1/iam/add-membership"
PathDelete = "/v1/iam/delete-membership"
)
// unauthorized is v1's refusal message, verbatim.
const unauthorized = "auth:Unauthorized operation"
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
// Route registers the membership surface on app, backed by db: the native REST
// pair plus the legacy verb aliases. get/add share the REST handlers (one authz
// gate, one store call, no duplication); delete adds the revoke the REST face does
// not carry. get-memberships is a GET whose target rides in ?user=/?org=, so it is
// handler-authorized (authz.handlerAuthorizedPrefixes) exactly like /v1/iam/
// memberships — the list handler's own scoped() check is the tenant gate; the two
// write verbs are POSTs the Guard never pre-authorizes, so each self-authorizes.
//
// The two READS are typed ops, so both addresses are in the OpenAPI document, the
// SDKs, the CLI and the MCP tool list. NEITHER names an operationId: what
// distinguishes them IS the address, so the address names them (zip's path-derived
// default), and a hand-picked id would collide — one operationId, one operation.
// The writes stay raw: typing them would newly route them through the op-invoke
// authorizer on a decoded (Owner, Name) their bodies do not carry, changing who
// may grant. That is a decision, not a projection.
//
// A typed read still reaches that authorizer, and is admitted by construction: it
// admits a GET whose decoded input names no owner, and `lookup` declares no Owner
// field and no AuthzTarget() for it to read. scoped() remains the whole tenant
// gate. A refusal is a VALUE (httpx.Bad), never a returned error — an error
// renders zip's {"status":<int>,"error":…} instead of this surface's envelope.
func Route(app *zip.App, db orm.DB) {
zip.Get[lookup, httpx.Answer](app, Path, list(db),
zip.WithStatus(200, 400),
zip.WithTags("memberships"))
app.Post(Path, ensure(db))
zip.Get[lookup, httpx.Answer](app, PathGet, list(db),
zip.WithStatus(200, 400),
zip.WithTags("memberships"))
app.Post(PathAdd, ensure(db))
app.Post(PathDelete, remove(db))
}
// lookup is the list request: exactly one of the identity whose organizations are
// wanted, or the organization whose roster is.
type lookup struct {
// User is "<homeOrg>/<username>" — which organizations that identity may act in.
User string `json:"user"`
// Org is an organization — who may act in it.
Org string `json:"org"`
}
// request is the ensure body.
type request struct {
User string `json:"user"` // "<homeOrg>/<username>"
Org string `json:"org"`
Role string `json:"role"`
}
// list answers either question about who belongs where: which organizations one
// person can act in, or who can act in one organization.
//
// Both are org-scoped: a non-SuperAdmin may ask about ITS OWN org's roster, or
// about a user whose home org is its own, and nothing else. The bound comes from
// the verified credential via authz.Scope, so a request parameter can never
// widen it — a membership row names who may act and spend in an org, so a
// cross-tenant read is a customer roster leak.
func list(db orm.DB) zip.TypedHandler[lookup, httpx.Answer] {
return func(ctx context.Context, in *lookup) (*httpx.Answer, error) {
if (in.User == "") == (in.Org == "") {
return httpx.Bad(400, "exactly one of user or org is required", ""), nil
}
if in.Org != "" {
if !scoped(ctx, in.Org) {
return httpx.Bad(400, unauthorized, ""), nil
}
return listed(store.MembershipsByOrg(ctx, db, in.Org))
}
// A user id is "<homeOrg>/<name>": its home org is the tenant bound here.
home, _, found := strings.Cut(in.User, "/")
if !found || home == "" {
return httpx.Bad(400, "user must be <owner>/<name>", ""), nil
}
if !scoped(ctx, home) {
return httpx.Bad(400, unauthorized, ""), nil
}
return listed(store.MembershipsByUser(ctx, db, in.User))
}
}
// ensure lets a person or an application act in an organization. It is the grant
// behind "add someone to the team", and it is safe to repeat — granting a
// membership that already exists changes nothing. Granting membership IS the org's authority to give, so it takes the
// same gate a write to that org's own registry row takes: a SuperAdmin, an admin
// of the org itself, or an org-admin-capable confidential client. One rule, one
// place (internal/authz).
func ensure(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
var in request
if err := c.Bind(&in); err != nil {
return httpx.Err(c, err.Error())
}
if in.User == "" || in.Org == "" {
return httpx.Err(c, "user and org are required")
}
switch in.Role {
case store.RoleOwner, store.RoleAdmin, store.RoleMember:
case "":
in.Role = store.RoleMember
default:
return httpx.Err(c, "role must be owner, admin, or member")
}
if !mayGrant(ctx, in.Org) {
return httpx.Err(c, unauthorized)
}
added, err := store.EnsureMembership(ctx, db, in.User, in.Org, in.Role)
if err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, added)
}
}
// remove takes away a person's or an application's right to act in an
// organization. Their account survives; what ends is their access to that
// organization. Revoking a membership that is already gone reports that nothing
// was removed rather than failing, so a retry is safe. It is the mirror of ensure and takes the SAME gate:
// revoking membership is the org's authority to give or take, so a SuperAdmin, an
// admin of the org itself, or an org-admin-capable confidential client. Idempotent
// through the store — deleting an absent membership reports removed=false, never an
// error — so a retried revoke is safe.
func remove(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
var in request
if err := c.Bind(&in); err != nil {
return httpx.Err(c, err.Error())
}
if in.User == "" || in.Org == "" {
return httpx.Err(c, "user and org are required")
}
if !mayGrant(ctx, in.Org) {
return httpx.Err(c, unauthorized)
}
removed, err := store.DeleteMembership(ctx, db, in.User, in.Org)
if err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, removed)
}
}
// mayGrant reports whether the ctx principal may grant OR revoke a membership into
// org — the ONE write gate ensure and remove share. Two clauses, both required:
//
// - the org's admin authority: a SuperAdmin, an admin of the org itself, or an
// org-admin-capable confidential client — the same authz.Can(POST, organizations)
// gate a write to that org's own registry row takes; AND
// - the reserved-org escalation guard (RED F2): a membership INTO a reserved system
// org (admin/built-in/app) flows into the target user's `orgs` claim, which the
// edge honors as X-Org-Id ∈ orgs — i.e. it seeds admin-org (SuperAdmin) tenancy.
// A CapOrgAdmin client passes authz.Can for the membership row (always owned by
// the reserved "admin" org, so the check is NOT bound to in.Org), so without this
// a brand console could grant anyone tenancy in the admin org. Only a real
// SuperAdmin may target a reserved org.
func mayGrant(ctx context.Context, org string) bool {
if store.IsReservedOrg(org) && !authz.IsSuper(ctx) {
return false
}
return authz.Can(ctx, "POST", "organizations", store.MembershipOwner, org)
}
// scoped reports whether the caller may read the membership rows of org — i.e.
// whether resolving the scope from its own verified credential yields exactly
// the org it asked for. A SuperAdmin gets what it asks for; anyone else gets its
// own org, so any other request fails the equality and is refused.
func scoped(ctx context.Context, org string) bool {
got, err := authz.Scope(ctx, org)
return err == nil && got == org
}
// listed answers a membership listing, or the error envelope on failure. It takes
// the store call's pair so the two branches of list read as one line each.
func listed(rows []*schema.Membership, err error) (*httpx.Answer, error) {
if err != nil {
return httpx.Bad(400, err.Error(), ""), nil
}
return httpx.Good(rows, len(rows)), nil
}
+480
View File
@@ -0,0 +1,480 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package memberships_test
// The the legacy surface membership VERB aliases (GAP A): get-memberships / add-membership /
// delete-membership, the spellings cloud's clients/team invite path hard-codes.
// Every case is a HTTP request driven through the REAL registered router (routes.Route
// installs the authz Guard, then registers memberships after it), so the assertions
// prove the three things a backend swap depends on: the verbs reach the SAME store
// as the REST surface, the SAME tenant authz gates the REST surface uses, and a
// cross-tenant caller is refused with v1's verbatim message.
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/orm"
ormdb "github.com/hanzoai/orm/db"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/routes"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/testhttp"
)
const signingKid = "cert-hanzo"
type harness struct {
app *zip.App
key *rsa.PrivateKey
db orm.DB
}
func newHarness(t *testing.T) *harness {
t.Helper()
_ = schema.Kinds()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa: %v", err)
}
dir := t.TempDir()
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(dir, "memberships.db"),
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
seedCert(t, db, "admin", signingKid, pemOf(t, key))
seedUser(t, db, "admin", "root", true) // SuperAdmin (org == admin)
seedUser(t, db, "hanzo", "boss", true) // org-admin of hanzo
seedUser(t, db, "orgb", "bob", true) // org-admin of a second tenant
app := zip.New(zip.Config{AppName: "memberships-test", DisableStartupMessage: true})
routes.Route(app, db)
if err := app.Build(); err != nil {
t.Fatalf("build: %v", err)
}
return &harness{app: app, key: key, db: db}
}
func (h *harness) token(t *testing.T, sub string) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"sub": sub,
"iat": time.Now().Add(-time.Minute).Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
})
tok.Header["kid"] = signingKid
s, err := tok.SignedString(h.key)
if err != nil {
t.Fatalf("sign: %v", err)
}
return s
}
func (h *harness) get(t *testing.T, path, bearer string) (int, env) {
t.Helper()
status, body := h.read(t, path, bearer)
return status, envOf(body)
}
func (h *harness) post(t *testing.T, path string, body any, bearer string) (int, env) {
t.Helper()
b, _ := json.Marshal(body)
req := httptest.NewRequest("POST", path, bytes.NewReader(b))
req.Host = "hanzo.id"
req.Header.Set("Content-Type", "application/json")
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
return h.do(t, req)
}
// postBasic drives an add/delete verb authenticating as a confidential client
// (client_secret_basic) — how a brand console / cloud service calls these verbs.
func (h *harness) postBasic(t *testing.T, path string, body any, clientID, secret string) (int, env) {
t.Helper()
b, _ := json.Marshal(body)
req := httptest.NewRequest("POST", path, bytes.NewReader(b))
req.Host = "hanzo.id"
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth(clientID, secret)
return h.do(t, req)
}
// do drives the request through the real registered router and decodes the v1
// envelope. A raw 401 (the Guard's fail-closed refusal) has no envelope body; the
// caller asserts on the status alone.
func (h *harness) do(t *testing.T, req *http.Request) (int, env) {
t.Helper()
status, body := h.raw(t, req)
return status, envOf(body)
}
// envOf decodes the v1 envelope a body carries — the ONE decode, so `get` and
// `do` cannot drift into reading the same bytes two ways.
func envOf(body string) env {
var e env
_ = json.Unmarshal([]byte(body), &e)
return e
}
// raw is do without the decode — the status and the body VERBATIM, for a case
// whose subject IS the bytes.
func (h *harness) raw(t *testing.T, req *http.Request) (int, string) {
t.Helper()
resp, err := testhttp.Do(h.app, req)
if err != nil {
t.Fatalf("%s %s: %v", req.Method, req.URL.Path, err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return resp.StatusCode, string(body)
}
// read drives one GET and returns the status and the body verbatim.
func (h *harness) read(t *testing.T, url, bearer string) (int, string) {
t.Helper()
req := httptest.NewRequest("GET", url, nil)
req.Host = "hanzo.id"
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
return h.raw(t, req)
}
// env is the v1 Response envelope the clients parse.
type env struct {
Status string `json:"status"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
Data2 json.RawMessage `json:"data2"`
}
// ---- cases -----------------------------------------------------------------
// get-memberships?user=<owner/name> lists one identity's orgs (SuperAdmin path).
func TestGetMemberships_byUser(t *testing.T) {
h := newHarness(t)
seedMembership(t, h.db, "hanzo/alice", "hanzo", store.RoleMember)
seedMembership(t, h.db, "hanzo/alice", "team-x", store.RoleAdmin)
status, e := h.get(t, "/v1/iam/get-memberships?user=hanzo/alice", h.token(t, "admin/root"))
if status != 200 || e.Status != "ok" {
t.Fatalf("get-memberships?user status=%d env=%+v, want 200 ok", status, e)
}
rows := parseMemberships(t, e)
if len(rows) != 2 {
t.Fatalf("alice acts in %d orgs, want 2 (hanzo, team-x)", len(rows))
}
}
// get-memberships?org=<slug> lists an org's roster.
func TestGetMemberships_byOrg(t *testing.T) {
h := newHarness(t)
seedMembership(t, h.db, "hanzo/alice", "hanzo", store.RoleMember)
seedMembership(t, h.db, "hanzo/boss", "hanzo", store.RoleAdmin)
// hanzo's own admin may read its own org's roster (handler-authorized scoped()).
status, e := h.get(t, "/v1/iam/get-memberships?org=hanzo", h.token(t, "hanzo/boss"))
if status != 200 || e.Status != "ok" {
t.Fatalf("get-memberships?org status=%d env=%+v, want 200 ok", status, e)
}
if rows := parseMemberships(t, e); len(rows) != 2 {
t.Fatalf("hanzo roster = %d, want 2 (alice, boss)", len(rows))
}
}
// add-membership creates the row the same store EnsureMembership does, and a
// following get-memberships shows it — the verbs share ONE store.
func TestAddMembership_thenGetShowsIt(t *testing.T) {
h := newHarness(t)
super := h.token(t, "admin/root")
status, e := h.post(t, "/v1/iam/add-membership",
map[string]string{"user": "hanzo/alice", "org": "team-x", "role": "admin"}, super)
if status != 200 || e.Status != "ok" {
t.Fatalf("add-membership status=%d env=%+v, want 200 ok", status, e)
}
if !parseBool(t, e) {
t.Fatal("add-membership reported no row created")
}
_, g := h.get(t, "/v1/iam/get-memberships?user=hanzo/alice", super)
rows := parseMemberships(t, g)
if len(rows) != 1 || rows[0].Org != "team-x" || rows[0].Role != store.RoleAdmin {
t.Fatalf("after add, memberships = %+v, want one {team-x, admin}", rows)
}
}
// delete-membership removes the row and is idempotent: a second delete of the same
// (user, org) reports removed=false with no error.
func TestDeleteMembership_removesAndIdempotent(t *testing.T) {
h := newHarness(t)
super := h.token(t, "admin/root")
seedMembership(t, h.db, "hanzo/alice", "team-x", store.RoleAdmin)
status, e := h.post(t, "/v1/iam/delete-membership",
map[string]string{"user": "hanzo/alice", "org": "team-x"}, super)
if status != 200 || e.Status != "ok" || !parseBool(t, e) {
t.Fatalf("first delete status=%d env=%+v, want 200 ok removed=true", status, e)
}
// Row is gone.
if m, _ := store.GetMembership(context.Background(), h.db, "hanzo/alice", "team-x"); m != nil {
t.Fatal("membership survived delete")
}
// Idempotent second delete: still ok, but removed=false.
_, e2 := h.post(t, "/v1/iam/delete-membership",
map[string]string{"user": "hanzo/alice", "org": "team-x"}, super)
if e2.Status != "ok" || parseBool(t, e2) {
t.Fatalf("second delete env=%+v, want ok removed=false (idempotent)", e2)
}
}
// A cross-tenant caller is refused with v1's verbatim message — neither writing nor
// reading another tenant's membership rows.
func TestMembership_crossTenantDenied(t *testing.T) {
h := newHarness(t)
boss := h.token(t, "hanzo/boss") // admin of hanzo, NOT of orgb
// Write into orgb: refused.
_, add := h.post(t, "/v1/iam/add-membership",
map[string]string{"user": "orgb/bob", "org": "orgb", "role": "member"}, boss)
if add.Status != "error" || add.Msg != "auth:Unauthorized operation" {
t.Fatalf("cross-tenant add-membership env=%+v, want error auth:Unauthorized operation", add)
}
// Delete from orgb: refused the same way.
_, del := h.post(t, "/v1/iam/delete-membership",
map[string]string{"user": "orgb/bob", "org": "orgb"}, boss)
if del.Status != "error" || del.Msg != "auth:Unauthorized operation" {
t.Fatalf("cross-tenant delete-membership env=%+v, want error auth:Unauthorized operation", del)
}
// Read orgb's roster: refused the same way.
_, roster := h.get(t, "/v1/iam/get-memberships?org=orgb", boss)
if roster.Status != "error" || roster.Msg != "auth:Unauthorized operation" {
t.Fatalf("cross-tenant get-memberships?org=orgb env=%+v, want error auth:Unauthorized operation", roster)
}
}
// RED F2 — a CapOrgAdmin (non-super) confidential client can create customer-org
// memberships but must NEVER grant tenancy INTO a reserved system org (admin /
// built-in), which would seed a SuperAdmin-org `orgs` claim on the target user. Only
// a real SuperAdmin may. The client's legitimate power over a normal org is intact.
func TestEnsureMembership_reservedOrgRequiresSuper(t *testing.T) {
h := newHarness(t)
seedClientApp(t, h.db, "hanzo-console", "console-secret")
t.Setenv("IAM_ORG_ADMIN_APPS", "hanzo-console")
// Into the reserved admin/built-in orgs: refused, verbatim.
for _, org := range []string{"admin", "built-in"} {
_, e := h.postBasic(t, "/v1/iam/add-membership",
map[string]string{"user": "hanzo/alice", "org": org, "role": "admin"}, "hanzo-console", "console-secret")
if e.Status != "error" || e.Msg != "auth:Unauthorized operation" {
t.Fatalf("CapOrgAdmin ensure into %q env=%+v, want error auth:Unauthorized operation", org, e)
}
if m, _ := store.GetMembership(context.Background(), h.db, "hanzo/alice", org); m != nil {
t.Fatalf("a reserved-org membership was created in %q despite the refusal", org)
}
}
// Revoke into a reserved org is gated the same way.
_, del := h.postBasic(t, "/v1/iam/delete-membership",
map[string]string{"user": "hanzo/alice", "org": "admin"}, "hanzo-console", "console-secret")
if del.Status != "error" || del.Msg != "auth:Unauthorized operation" {
t.Fatalf("CapOrgAdmin revoke into admin env=%+v, want error auth:Unauthorized operation", del)
}
// Legit power preserved: the SAME client CAN ensure into a normal customer org.
_, ok := h.postBasic(t, "/v1/iam/add-membership",
map[string]string{"user": "hanzo/alice", "org": "hanzo", "role": "member"}, "hanzo-console", "console-secret")
if ok.Status != "ok" {
t.Fatalf("CapOrgAdmin ensure into a normal org env=%+v, want ok (legit power broken)", ok)
}
// And a real SuperAdmin MAY grant a reserved-org membership (the escape hatch).
_, sup := h.post(t, "/v1/iam/add-membership",
map[string]string{"user": "hanzo/alice", "org": "admin", "role": "admin"}, h.token(t, "admin/root"))
if sup.Status != "ok" {
t.Fatalf("SuperAdmin ensure into admin env=%+v, want ok", sup)
}
}
// ---- the read as a typed op ------------------------------------------------
// The list is a TYPED op at BOTH addresses, so it reaches two seams a raw handler
// never did: zip's query binder, and the op-invoke authorizer (authz.Authorize).
// Both are silent when they work and fatal when they do not — a binder that missed
// ?org= answers "exactly one of user or org is required", an authorizer that saw a
// target answers 403 — so these cases assert the RAW BODY BYTES at each address.
//
// The bytes are the point. Typing this read is a projection, not a change: same
// address, same status, same envelope, before and after.
func TestList_wire(t *testing.T) {
h := newHarness(t)
seedMembership(t, h.db, "hanzo/alice", "hanzo", store.RoleMember)
seedMembership(t, h.db, "hanzo/boss", "hanzo", store.RoleAdmin)
boss := h.token(t, "hanzo/boss")
// Both addresses, one handler, one answer.
for _, path := range []string{"/v1/iam/memberships", "/v1/iam/get-memberships"} {
t.Run(path, func(t *testing.T) {
status, body := h.read(t, path+"?org=hanzo", boss)
if status != 200 {
t.Fatalf("status=%d body=%s, want 200", status, body)
}
if !strings.HasPrefix(body, `{"status":"ok","msg":"","data":[`) || !strings.HasSuffix(body, `],"data2":2}`) {
t.Fatalf("body=%s, want the v1 envelope with data2=2", body)
}
// The other question the same op answers: one identity's orgs.
status, body = h.read(t, path+"?user=hanzo/alice", boss)
if status != 200 || !strings.HasSuffix(body, `],"data2":1}`) {
t.Fatalf("?user status=%d body=%s, want 200 with data2=1", status, body)
}
})
}
}
// The refusals, byte for byte at both addresses: 400 carrying {status:"error",
// msg, data:null}.
func TestList_refusals(t *testing.T) {
h := newHarness(t)
boss := h.token(t, "hanzo/boss") // admin of hanzo, NOT of orgb
const denied = `{"status":"error","msg":"auth:Unauthorized operation","data":null}`
for _, c := range []struct{ name, query, want string }{
{"neither", "", `{"status":"error","msg":"exactly one of user or org is required","data":null}`},
{"both", "?user=hanzo/alice&org=hanzo", `{"status":"error","msg":"exactly one of user or org is required","data":null}`},
// The angle brackets arrive escaped: encoding/json escapes HTML by
// default, so the bytes carry the < form. The brackets are the
// message's, the escaping is the encoder's, and the escaped form is what
// this address has always put on the wire — assert the bytes, not the
// message.
{"unqualified user", "?user=alice", `{"status":"error","msg":"user must be \u003cowner\u003e/\u003cname\u003e","data":null}`},
{"cross-tenant org", "?org=orgb", denied},
{"cross-tenant user", "?user=orgb/bob", denied},
} {
for _, path := range []string{"/v1/iam/memberships", "/v1/iam/get-memberships"} {
t.Run(c.name+" "+path, func(t *testing.T) {
status, body := h.read(t, path+c.query, boss)
if status != 400 || body != c.want {
t.Fatalf("status=%d body=%s, want 400 %s", status, body, c.want)
}
})
}
}
}
// The op-invoke authorizer admits this read because its input names no owner —
// `lookup` declares no Owner field and no AuthzTarget(). An unknown query key is
// therefore just an unknown query key: it is ignored by the binder and can never
// become the target the authorizer decides on. Give the input an Owner field and
// this is a 403, which is why the case is here rather than in a comment.
func TestList_ownerQueryIsNotATarget(t *testing.T) {
h := newHarness(t)
seedMembership(t, h.db, "hanzo/alice", "hanzo", store.RoleMember)
for _, path := range []string{"/v1/iam/memberships", "/v1/iam/get-memberships"} {
status, body := h.read(t, path+"?org=hanzo&owner=orgb&name=whatever", h.token(t, "hanzo/boss"))
if status != 200 {
t.Fatalf("%s status=%d body=%s, want 200 — the read is authorized by scoped(), not by ?owner=", path, status, body)
}
}
}
// The verbs are gated: no bearer → the Guard fails closed (401).
func TestMembershipVerbs_requireAuth(t *testing.T) {
h := newHarness(t)
if status, _ := h.get(t, "/v1/iam/get-memberships?org=hanzo", ""); status != 401 {
t.Fatalf("unauthenticated get-memberships status=%d, want 401", status)
}
}
// ---- helpers ---------------------------------------------------------------
func parseMemberships(t *testing.T, e env) []schema.Membership {
t.Helper()
var rows []schema.Membership
if err := json.Unmarshal(e.Data, &rows); err != nil {
t.Fatalf("data is not a membership list: %v (data=%s)", err, e.Data)
}
return rows
}
func parseBool(t *testing.T, e env) bool {
t.Helper()
var b bool
if err := json.Unmarshal(e.Data, &b); err != nil {
t.Fatalf("data is not a bool: %v (data=%s)", err, e.Data)
}
return b
}
func seedMembership(t *testing.T, db orm.DB, user, org, role string) {
t.Helper()
if _, err := store.EnsureMembership(context.Background(), db, user, org, role); err != nil {
t.Fatalf("seed membership %s@%s: %v", user, org, err)
}
}
func seedCert(t *testing.T, db orm.DB, owner, name, privPEM string) {
t.Helper()
c := orm.New[schema.Cert](db)
c.Owner, c.Name = owner, name
c.CryptoAlgorithm = "RS256"
c.PrivateKey = privPEM
c.SetId(owner + "/" + name)
if err := c.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed cert: %v", err)
}
}
func seedUser(t *testing.T, db orm.DB, owner, name string, admin bool) {
t.Helper()
u := orm.New[schema.User](db)
u.Owner, u.Name = owner, name
u.IsAdmin = admin
u.SetId(owner + "/" + name)
if err := u.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed user: %v", err)
}
}
// seedClientApp seeds an admin-owned confidential client (so the CapOrgAdmin
// owner-pin holds) with a client_secret for Basic-auth authentication.
func seedClientApp(t *testing.T, db orm.DB, name, secret string) {
t.Helper()
a := orm.New[schema.Application](db)
a.Owner, a.Name = "admin", name
a.Organization = "hanzo"
a.ClientId = name
a.ClientSecret = secret
a.SetId("admin/" + name)
if err := a.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed client app: %v", err)
}
}
func pemOf(t *testing.T, k *rsa.PrivateKey) string {
t.Helper()
return string(pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k),
}))
}
+35
View File
@@ -0,0 +1,35 @@
// Code generated by zipdoc; DO NOT EDIT.
package memberships
import (
"github.com/zap-proto/zip"
)
func init() {
zip.Describe("GET /v1/iam/get-memberships", zip.Doc{
Description: "Answers either question about who belongs where: which organizations one\nperson can act in, or who can act in one organization.\n\nBoth are org-scoped: a non-SuperAdmin may ask about ITS OWN org's roster, or\nabout a user whose home org is its own, and nothing else. The bound comes from\nthe verified credential via authz.Scope, so a request parameter can never\nwiden it — a membership row names who may act and spend in an org, so a\ncross-tenant read is a customer roster leak.",
Fields: map[string]string{
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
"lookup.org": "Org is an organization — who may act in it.",
"lookup.user": "User is \"<homeOrg>/<username>\" — which organizations that identity may act in.",
},
})
zip.Describe("GET /v1/iam/memberships", zip.Doc{
Description: "Answers either question about who belongs where: which organizations one\nperson can act in, or who can act in one organization.\n\nBoth are org-scoped: a non-SuperAdmin may ask about ITS OWN org's roster, or\nabout a user whose home org is its own, and nothing else. The bound comes from\nthe verified credential via authz.Scope, so a request parameter can never\nwiden it — a membership row names who may act and spend in an org, so a\ncross-tenant read is a customer roster leak.",
Fields: map[string]string{
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
"lookup.org": "Org is an organization — who may act in it.",
"lookup.user": "User is \"<homeOrg>/<username>\" — which organizations that identity may act in.",
},
})
zip.Describe("POST /v1/iam/add-membership", zip.Doc{
Description: "Lets a person or an application act in an organization. It is the grant\nbehind \"add someone to the team\", and it is safe to repeat — granting a\nmembership that already exists changes nothing. Granting membership IS the org's authority to give, so it takes the\nsame gate a write to that org's own registry row takes: a SuperAdmin, an admin\nof the org itself, or an org-admin-capable confidential client. One rule, one\nplace (internal/authz).",
})
zip.Describe("POST /v1/iam/delete-membership", zip.Doc{
Description: "Takes away a person's or an application's right to act in an\norganization. Their account survives; what ends is their access to that\norganization. Revoking a membership that is already gone reports that nothing\nwas removed rather than failing, so a retry is safe. It is the mirror of ensure and takes the SAME gate:\nrevoking membership is the org's authority to give or take, so a SuperAdmin, an\nadmin of the org itself, or an org-admin-capable confidential client. Idempotent\nthrough the store — deleting an absent membership reports removed=false, never an\nerror — so a retried revoke is safe.",
})
zip.Describe("POST /v1/iam/memberships", zip.Doc{
Description: "Lets a person or an application act in an organization. It is the grant\nbehind \"add someone to the team\", and it is safe to repeat — granting a\nmembership that already exists changes nothing. Granting membership IS the org's authority to give, so it takes the\nsame gate a write to that org's own registry row takes: a SuperAdmin, an admin\nof the org itself, or an org-admin-capable confidential client. One rule, one\nplace (internal/authz).",
})
}
+270
View File
@@ -0,0 +1,270 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package factor
import (
"context"
"crypto/rand"
"encoding/base32"
"errors"
"strings"
"github.com/hanzoai/orm"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
)
// Package factor is the pure multi-factor DOMAIN — what a factor IS, whether a
// passcode verifies, which factors a user has, whether the org demands one, and
// how that state is written. It is the ONE implementation both the enrollment
// surface (internal/mfa) and the login-time second-factor gate (internal/oidc)
// call, so the Verify the challenge runs is the one enrollment's setup check uses
// and the Save every MFA write goes through cannot drift apart.
//
// It is a LEAF: it imports only store + schema, never authz or oidc. That is what
// lets the gate (in oidc, which authz imports) use it without an import cycle,
// while the enrollment surface (which does need authz) uses it too — one domain,
// two callers, no duplication. Radius and push are deliberately absent: no v2
// provider transport serves them, and a factor listed as available but unservable
// is an unusable challenge.
// The factor types, verbatim from v1 (object/mfa.go:42-48). "app" is TOTP — the
// name is v1's and it is in the serialized payload, so it does not get "improved".
const (
App = "app"
SMS = "sms"
Email = "email"
)
// Types lists the factors this package can project, in v1's order. It bounds
// AllProps: a factor absent here is never offered on a challenge.
var Types = []string{SMS, Email, App}
// errNoUser is the ONE answer to an unresolvable MFA subject.
var errNoUser = errors.New("user doesn't exist")
// Enroll generates a fresh TOTP secret for userID ("owner/name") and the
// otpauth:// URL that encodes it, using the RFC 6238 defaults every authenticator
// app assumes (the same totp.Generate defaults the enrollment surface uses). It
// persists NOTHING: enrollment is stateless and client-held until enable commits
// it.
func Enroll(userID, issuer string) (secret, url string, err error) {
if issuer == "" {
issuer = "Hanzo"
}
key, err := totp.Generate(totp.GenerateOpts{Issuer: issuer, AccountName: userID})
if err != nil {
return "", "", err
}
return key.Secret(), key.URL(), nil
}
// Verify reports whether passcode is currently valid for secret. It is the ONE
// TOTP verification point — enrollment's setup check and the login challenge call
// this same function, so they cannot drift apart. totp.Validate accepts the
// adjacent windows (skew 1), tolerating clock drift.
func Verify(secret, passcode string) bool {
if secret == "" || passcode == "" {
return false
}
return totp.Validate(passcode, secret)
}
// recoveryBytes is the entropy behind one recovery code: 20 bytes → 32 base32
// characters, the same strength as the TOTP secret it backs up.
const recoveryBytes = 20
// MintRecovery returns one fresh recovery code, in the clear, for the user to write
// down. It asks crypto/rand for a secret directly (not a formatted identifier).
func MintRecovery() (string, error) {
b := make([]byte, recoveryBytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)), nil
}
// HashRecovery is the digest a recovery code is STORED as. A recovery code is a
// bearer credential verified by equality alone, so — unlike the TOTP secret, which
// the verifier needs back in the clear — it hashes like a password.
func HashRecovery(plain string) (string, error) {
h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
return string(h), err
}
// HashRecoveryCodes digests each plaintext recovery code for storage — enrollment
// hands the user the plaintext (the QR's backup code) exactly once and keeps only
// the digest, so a database dump exposes no usable recovery credential.
func HashRecoveryCodes(plain []string) ([]string, error) {
out := make([]string, 0, len(plain))
for _, p := range plain {
h, err := HashRecovery(p)
if err != nil {
return nil, err
}
out = append(out, h)
}
return out, nil
}
// UseRecovery consumes one of the user's recovery codes, reporting whether code
// matched. A hit is DELETED from u.RecoveryCodes in place — one-time use — and the
// caller persists the row.
//
// Stored codes are bcrypt digests, but every code migrated from v1 is PLAINTEXT
// (object/mfa.go:81 compares in the clear), so a stored value that is not a digest
// is compared literally. The algorithm is a property of the stored value, never a
// constant — the same rule the password path lives by. A legacy hit is spent and
// removed like any other, so the plaintext dies on first use.
func UseRecovery(u *schema.User, code string) bool {
if u == nil || code == "" {
return false
}
for i, stored := range u.RecoveryCodes {
if !recoveryMatches(stored, code) {
continue
}
u.RecoveryCodes = append(u.RecoveryCodes[:i:i], u.RecoveryCodes[i+1:]...)
return true
}
return false
}
// recoveryMatches compares one presented code against one stored value, choosing
// the comparison from what the value IS: a bcrypt digest is verified with bcrypt,
// a v1-era plaintext by equality.
func recoveryMatches(stored, code string) bool {
if isBcrypt(stored) {
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(code)) == nil
}
return stored != "" && stored == code
}
// isBcrypt reports whether s is a bcrypt digest by asking the library's own parser
// (bcrypt.Cost), so the answer comes from the format itself rather than a guess.
func isBcrypt(s string) bool {
_, err := bcrypt.Cost([]byte(s))
return err == nil
}
// Enabled reports whether the user has multi-factor sign-in on. The predicate is
// PreferredMfaType != "" and nothing else (v1 object/user.go:1641): the per-factor
// enabled flags say which factors exist, not whether the gate runs.
func Enabled(u *schema.User) bool { return u != nil && u.PreferredMfaType != "" }
// Prompt reports whether the organization REQUIRES a factor the user has not
// enrolled yet — the sign-in must divert to enrollment before it can finish. The
// user's own MfaItems override the org's entirely when present (not merge: v1
// object/organization.go:770-792), so a per-user policy is a replacement.
func Prompt(org *schema.Organization, u *schema.User) bool {
if org == nil || u == nil {
return false
}
items := org.MfaItems
if len(u.MfaItems) > 0 {
items = u.MfaItems
}
for _, item := range items {
if item == nil || item.Rule != "Required" {
continue
}
switch item.Name {
case Email:
if !u.MfaEmailEnabled {
return true
}
case SMS:
if !u.MfaPhoneEnabled {
return true
}
case App:
if u.TotpSecret == "" {
return true
}
}
}
return false
}
// Props projects one factor of the user for a client, ALWAYS masked: Secret and
// RecoveryCodes are never populated (and are json:"-" besides). The login-gate
// verifier reads u.TotpSecret directly, so this projection has no unmasked mode to
// misuse.
func Props(u *schema.User, mfaType string) *schema.MfaProps {
p := &schema.MfaProps{MfaType: mfaType}
if u == nil {
return p
}
switch mfaType {
case SMS:
p.Enabled = u.MfaPhoneEnabled
if p.Enabled {
p.CountryCode = u.CountryCode
}
case Email:
p.Enabled = u.MfaEmailEnabled
case App:
p.Enabled = u.TotpSecret != ""
}
if !p.Enabled {
return &schema.MfaProps{MfaType: mfaType}
}
p.IsPreferred = u.PreferredMfaType == mfaType
return p
}
// AllProps projects every factor this package serves, masked, in v1's order.
func AllProps(u *schema.User) []*schema.MfaProps {
all := make([]*schema.MfaProps, 0, len(Types))
for _, t := range Types {
all = append(all, Props(u, t))
}
return all
}
// Copy overwrites dst's multi-factor state with src's, and nothing else. It is the
// ONE declaration of which columns ARE multi-factor state, so every writer agrees
// on the set by construction: Save overlays a caller's factors onto the STORED row
// through this, which is what makes an MFA write column-scoped — the request's user
// value never reaches the store, so it cannot carry isAdmin along and self-promote.
func Copy(dst, src *schema.User) {
if dst == nil || src == nil {
return
}
dst.PreferredMfaType = src.PreferredMfaType
dst.RecoveryCodes = src.RecoveryCodes
dst.TotpSecret = src.TotpSecret
dst.MfaPhoneEnabled = src.MfaPhoneEnabled
dst.MfaEmailEnabled = src.MfaEmailEnabled
dst.MfaRadiusEnabled = src.MfaRadiusEnabled
dst.MfaRadiusUsername = src.MfaRadiusUsername
dst.MfaRadiusProvider = src.MfaRadiusProvider
dst.MfaPushEnabled = src.MfaPushEnabled
dst.MfaPushReceiver = src.MfaPushReceiver
dst.MfaPushProvider = src.MfaPushProvider
dst.MfaRememberDeadline = src.MfaRememberDeadline
}
// Save writes u's multi-factor state — and ONLY that — onto its stored row. It is
// the single write point for every MFA mutation the login gate makes: spend a
// recovery code, remember a device. The scoping is what makes it safe: the row is
// loaded fresh and Copy overlays exactly the multi-factor columns, so an isAdmin,
// a balance, or a password digest arriving on an MFA request reaches nothing.
func Save(ctx context.Context, db orm.DB, u *schema.User) error {
if u == nil {
return errNoUser
}
stored, err := store.GetUserByName(ctx, db, u.Owner, u.Name)
if err != nil {
return err
}
if stored == nil {
return errNoUser
}
Copy(stored, u)
return stored.UpdateCtx(ctx)
}
+286
View File
@@ -0,0 +1,286 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
// Package mfa serves the TOTP multi-factor enrollment surface — the account
// security page's initiate → verify → enable flow (RFC 6238 TOTP), plus
// disabling a factor and choosing the preferred one. Enrollment is SELF-SERVICE: every handler
// acts on the AUTHENTICATED caller's own user record (authz.From), so the routes
// register AFTER the Guard — they need the Principal. Touching a DIFFERENT user's
// MFA requires admin authority over that org, authorized through the SAME seam a
// SCIM write uses (authz.Can); the general user-write policy correctly refuses a
// non-admin writing a user row, so self-enrollment is authorized by
// self-ownership (target == principal), NOT by that policy.
//
// The handshake is STATELESS across the three calls: initiate mints a TOTP
// secret + otpauth URL + recovery code and hands them to the client; the client
// renders the QR, the authenticator app derives a passcode, verify checks it
// against the SAME secret the client echoes back, and enable persists the secret
// + recovery code to the user. No pending secret is parked server-side between
// calls — it is client-held until enable commits it.
package mfa
import (
"crypto/rand"
"encoding/base32"
"encoding/json"
"errors"
"os"
"strings"
"github.com/pquerna/otp/totp"
"github.com/zap-proto/zip"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/authz"
"github.com/hanzoai/iam/internal/mfa/factor"
"github.com/hanzoai/iam/pkg/store"
)
// The TOTP factor type ("app") and the domain helpers are factor.App et al (internal/mfa/factor).
// Route registers the MFA endpoints on app. They are RAW handlers (not typed
// ops), so — like SCIM — each authorizes itself; callers register app AFTER the
// Guard so a verified Principal rides the request context.
// The MFA surface hangs off the /v1/iam/mfa noun. The two verb-noun spellings it
// arrived with stay reachable for pinned consumers and are taught nowhere; see
// zip.Alias.
const (
PathDisable = "/v1/iam/mfa/disable"
PathPreferred = "/v1/iam/mfa/preferred"
LegacyPathDisable = "/v1/iam/delete-mfa"
LegacyPathPreferred = "/v1/iam/set-preferred-mfa"
)
func Route(app *zip.App, db orm.DB) {
app.Post("/v1/iam/mfa/setup/initiate", initiate(db))
app.Post("/v1/iam/mfa/setup/verify", verify(db))
app.Post("/v1/iam/mfa/setup/enable", enable(db))
zip.Alias(app.Post, PathDisable, LegacyPathDisable, disable(db))
zip.Alias(app.Post, PathPreferred, LegacyPathPreferred, setPreferred(db))
}
// setupReq is the union of fields the enrollment handshake posts. owner/name
// address the target user (default: the caller itself); secret/passcode/
// recoveryCodes carry the client-held enrollment material; mfaType selects the
// preferred factor for the preferred-factor endpoint.
type setupReq struct {
Owner string `json:"owner"`
Name string `json:"name"`
Secret string `json:"secret"`
Passcode string `json:"passcode"`
RecoveryCodes []string `json:"recoveryCodes"`
MfaType string `json:"mfaType"`
}
// target resolves the (owner, name) an MFA request addresses and authorizes it:
// the caller may always manage its OWN record; touching another user's MFA
// requires admin authority over that org (authz.Can — the seam SCIM writes use).
// An unauthenticated caller fails closed (the Guard already required a bearer, so
// this is defense in depth). Returns a zip error to return verbatim on refusal.
func target(c *zip.Ctx, req *setupReq) (owner, name string, err error) {
p, present := authz.From(c.Context())
if !present {
return "", "", zip.ErrUnauthorized("authentication required")
}
owner, name = strings.TrimSpace(req.Owner), strings.TrimSpace(req.Name)
if owner == "" || name == "" {
owner, name = p.Org, p.User // default: the caller itself
}
self := owner == p.Org && name == p.User
if !self && !authz.Can(c.Context(), "PUT", "users", owner, name) {
return "", "", zip.ErrForbidden("forbidden")
}
return owner, name, nil
}
// initiate starts enrolling an authenticator app: it returns a fresh secret, a
// URL to render as a QR code, and one recovery code to keep somewhere safe.
//
// Nothing is switched on yet. The enrolment counts only once it is confirmed with
// a code from the app, so abandoning this step leaves the account exactly as it
// was. Response:
// {status:"ok", data:{secret, url, recoveryCodes:[code]}}.
func initiate(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
var req setupReq
_ = decode(c, &req) // body optional: owner/name default to the caller
owner, name, err := target(c, &req)
if err != nil {
return err
}
key, err := totp.Generate(totp.GenerateOpts{Issuer: issuer(owner), AccountName: name})
if err != nil {
return c.JSON(500, errResp("failed to generate secret"))
}
code, err := recoveryCode()
if err != nil {
return c.JSON(500, errResp("server_error"))
}
return c.JSON(200, okData(map[string]any{
"secret": key.Secret(),
"url": key.URL(),
"recoveryCodes": []string{code},
}))
}
}
// verify checks a six-digit code against an enrolment in progress, so somebody
// can confirm their authenticator app is set up correctly before it starts being
// required. Clocks a step out either way are accepted.
// A valid code → {status:"ok"}; an invalid one → 200 {status:"error"} (the
// casibase convention: clients branch on status, not the HTTP code).
func verify(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
var req setupReq
if err := decode(c, &req); err != nil {
return c.JSON(400, errResp("invalid body"))
}
if _, _, err := target(c, &req); err != nil {
return err
}
if req.Secret == "" || req.Passcode == "" {
return c.JSON(200, errResp("secret and passcode are required"))
}
if !totp.Validate(req.Passcode, req.Secret) {
return c.JSON(200, errResp("the code is incorrect"))
}
return c.JSON(200, okData(nil))
}
}
// enable finishes the enrolment: from here the account's sign-ins ask for a code
// from the authenticator app. Repeating it re-enrols rather than failing.
func enable(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
var req setupReq
if err := decode(c, &req); err != nil {
return c.JSON(400, errResp("invalid body"))
}
owner, name, err := target(c, &req)
if err != nil {
return err
}
if req.Secret == "" {
return c.JSON(200, errResp("secret is required"))
}
u, err := store.GetUserByName(c.Context(), db, owner, name)
if err != nil {
return c.JSON(500, errResp("server_error"))
}
if u == nil {
return c.JSON(404, errResp("user not found"))
}
u.TotpSecret = req.Secret
hashed, herr := factor.HashRecoveryCodes(req.RecoveryCodes)
if herr != nil {
return c.JSON(500, errResp("server_error"))
}
u.RecoveryCodes = hashed
u.PreferredMfaType = factor.App
if err := u.UpdateCtx(c.Context()); err != nil {
return c.JSON(500, errResp("server_error"))
}
return c.JSON(200, okData(map[string]any{"preferredMfaType": factor.App}))
}
}
// disable turns off the authenticator app for an account, so sign-in stops
// asking for a code. People may do this for themselves; doing it for somebody
// else takes an administrator, which is what makes it the reset path when a
// phone is lost.
func disable(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
var req setupReq
_ = decode(c, &req) // body optional: owner/name default to the caller
owner, name, err := target(c, &req)
if err != nil {
return err
}
u, err := store.GetUserByName(c.Context(), db, owner, name)
if err != nil {
return c.JSON(500, errResp("server_error"))
}
if u == nil {
return c.JSON(404, errResp("user not found"))
}
u.TotpSecret = ""
u.RecoveryCodes = nil
u.PreferredMfaType = ""
if err := u.UpdateCtx(c.Context()); err != nil {
return c.JSON(500, errResp("server_error"))
}
return c.JSON(200, okData(nil))
}
}
// setPreferred picks which second factor an account is asked for first when it
// has more than one enrolled.
func setPreferred(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
var req setupReq
if err := decode(c, &req); err != nil {
return c.JSON(400, errResp("invalid body"))
}
owner, name, err := target(c, &req)
if err != nil {
return err
}
if strings.TrimSpace(req.MfaType) == "" {
return c.JSON(200, errResp("mfaType is required"))
}
u, err := store.GetUserByName(c.Context(), db, owner, name)
if err != nil {
return c.JSON(500, errResp("server_error"))
}
if u == nil {
return c.JSON(404, errResp("user not found"))
}
u.PreferredMfaType = req.MfaType
if err := u.UpdateCtx(c.Context()); err != nil {
return c.JSON(500, errResp("server_error"))
}
return c.JSON(200, okData(nil))
}
}
// ---- helpers ----
func decode(c *zip.Ctx, v any) error {
body := c.Body()
if len(body) == 0 {
return errors.New("empty request body")
}
return json.Unmarshal(body, v)
}
// issuer is the otpauth issuer label the authenticator app shows: an explicit
// IAM_MFA_ISSUER override (white-label brand), else the account's org, else Hanzo.
func issuer(owner string) string {
if v := strings.TrimSpace(os.Getenv("IAM_MFA_ISSUER")); v != "" {
return v
}
if owner != "" {
return owner
}
return "Hanzo"
}
// recoveryCode returns a 160-bit base32 single-use backup code.
func recoveryCode() (string, error) {
b := make([]byte, 20)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b), nil
}
func okData(data any) map[string]any {
m := map[string]any{"status": "ok"}
if data != nil {
m["data"] = data
}
return m
}
func errResp(msg string) map[string]any { return map[string]any{"status": "error", "msg": msg} }
+283
View File
@@ -0,0 +1,283 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package mfa_test
// TOTP MFA tests driven through the REAL registered router (routes.Route installs
// the Guard, then mfa.Route after it). Every case is a HTTP request the account
// security page sends. The assertions pin the enrollment contract (initiate mints
// a secret the client can turn into a valid passcode; enable persists it) and the
// security one: enrollment is self-service on your OWN record, and a regular user
// can NEVER touch another user's MFA — that needs admin authority.
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"io"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/pquerna/otp/totp"
"github.com/hanzoai/orm"
ormdb "github.com/hanzoai/orm/db"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/routes"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/testhttp"
)
const signingKid = "cert-hanzo"
type harness struct {
app *zip.App
key *rsa.PrivateKey
db orm.DB
}
func newHarness(t *testing.T) *harness {
t.Helper()
_ = schema.Kinds()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa: %v", err)
}
dir := t.TempDir()
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(dir, "mfa.db"),
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
seedCert(t, db, "admin", signingKid, pemOf(t, key))
seedUser(t, db, "admin", "root", true) // SuperAdmin (org == admin)
seedUser(t, db, "hanzo", "boss", true) // org-admin of hanzo
seedUser(t, db, "hanzo", "alice", false) // regular user in hanzo
app := zip.New(zip.Config{AppName: "mfa-test", DisableStartupMessage: true})
routes.Route(app, db)
if err := app.Build(); err != nil {
t.Fatalf("build: %v", err)
}
return &harness{app: app, key: key, db: db}
}
func (h *harness) token(t *testing.T, sub string) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"sub": sub,
"iat": time.Now().Add(-time.Minute).Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
})
tok.Header["kid"] = signingKid
s, err := tok.SignedString(h.key)
if err != nil {
t.Fatalf("sign: %v", err)
}
return s
}
func (h *harness) do(t *testing.T, path, bearer, body string) (int, map[string]any) {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req := httptest.NewRequest("POST", path, r)
req.Host = "hanzo.id"
req.Header.Set("Content-Type", "application/json")
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := testhttp.Do(h.app, req)
if err != nil {
t.Fatalf("POST %s: %v", path, err)
}
b, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
var m map[string]any
_ = json.Unmarshal(b, &m)
return resp.StatusCode, m
}
// dataString reads m.data.<key> as a string.
func dataString(m map[string]any, key string) string {
d, _ := m["data"].(map[string]any)
s, _ := d[key].(string)
return s
}
// TestMFA_enrollLifecycle: a regular user enrolls TOTP on her own account —
// initiate mints a secret she can turn into a valid passcode, verify accepts it,
// enable persists it, disable clears it.
func TestMFA_enrollLifecycle(t *testing.T) {
h := newHarness(t)
alice := h.token(t, "hanzo/alice")
// initiate — a secret, an otpauth URL, and a recovery code.
st, m := h.do(t, "/v1/iam/mfa/setup/initiate", alice, `{}`)
if st != 200 || m["status"] != "ok" {
t.Fatalf("initiate: status=%d body=%v", st, m)
}
secret := dataString(m, "secret")
if secret == "" {
t.Fatalf("initiate returned no secret: %v", m)
}
if url := dataString(m, "url"); !strings.HasPrefix(url, "otpauth://totp/") {
t.Fatalf("initiate url is not an otpauth URI: %q", url)
}
d, _ := m["data"].(map[string]any)
codes, _ := d["recoveryCodes"].([]any)
if len(codes) == 0 || codes[0].(string) == "" {
t.Fatalf("initiate returned no recovery code: %v", d)
}
recovery := codes[0].(string)
// verify — a code derived from the secret is accepted.
code, err := totp.GenerateCode(secret, time.Now())
if err != nil {
t.Fatalf("totp code: %v", err)
}
if st, m := h.do(t, "/v1/iam/mfa/setup/verify", alice,
`{"secret":"`+secret+`","passcode":"`+code+`"}`); st != 200 || m["status"] != "ok" {
t.Fatalf("verify valid code: status=%d body=%v", st, m)
}
// enable — the secret + recovery code land on alice's row; TOTP is preferred.
if st, m := h.do(t, "/v1/iam/mfa/setup/enable", alice,
`{"secret":"`+secret+`","recoveryCodes":["`+recovery+`"]}`); st != 200 || m["status"] != "ok" {
t.Fatalf("enable: status=%d body=%v", st, m)
}
u, _ := store.GetUserByName(context.Background(), h.db, "hanzo", "alice")
if u == nil || u.TotpSecret != secret {
t.Fatalf("enable did not persist TotpSecret: %+v", u)
}
if u.PreferredMfaType != "app" {
t.Fatalf("preferredMfaType = %q, want app", u.PreferredMfaType)
}
if len(u.RecoveryCodes) == 0 {
t.Fatalf("enable did not persist recovery codes")
}
// disable — every TOTP field is cleared.
if st, m := h.do(t, "/v1/iam/delete-mfa", alice, `{}`); st != 200 || m["status"] != "ok" {
t.Fatalf("disable: status=%d body=%v", st, m)
}
u, _ = store.GetUserByName(context.Background(), h.db, "hanzo", "alice")
if u.TotpSecret != "" || u.PreferredMfaType != "" || len(u.RecoveryCodes) != 0 {
t.Fatalf("disable did not clear MFA fields: %+v", u)
}
}
// TestMFA_verifyRejectsBadCode: an incorrect passcode is refused (status:error at
// 200 — the casibase convention the console branches on).
func TestMFA_verifyRejectsBadCode(t *testing.T) {
h := newHarness(t)
alice := h.token(t, "hanzo/alice")
_, m := h.do(t, "/v1/iam/mfa/setup/initiate", alice, `{}`)
secret := dataString(m, "secret")
st, body := h.do(t, "/v1/iam/mfa/setup/verify", alice,
`{"secret":"`+secret+`","passcode":"000000"}`)
if st != 200 || body["status"] != "error" {
t.Fatalf("bad code should be rejected: status=%d body=%v", st, body)
}
}
// TestMFA_crossUserRequiresAdmin: a regular user cannot enroll/disable MFA on
// ANOTHER user — the general user-write policy refuses it (403). An org-admin and
// a super over that user CAN.
func TestMFA_crossUserRequiresAdmin(t *testing.T) {
h := newHarness(t)
alice := h.token(t, "hanzo/alice") // regular
boss := h.token(t, "hanzo/boss") // org-admin of hanzo
super := h.token(t, "admin/root") // SuperAdmin
// alice → boss's MFA: forbidden.
body := `{"owner":"hanzo","name":"boss"}`
if st, _ := h.do(t, "/v1/iam/mfa/setup/initiate", alice, body); st != 403 {
t.Fatalf("regular user initiating another user's MFA: status=%d, want 403", st)
}
if st, _ := h.do(t, "/v1/iam/delete-mfa", alice, body); st != 403 {
t.Fatalf("regular user disabling another user's MFA: status=%d, want 403", st)
}
// org-admin → a user in the SAME org: allowed.
if st, m := h.do(t, "/v1/iam/mfa/setup/initiate", boss,
`{"owner":"hanzo","name":"alice"}`); st != 200 || m["status"] != "ok" {
t.Fatalf("org-admin initiating a same-org user's MFA: status=%d body=%v", st, m)
}
// super → anyone: allowed.
if st, m := h.do(t, "/v1/iam/mfa/setup/initiate", super,
`{"owner":"hanzo","name":"alice"}`); st != 200 || m["status"] != "ok" {
t.Fatalf("super initiating a user's MFA: status=%d body=%v", st, m)
}
}
// TestMFA_setPreferred: a user selects a preferred factor on her own account.
func TestMFA_setPreferred(t *testing.T) {
h := newHarness(t)
alice := h.token(t, "hanzo/alice")
if st, m := h.do(t, "/v1/iam/set-preferred-mfa", alice, `{"mfaType":"app"}`); st != 200 || m["status"] != "ok" {
t.Fatalf("set-preferred-mfa: status=%d body=%v", st, m)
}
u, _ := store.GetUserByName(context.Background(), h.db, "hanzo", "alice")
if u.PreferredMfaType != "app" {
t.Fatalf("preferredMfaType = %q, want app", u.PreferredMfaType)
}
}
// TestMFA_requiresBearer: no token → the Guard refuses before the handler.
func TestMFA_requiresBearer(t *testing.T) {
h := newHarness(t)
if st, _ := h.do(t, "/v1/iam/mfa/setup/initiate", "", `{}`); st != 401 {
t.Fatalf("no-bearer initiate: status=%d, want 401", st)
}
}
// ---- seed helpers (mirror the SCIM harness) ----
func seedCert(t *testing.T, db orm.DB, owner, name, privPEM string) {
t.Helper()
c := orm.New[schema.Cert](db)
c.Owner, c.Name = owner, name
c.PrivateKey = privPEM
c.SetId(owner + "/" + name)
if err := c.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed cert: %v", err)
}
}
func seedUser(t *testing.T, db orm.DB, owner, name string, admin bool) {
t.Helper()
u := orm.New[schema.User](db)
u.Owner, u.Name = owner, name
u.IsAdmin = admin
u.PasswordHash = "$argon2id$SENTINEL"
u.PasswordType = "argon2id"
u.SetId(owner + "/" + name)
if err := u.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed user: %v", err)
}
}
func pemOf(t *testing.T, k *rsa.PrivateKey) string {
t.Helper()
return string(pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k),
}))
}
+31
View File
@@ -0,0 +1,31 @@
// Code generated by zipdoc; DO NOT EDIT.
package mfa
import (
"github.com/zap-proto/zip"
)
func init() {
zip.Describe("POST /v1/iam/delete-mfa", zip.Doc{
Description: "Turns off the authenticator app for an account, so sign-in stops\nasking for a code. People may do this for themselves; doing it for somebody\nelse takes an administrator, which is what makes it the reset path when a\nphone is lost.",
})
zip.Describe("POST /v1/iam/mfa/disable", zip.Doc{
Description: "Turns off the authenticator app for an account, so sign-in stops\nasking for a code. People may do this for themselves; doing it for somebody\nelse takes an administrator, which is what makes it the reset path when a\nphone is lost.",
})
zip.Describe("POST /v1/iam/mfa/preferred", zip.Doc{
Description: "Picks which second factor an account is asked for first when it\nhas more than one enrolled.",
})
zip.Describe("POST /v1/iam/mfa/setup/enable", zip.Doc{
Description: "Finishes the enrolment: from here the account's sign-ins ask for a code\nfrom the authenticator app. Repeating it re-enrols rather than failing.",
})
zip.Describe("POST /v1/iam/mfa/setup/initiate", zip.Doc{
Description: "Starts enrolling an authenticator app: it returns a fresh secret, a\nURL to render as a QR code, and one recovery code to keep somewhere safe.\n\nNothing is switched on yet. The enrolment counts only once it is confirmed with\na code from the app, so abandoning this step leaves the account exactly as it\nwas. Response:\n{status:\"ok\", data:{secret, url, recoveryCodes:[code]}}.",
})
zip.Describe("POST /v1/iam/mfa/setup/verify", zip.Doc{
Description: "Checks a six-digit code against an enrolment in progress, so somebody\ncan confirm their authenticator app is set up correctly before it starts being\nrequired. Clocks a step out either way are accepted.\nA valid code → {status:\"ok\"}; an invalid one → 200 {status:\"error\"} (the\ncasibase convention: clients branch on status, not the HTTP code).",
})
zip.Describe("POST /v1/iam/set-preferred-mfa", zip.Doc{
Description: "Picks which second factor an account is asked for first when it\nhas more than one enrolled.",
})
}
+293
View File
@@ -0,0 +1,293 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
"net/url"
"strings"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
)
// The authorization endpoint: GET/POST /v1/iam/oauth/authorize — the front door
// of the authorization-code flow. iam validates the request BEFORE it trusts
// any redirect: an unknown client_id or an unregistered redirect_uri is answered
// in place and NEVER redirected to (RFC 6749 §4.1.2.1), closing the open-redirect
// and code-injection surface that a bare pass-through would leave open.
//
// A validated request then has THREE possible answers, and which one it gets is
// the whole of single sign-on:
//
// the session answers it — a code, straight back to the registered
// redirect_uri, no screen (prompt.go)
// nobody is signed in, and — error=login_required, back to the registered
// the client said none redirect_uri, still no screen
// otherwise — the hosted login UI, which collects credentials
// and posts to /v1/iam/login
//
// Before this, only the third existed: every request rendered a login page,
// prompt=none included. A relying party therefore had no way to ask "is anyone
// signed in?" without putting a login screen in front of a user who already
// was — which is not a missing feature, it is the absence of SSO.
// hostedLoginPath is the default hosted-login route the authorize endpoint hands
// a validated request to when the application pins no SigninUrl of its own.
const hostedLoginPath = "/login/oauth/authorize"
// authorizeRequest is the parsed authorize query.
type authorizeRequest struct {
responseType string
clientID string
redirectURI string
scope string
state string
nonce string
codeChallenge string
codeChallengeMethod string
resource string
responseMode string
provider string
prompt string
}
// authorizeHandler starts a sign-in — the address you send a browser to, and the
// beginning of every OAuth and OpenID Connect flow.
//
// If the person is ALREADY signed in here, it does not ask them again: it
// returns them to the application with a one-time code and they never see this
// page. Otherwise it shows the right way to sign in for the application they are
// signing in to, or hands off to another identity provider if that is what they
// pick.
//
// A client can say what it wants with `prompt`: `none` means answer without any
// screen at all — with the code if a session exists, with an error if not, but
// never with a page; `login` means ask for the password again even if a session
// exists; `select_account` means let the person choose which identity to use.
//
// It returns only to an address the application has registered. That check
// happens before anything else, so a request naming an unregistered address is
// refused where the person can see it rather than being bounced onwards.
func authorizeHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
// A sign-in must run AT its brand's issuer, because everything a flow
// sets along the way — the hanzo_fed browser binding, the session — is a
// host-only cookie, while the IdP callback and `iss` are pinned to the
// issuer. Answering on an alias host (iam.hanzo.ai, www.zoolabs.id, any
// host the map folds) strands those cookies and social sign-in fails
// closed at the callback. So an alias is answered with the same request
// relocated to the issuer, before anything is minted or set; 307 keeps
// the method. See issuerRelocation for the fail-closed guards.
if loc, ok := issuerRelocation(c); ok {
return c.Redirect(307, loc)
}
ctx := c.Context()
q := authorizeParams(c)
// 1. Resolve the client. Without a known client there is no trusted
// redirect target, so the error is shown in place — never redirected.
if q.clientID == "" {
return authorizeUserError(c, "client_id is required")
}
app, err := store.GetApplicationByClientId(ctx, db, q.clientID)
if err != nil {
return authorizeUserError(c, "internal error")
}
if app == nil {
return authorizeUserError(c, "unknown client_id")
}
// 2. redirect_uri must EXACTLY match a registered URI before it can ever
// be used as a redirect target. A mismatch is answered in place.
if q.redirectURI == "" || !app.IsRedirectUriValid(q.redirectURI) {
return authorizeUserError(c, "invalid redirect_uri")
}
// The redirect target is now trusted: protocol errors redirect back to it
// with error+state (RFC 6749 §4.1.2.1).
if q.responseType != "code" {
return authorizeErrorRedirect(c, q, "unsupported_response_type", "only response_type=code is supported")
}
method := normalizeChallengeMethod(q.codeChallenge, q.codeChallengeMethod)
if q.codeChallenge != "" && method != "S256" {
return authorizeErrorRedirect(c, q, "invalid_request", "only S256 PKCE is supported")
}
if app.ClientSecret == "" && q.codeChallenge == "" {
return authorizeErrorRedirect(c, q, "invalid_request", "PKCE is required for public clients")
}
p := parsePrompt(q.prompt)
if p.combined {
return authorizeErrorRedirect(c, q, "invalid_request", "prompt=none must not be combined with other values")
}
// A request that names a social `provider` is federated to that external
// IdP (Google/GitHub, …) instead of the hosted credential login. The
// client + redirect_uri + PKCE policy above are already enforced, so the
// federation broker starts from a validated request and a trusted target.
//
// It is decided BEFORE the session is consulted, because naming a provider
// is an explicit instruction about WHICH identity to authenticate — the
// person pressed "continue with Google" — and an ambient session is not an
// answer to that. Which also means it can never be silent: the external IdP
// is the one who decides, and reaching it is an interaction.
if q.provider != "" {
if p.none {
return authorizeErrorRedirect(c, q, errInteractionRequired, "an external identity provider cannot be used without interaction")
}
return beginFederation(c, db, app, q, method)
}
// SINGLE SIGN-ON. A live session answers the request outright — this is
// the branch that means "log in once at the issuer and every other app
// already knows you". It is skipped only when the client asked for a
// screen (prompt=login / select_account), and its refusals are the OIDC
// error codes prompt=none is owed.
if !p.interactive() {
code, refusal := silentGrant(c, db, app, q)
if refusal == "" {
return authorizeCodeRedirect(c, q, code)
}
if p.none {
return authorizeErrorRedirect(c, q, refusal, "no interaction was permitted and the request could not be answered from an existing session")
}
}
// prompt=none has now been answered one way or the other; reaching here
// with it set means the client asked for no UI and for a UI at once, which
// `combined` already refused. Everything else gets the hosted login with a
// clean, re-encoded request. The login page posts credentials to
// /v1/iam/login, which mints the code.
q.prompt = p.forwarded()
return c.Redirect(302, hostedLoginTarget(app)+"?"+authorizeForwardQuery(q, method))
}
}
// authorizeParams reads the authorize parameters from the query (GET) or form
// body (POST).
func authorizeParams(c *zip.Ctx) authorizeRequest {
return authorizeRequest{
responseType: param(c, "response_type"),
clientID: param(c, "client_id"),
redirectURI: param(c, "redirect_uri"),
scope: param(c, "scope"),
state: param(c, "state"),
nonce: param(c, "nonce"),
codeChallenge: param(c, "code_challenge"),
codeChallengeMethod: param(c, "code_challenge_method"),
resource: param(c, "resource"),
responseMode: param(c, "response_mode"),
provider: param(c, "provider"),
prompt: param(c, "prompt"),
}
}
// hostedLoginTarget is the login URL a validated request is delegated to — the
// application's own SigninUrl when set, else the default hosted-login route.
func hostedLoginTarget(app *schema.Application) string {
if app.SigninUrl != "" {
return app.SigninUrl
}
return hostedLoginPath
}
// authorizeForwardQuery re-encodes the validated request as a clean query string
// for the hosted login — reconstructed from known parameters so nothing
// unexpected is passed through.
func authorizeForwardQuery(q authorizeRequest, method string) string {
v := url.Values{}
v.Set("response_type", "code")
v.Set("client_id", q.clientID)
v.Set("redirect_uri", q.redirectURI)
setIfPresent(v, "scope", q.scope)
setIfPresent(v, "state", q.state)
setIfPresent(v, "nonce", q.nonce)
if q.codeChallenge != "" {
v.Set("code_challenge", q.codeChallenge)
v.Set("code_challenge_method", method)
}
setIfPresent(v, "resource", q.resource)
setIfPresent(v, "response_mode", q.responseMode)
// The surviving prompt is carried to the page, because the page is what has
// to act on it: `select_account` is a request to show an account CHOOSER
// rather than a bare credential form, and only the UI can do that. `none`
// never reaches here — it is answered above, without a page, which is what it
// asked for.
setIfPresent(v, "prompt", q.prompt)
return v.Encode()
}
// authorizeCodeRedirect returns a successful silent authorization to the client:
// the code and the state, on the registered redirect_uri.
//
// It is the SAME return path an interactive sign-in takes — the browser lands on
// the client's callback with a code it exchanges at /token — so nothing
// downstream can tell the two apart, and nothing downstream has to.
func authorizeCodeRedirect(c *zip.Ctx, q authorizeRequest, code string) error {
v := url.Values{}
v.Set("code", code)
return authorizeRedirect(c, q, v)
}
// authorizeErrorRedirect bounces a protocol error back to the (already
// validated) redirect_uri with error+state, in the requested response mode.
func authorizeErrorRedirect(c *zip.Ctx, q authorizeRequest, code, desc string) error {
v := url.Values{}
v.Set("error", code)
setIfPresent(v, "error_description", desc)
return authorizeRedirect(c, q, v)
}
// authorizeRedirect returns the browser to the redirect_uri carrying v, in the
// requested response mode, with `state` echoed.
//
// Success and failure share it deliberately. They are the same act — hand these
// parameters to the client's registered address — and splitting them is how a
// server ends up echoing state on one and forgetting it on the other, or
// honouring response_mode=fragment for an error and not for a code.
//
// It runs only AFTER redirect_uri has been matched against the application's
// registered list, which is what makes appending to it safe.
func authorizeRedirect(c *zip.Ctx, q authorizeRequest, v url.Values) error {
setIfPresent(v, "state", q.state)
sep := "?"
switch {
case q.responseMode == "fragment":
sep = "#"
case strings.Contains(q.redirectURI, "?"):
sep = "&"
}
return c.Redirect(302, q.redirectURI+sep+v.Encode())
}
// authorizeUserError answers a request whose client_id/redirect_uri could not be
// validated: the resource owner is informed in place and the request is NOT
// redirected anywhere (RFC 6749 §4.1.2.1). The message is server-controlled.
func authorizeUserError(c *zip.Ctx, msg string) error {
c.SetHeader("Content-Type", "text/plain; charset=utf-8")
return c.String(400, "authorization error: "+msg)
}
// normalizeChallengeMethod maps an omitted PKCE method to S256 when a challenge
// is present (S256 is the only method iam supports); an explicit non-S256
// method is returned unchanged so the caller rejects the downgrade.
func normalizeChallengeMethod(challenge, method string) string {
if challenge == "" {
return method
}
if method == "" || strings.EqualFold(method, "null") {
return "S256"
}
return method
}
// setIfPresent sets a query value only when non-empty.
func setIfPresent(v url.Values, key, value string) {
if value != "" {
v.Set(key, value)
}
}
+221
View File
@@ -0,0 +1,221 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
"net/http"
"net/url"
"strings"
"testing"
"github.com/hanzoai/iam/pkg/pkce"
)
const testRedirect = "https://app.example/callback"
func authorizeURL(q url.Values) string {
return PathAuthorize + "?" + q.Encode()
}
// The authorize endpoint validates the client and redirect_uri BEFORE it will
// redirect anywhere: an unknown client or an unregistered redirect_uri is
// answered in place (never bounced), closing the open-redirect surface.
func TestAuthorize_RefusesToRedirectOnBadClientOrRedirect(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
cases := []struct {
name string
q url.Values
}{
{"missing client_id", url.Values{"response_type": {"code"}, "redirect_uri": {testRedirect}}},
{"unknown client_id", url.Values{"response_type": {"code"}, "client_id": {"ghost"}, "redirect_uri": {testRedirect}}},
{"missing redirect_uri", url.Values{"response_type": {"code"}, "client_id": {"pub"}}},
{"unregistered redirect_uri", url.Values{"response_type": {"code"}, "client_id": {"pub"}, "redirect_uri": {"https://evil.example/steal"}}},
{"redirect near-match", url.Values{"response_type": {"code"}, "client_id": {"pub"}, "redirect_uri": {testRedirect + "/.."}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(tc.q)))
if resp.StatusCode != 400 {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); loc != "" {
t.Fatalf("must NOT redirect on bad client/redirect; got Location %q", loc)
}
})
}
}
// Once the client + redirect_uri are validated, a protocol error bounces back to
// the (trusted) redirect_uri with error + state.
func TestAuthorize_ProtocolErrorRedirectsToClient(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
t.Run("unsupported response_type", func(t *testing.T) {
q := url.Values{"response_type": {"token"}, "client_id": {"pub"}, "redirect_uri": {testRedirect}, "state": {"xyz"}, "code_challenge": {"abc"}}
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(q)))
loc := requireRedirect(t, resp, testRedirect)
if !strings.Contains(loc, "error=unsupported_response_type") || !strings.Contains(loc, "state=xyz") {
t.Fatalf("Location = %q", loc)
}
})
t.Run("public client without PKCE", func(t *testing.T) {
q := url.Values{"response_type": {"code"}, "client_id": {"pub"}, "redirect_uri": {testRedirect}, "state": {"s1"}}
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(q)))
loc := requireRedirect(t, resp, testRedirect)
if !strings.Contains(loc, "error=invalid_request") {
t.Fatalf("public client without PKCE should error; Location = %q", loc)
}
})
t.Run("plain PKCE rejected", func(t *testing.T) {
q := url.Values{"response_type": {"code"}, "client_id": {"pub"}, "redirect_uri": {testRedirect}, "code_challenge": {"abc"}, "code_challenge_method": {"plain"}}
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(q)))
loc := requireRedirect(t, resp, testRedirect)
if !strings.Contains(loc, "error=invalid_request") {
t.Fatalf("plain PKCE should be rejected; Location = %q", loc)
}
})
}
// A well-formed request is delegated to the hosted login with the (re-encoded)
// request preserved.
func TestAuthorize_DelegatesValidRequest(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
challenge := pkce.Challenge("verifier-abcdefghijklmnopqrstuvwxyz-012345")
q := url.Values{
"response_type": {"code"},
"client_id": {"pub"},
"redirect_uri": {testRedirect},
"scope": {"openid profile"},
"state": {"state-1"},
"nonce": {"nonce-1"},
"code_challenge": {challenge},
}
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(q)))
if resp.StatusCode != 302 {
t.Fatalf("status = %d, want 302", resp.StatusCode)
}
loc := resp.Header.Get("Location")
if !strings.HasPrefix(loc, hostedLoginPath+"?") {
t.Fatalf("Location = %q, want hosted-login delegate", loc)
}
forwarded, err := url.Parse(loc)
if err != nil {
t.Fatal(err)
}
fq := forwarded.Query()
if fq.Get("client_id") != "pub" || fq.Get("redirect_uri") != testRedirect ||
fq.Get("code_challenge") != challenge || fq.Get("code_challenge_method") != "S256" ||
fq.Get("state") != "state-1" || fq.Get("nonce") != "nonce-1" {
t.Fatalf("delegated query missing/incorrect: %v", fq)
}
}
// A sign-in must run AT its brand's pinned issuer: the hanzo_fed browser
// binding and the session are host-only cookies, while the IdP callback and
// `iss` live at the issuer. An authorize served on an alias host (iam.hanzo.ai
// folding into hanzo.id) is therefore answered with the SAME request relocated
// to the issuer — 307, query intact, before anything is minted or set. Measured
// live before this hop: a begin on iam.hanzo.ai set the cookie there and
// registered the Google callback at hanzo.id, so every social sign-in on the
// alias failed closed at the callback with "the federation session could not
// be verified".
func TestAuthorize_AliasHostRelocatesToIssuer(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
installIssuerResolver(t, "https://hanzo.id", testIssuerMap)
q := url.Values{
"response_type": {"code"},
"client_id": {"pub"},
"redirect_uri": {testRedirect},
"state": {"s-alias"},
"code_challenge": {pkce.Challenge("verifier-abcdefghijklmnopqrstuvwxyz-012345")},
"provider": {"provider-google"},
}
target := authorizeURL(q)
t.Run("alias relocates, method kept, nothing set", func(t *testing.T) {
for _, method := range []string{"GET", "POST"} {
req := formReqNoBody(method, target)
req.Host = "iam.hanzo.ai"
resp, _ := do(t, app, req)
if resp.StatusCode != 307 {
t.Fatalf("%s status = %d, want 307", method, resp.StatusCode)
}
if loc := resp.Header.Get("Location"); loc != "https://hanzo.id"+target {
t.Fatalf("%s Location = %q, want %q", method, loc, "https://hanzo.id"+target)
}
// Relocation precedes every mint: a cookie set here would be the
// stranded-cookie bug this hop exists to close.
if sc := resp.Header.Get("Set-Cookie"); sc != "" {
t.Fatalf("%s relocation must set nothing; Set-Cookie = %q", method, sc)
}
}
})
t.Run("issuer host is terminal", func(t *testing.T) {
req := formReqNoBody("GET", target)
req.Host = "hanzo.id"
resp, _ := do(t, app, req)
if resp.StatusCode == 307 {
t.Fatalf("issuer host must not relocate; got 307 to %q", resp.Header.Get("Location"))
}
})
t.Run("unknown host folds to the default issuer", func(t *testing.T) {
req := formReqNoBody("GET", target)
req.Host = "www.zoolabs.id" // deliberately absent from testIssuerMap
resp, _ := do(t, app, req)
if resp.StatusCode != 307 {
t.Fatalf("status = %d, want 307", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); loc != "https://hanzo.id"+target {
t.Fatalf("Location = %q, want fold to the default issuer", loc)
}
})
t.Run("a non-idempotent map must not steer", func(t *testing.T) {
installIssuerResolver(t, "https://a.example",
`{"x.example":"https://a.example","a.example":"https://b.example"}`)
req := formReqNoBody("GET", target)
req.Host = "x.example"
resp, _ := do(t, app, req)
if resp.StatusCode == 307 {
t.Fatalf("ping-pong map must serve in place; got 307 to %q", resp.Header.Get("Location"))
}
})
}
// A confidential client may authorize without PKCE (it authenticates with its
// secret at the token endpoint).
func TestAuthorize_ConfidentialWithoutPKCEDelegates(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
q := url.Values{"response_type": {"code"}, "client_id": {"conf"}, "redirect_uri": {testRedirect}, "scope": {"openid"}}
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(q)))
if resp.StatusCode != 302 || !strings.HasPrefix(resp.Header.Get("Location"), hostedLoginPath+"?") {
t.Fatalf("confidential authorize: status=%d loc=%q", resp.StatusCode, resp.Header.Get("Location"))
}
}
// requireRedirect asserts a 302 whose Location targets wantPrefix and returns it.
func requireRedirect(t *testing.T, resp *http.Response, wantPrefix string) string {
t.Helper()
if resp.StatusCode != 302 {
t.Fatalf("status = %d, want 302", resp.StatusCode)
}
loc := resp.Header.Get("Location")
if !strings.HasPrefix(loc, wantPrefix) {
t.Fatalf("Location = %q, want prefix %q", loc, wantPrefix)
}
return loc
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
// Canonical noun addresses for the front-door endpoints that were spelled as
// verb-nouns.
//
// A path segment names a THING, and the HTTP method says what is being done to
// it. `POST /v1/iam/send-verification-code` says the verb twice and the noun
// once; `POST /v1/iam/verification-codes` says each exactly once. The verb-noun
// spellings came in with the entity store this service replaced, and they are
// what a customer reads in `hanzo iam --help`, in every generated SDK method
// name and on every docs page — so they are a customer-facing surface, not an
// internal detail.
//
// Every constant below is the address the published document declares. The old
// spelling stays REACHABLE — same handler, registered twice by alias() — so no
// consumer pinned to it breaks; it is simply not what anything teaches. When the
// last pinned consumer moves, the Legacy* half of a pair is deleted and nothing
// else changes.
const (
PathAccount = "/v1/iam/account" // legacy: get-account
PathAuthApplication = "/v1/iam/auth/application" // legacy: get-app-login
PathPreferences = "/v1/iam/preferences" // legacy: update-preferences
PathVerificationCodes = "/v1/iam/verification-codes" // legacy: send-verification-code
PathTokensIssue = "/v1/iam/tokens/issue" // legacy: issue-user-token
PathKeysMint = "/v1/iam/keys/mint" // legacy: mint-user-keys
PathKeysRevoke = "/v1/iam/keys/revoke" // legacy: revoke-user-keys
)
// The verb-noun spellings these replaced. Kept reachable, taught nowhere.
const (
LegacyPathAccount = "/v1/iam/get-account"
LegacyPathAuthApplication = "/v1/iam/get-app-login"
LegacyPathPreferences = "/v1/iam/update-preferences"
LegacyPathVerificationCodes = "/v1/iam/send-verification-code"
LegacyPathTokensIssue = "/v1/iam/issue-user-token"
LegacyPathKeysMint = "/v1/iam/mint-user-keys"
LegacyPathKeysRevoke = "/v1/iam/revoke-user-keys"
)
+40
View File
@@ -0,0 +1,40 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
"net/http"
"testing"
)
// Every front-door endpoint that used to be spelled as a verb-noun answers at
// BOTH its canonical noun address and the legacy spelling, from ONE handler.
//
// This is the whole contract of alias(): the canonical address is what the
// published document, the SDKs and the CLI teach, and the legacy one stays
// reachable so a consumer pinned to it does not break. The test that matters is
// not "the new address works" — it is that neither address 404s, because a
// rename that quietly drops the old spelling is an outage in the console, and a
// rename nobody registers is a document that lies.
func TestCanonicalAndLegacyAddressesBothRoute(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedRichUser(t, db)
for _, tc := range []struct{ method, canonical, legacy string }{
{"GET", PathAccount, LegacyPathAccount},
{"GET", PathAuthApplication, LegacyPathAuthApplication},
{"POST", PathPreferences, LegacyPathPreferences},
{"POST", PathVerificationCodes, LegacyPathVerificationCodes},
{"POST", PathTokensIssue, LegacyPathTokensIssue},
{"POST", PathKeysMint, LegacyPathKeysMint},
{"POST", PathKeysRevoke, LegacyPathKeysRevoke},
} {
for _, path := range []string{tc.canonical, tc.legacy} {
resp, _ := do(t, app, formReqNoBody(tc.method, path))
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed {
t.Errorf("%s %s -> %d, want any answer but not-routed", tc.method, path, resp.StatusCode)
}
}
}
}
+166
View File
@@ -0,0 +1,166 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"math/big"
"strings"
"github.com/luxfi/crypto/pq/mldsa/mldsa65"
"github.com/hanzoai/iam/pkg/schema"
)
// certkey resolves the PUBLIC half of a signing Cert and encodes it as a JWK.
// It is the one place cert → public-key happens, shared by the JWKS endpoint
// (which publishes the key so relying parties can verify) and token
// verification (which checks a bearer against it). The public key is read from
// the Cert's published x509 certificate when present, else derived from the key
// pair; private material never crosses this boundary.
// certPublicKey returns a Cert's public key, its JOSE alg, and (for x509 certs)
// the base64 DER chain for the JWK `x5c`. An ML-DSA cert yields a raw ML-DSA
// public key and no chain.
func certPublicKey(cert *schema.Cert) (pub crypto.PublicKey, alg string, x5c []string, err error) {
if cert == nil {
return nil, "", nil, errors.New("jwks: nil cert")
}
if isMLDSACert(cert) {
pk, err := mldsa65PublicFromCert(cert)
if err != nil {
return nil, "", nil, err
}
return pk, algMLDSA65, nil, nil
}
if cert.Certificate != "" {
block, _ := pem.Decode([]byte(cert.Certificate))
if block != nil {
x509Cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, "", nil, err
}
a, err := classicalAlg(x509Cert.PublicKey, cert.CryptoAlgorithm)
if err != nil {
return nil, "", nil, err
}
return x509Cert.PublicKey, a, []string{base64.StdEncoding.EncodeToString(x509Cert.Raw)}, nil
}
}
// Dev/test cert that stores only the private key: derive the public half.
signer, err := parsePrivateKeyPEM(cert.PrivateKey)
if err != nil {
return nil, "", nil, err
}
a, err := classicalAlg(signer.Public(), cert.CryptoAlgorithm)
if err != nil {
return nil, "", nil, err
}
return signer.Public(), a, nil, nil
}
// certToJWK encodes a Cert's public key as a JWK map: {kty, alg, use:"sig", kid,
// key params, x5c?}. kid is the Cert name (what token headers carry), matching
// the live hanzo.id JWKS.
func certToJWK(cert *schema.Cert) (map[string]any, error) {
pub, alg, x5c, err := certPublicKey(cert)
if err != nil {
return nil, err
}
var jwk map[string]any
switch k := pub.(type) {
case *rsa.PublicKey:
jwk = rsaJWK(k)
case *ecdsa.PublicKey:
jwk, err = ecJWK(k)
if err != nil {
return nil, err
}
case *mldsa65.PublicKey:
jwk = map[string]any{"kty": "MLDSA", "x": base64.RawURLEncoding.EncodeToString(k.Bytes())}
default:
return nil, errors.New("jwks: unsupported public key type")
}
jwk["use"] = "sig"
jwk["kid"] = cert.Name
jwk["alg"] = alg
if len(x5c) > 0 {
jwk["x5c"] = x5c
}
return jwk, nil
}
// rsaJWK encodes an RSA public key's modulus and exponent (RFC 7518 §6.3).
func rsaJWK(k *rsa.PublicKey) map[string]any {
return map[string]any{
"kty": "RSA",
"n": base64.RawURLEncoding.EncodeToString(k.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes()),
}
}
// ecJWK encodes an EC public key's curve and fixed-width coordinates (RFC 7518
// §6.2) and returns the curve's JOSE alg.
func ecJWK(k *ecdsa.PublicKey) (map[string]any, error) {
var crv string
var size int
switch k.Curve.Params().BitSize {
case 256:
crv, size = "P-256", 32
case 384:
crv, size = "P-384", 48
case 521:
crv, size = "P-521", 66
default:
return nil, errors.New("jwks: unsupported EC curve")
}
return map[string]any{
"kty": "EC",
"crv": crv,
"x": base64.RawURLEncoding.EncodeToString(leftPad(k.X.Bytes(), size)),
"y": base64.RawURLEncoding.EncodeToString(leftPad(k.Y.Bytes(), size)),
}, nil
}
// classicalAlg maps a classical public key (and the Cert's declared algorithm,
// when it agrees with the key family) to a JOSE alg. The key type is
// authoritative; the declared value only refines RSA (RS256 default, RS512 when
// pinned).
func classicalAlg(pub crypto.PublicKey, declared string) (string, error) {
switch k := pub.(type) {
case *rsa.PublicKey:
if strings.EqualFold(declared, "RS512") {
return "RS512", nil
}
return "RS256", nil
case *ecdsa.PublicKey:
switch k.Curve.Params().BitSize {
case 256:
return "ES256", nil
case 384:
return "ES384", nil
case 521:
return "ES512", nil
}
return "", errors.New("jwks: unsupported EC curve")
default:
return "", errors.New("jwks: unsupported public key type")
}
}
// leftPad left-zero-pads b to size bytes (EC coordinates are fixed-width).
func leftPad(b []byte, size int) []byte {
if len(b) >= size {
return b
}
out := make([]byte, size)
copy(out[size-len(b):], b)
return out
}
+161
View File
@@ -0,0 +1,161 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
"context"
"errors"
"time"
"github.com/hanzoai/orm"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/pkg/schema"
)
// The login-challenge lifecycle: the ONE primitive for a sign-in that has proven
// one thing and must prove another before a token exists. The MFA gate mints one
// when a password verifies but the second factor is outstanding; the matching
// finish takes it.
//
// v1 keeps this in a beego cookie session; v2 has no key/value session store, so
// the state is a server-side row (schema.LoginChallenge) and the client holds only
// its opaque id. It is a SIBLING of Token, never a Token with borrowed fields:
// /token resolves a grant by Code, so a challenge filed there would sit on the
// redemption path wearing a fictional Application.
// challengeTTL bounds a half-finished ceremony. Five minutes is the authorization
// code's own bound — long enough to read a code off a phone, short enough that an
// abandoned challenge is not a standing key to an account whose password is
// already known.
const challengeTTL = 5 * time.Minute
// The challenge kinds. Each names the proof still outstanding, and a taker demands
// its own kind: a challenge minted for one purpose must never satisfy another.
const (
KindMfa = "mfa"
KindFederation = "federation"
)
// ErrChallenge is the ONE opaque failure for every way a challenge can be refused
// — unknown, expired, spent, or the wrong kind. They collapse to one answer so a
// prober cannot tell a spent challenge from a forged one.
var ErrChallenge = errors.New("the multi-factor session has expired")
// challengeOwner files every challenge under the reserved admin org. A challenge
// is the authorization server's own state, not a tenant record: it is never
// listed, never served by an entity route, and its subject is the only tenancy
// that matters (and rides inside it, verified).
const challengeOwner = "admin"
// MintChallenge persists a fresh challenge for subject ("owner/name") and returns
// its opaque id. payload is the kind's own state — the just-used verification type
// for the MFA gate. now is injected for testability.
func MintChallenge(ctx context.Context, db orm.DB, kind, subject, payload string, now time.Time) (string, error) {
id, err := newOpaqueToken()
if err != nil {
return "", err
}
c := orm.New[schema.LoginChallenge](db)
c.Owner = challengeOwner
c.Name = id
c.CreatedTime = now.UTC().Format(time.RFC3339)
c.Kind = kind
c.Subject = subject
c.Payload = payload
c.ExpireIn = now.Add(challengeTTL).Unix()
c.SetId(challengeOwner + "/" + id)
if err := c.CreateCtx(ctx); err != nil {
return "", err
}
return id, nil
}
// TakeChallenge resolves and atomically SPENDS a challenge of the given kind,
// returning it. The find-and-burn runs inside a GetForUpdate transaction — the row
// lock is held from the read through the Used=true write — so two concurrent
// finishMfa calls on ONE captured passcode cannot both observe Used=false and both
// win: the loser blocks until the winner commits, then reads it spent. A plain
// Get→set→Update would leave a window in which both pass the used check (the F-D1
// lost-update/TOCTOU class); this is the same guard as the wallet challenge burn
// (internal/wallet/store.go). The caller gets the subject from the returned row and
// nowhere else — never from a request parameter, so a body naming another user
// cannot redirect the ceremony.
//
// Every refusal — unknown, expired, spent, wrong kind, or a transient store fault —
// collapses to ErrChallenge, so a prober cannot tell them apart.
func TakeChallenge(ctx context.Context, db orm.DB, id, kind string, now time.Time) (*schema.LoginChallenge, error) {
if id == "" {
return nil, ErrChallenge
}
var out *schema.LoginChallenge
err := db.RunInTransaction(ctx, func(tx orm.DB) error {
c, err := orm.GetForUpdate[schema.LoginChallenge](tx, challengeOwner+"/"+id)
if err != nil {
return ErrChallenge // unknown id (ErrNotFound) or a transient read fault
}
if c.Used || c.Kind != kind || now.Unix() > c.ExpireIn {
return ErrChallenge // spent, wrong kind, or expired
}
c.Used = true
if err := c.UpdateCtx(ctx); err != nil {
return ErrChallenge
}
out = c
return nil
})
if err != nil {
return nil, ErrChallenge
}
return out, nil
}
// challengeCookie carries the challenge id to the client exactly the way v1 carries
// its beego session: a host-only, HttpOnly cookie the browser returns on the
// finishing request. Script cannot read it; it is bound to the ceremony's own
// short life.
const challengeCookie = "hanzo_challenge"
// SetChallenge writes the challenge id for the finishing request to return.
// HttpOnly keeps script out of it; SameSite=Lax lets the portal's own POST carry
// it while refusing a cross-site one; the MaxAge matches the row's TTL so the
// browser forgets it exactly when the server does.
func SetChallenge(c *zip.Ctx, id string) {
c.Fiber().Cookie(&fiber.Cookie{
Name: challengeCookie,
Value: id,
Path: "/",
MaxAge: int(challengeTTL / time.Second),
HTTPOnly: true,
Secure: true,
SameSite: fiber.CookieSameSiteLaxMode,
})
}
// ClearChallenge expires the cookie once its challenge is spent, so a finished
// ceremony leaves nothing behind to replay.
func ClearChallenge(c *zip.Ctx) {
c.Fiber().Cookie(&fiber.Cookie{
Name: challengeCookie,
Value: "",
Path: "/",
MaxAge: -1,
HTTPOnly: true,
Secure: true,
SameSite: fiber.CookieSameSiteLaxMode,
})
}
// ReadChallenge returns the challenge id a finishing request presents: the body
// field when one is given (an SDK holding no cookie jar), else the cookie the
// browser returned. ONE function, ONE precedence — the id is the bearer of the
// ceremony either way, and the row it names is single-use, short-lived, and
// carries its own subject, so neither source can widen what it proves.
func ReadChallenge(c *zip.Ctx, fromBody string) string {
if fromBody != "" {
return fromBody
}
return c.Fiber().Cookies(challengeCookie)
}
+92
View File
@@ -0,0 +1,92 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
"sync"
"sync/atomic"
"testing"
"time"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
)
// ITEM 4: TakeChallenge burns a login challenge exactly once. A captured MFA passcode
// rides on ONE challenge id; if two concurrent finishMfa calls both observe Used=false
// and both mark it used, the passcode is double-spent (the F-D1 lost-update/TOCTOU
// class). The burn runs inside a GetForUpdate transaction, so exactly one caller wins.
func TestTakeChallenge_concurrentBurn_exactlyOneWinner(t *testing.T) {
db := openTestDB(t)
ctx := tctx()
now := time.Now()
id, err := MintChallenge(ctx, db, KindMfa, "hanzo/alice", "", now)
if err != nil {
t.Fatalf("mint challenge: %v", err)
}
const N = 16
var wins int64
var wg sync.WaitGroup
start := make(chan struct{})
for i := 0; i < N; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
if ch, err := TakeChallenge(ctx, db, id, KindMfa, now); err == nil && ch != nil {
atomic.AddInt64(&wins, 1)
}
}()
}
close(start)
wg.Wait()
if wins != 1 {
t.Fatalf("concurrent TakeChallenge on one id produced %d winners, want exactly 1 — a captured passcode can be double-spent (ITEM 4)", wins)
}
}
// I1: BurnFederationState consumes an in-flight federation transaction exactly once —
// the OAuth-callback single-use guard, the exact twin of TakeChallenge. Two concurrent
// callbacks on one `state` must not both flip Used=false→true (double-completion of the
// same federated login). The burn runs inside a GetForUpdate transaction, so exactly one
// wins.
func TestBurnFederationState_concurrentBurn_exactlyOneWinner(t *testing.T) {
db := openTestDB(t)
ctx := tctx()
now := time.Now()
const state = "fedstate-0123456789abcdef0123456789abcdef" // opaque state token = row Name
if err := store.PersistFederationState(ctx, db, &schema.FederationState{
Owner: "admin",
Name: state,
ExpireIn: now.Add(5 * time.Minute).Unix(),
}); err != nil {
t.Fatalf("persist federation state: %v", err)
}
const N = 16
var wins int64
var wg sync.WaitGroup
start := make(chan struct{})
for i := 0; i < N; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
if st, err := store.BurnFederationState(ctx, db, state, now); err == nil && st != nil {
atomic.AddInt64(&wins, 1)
}
}()
}
close(start)
wg.Wait()
if wins != 1 {
t.Fatalf("concurrent BurnFederationState on one state produced %d winners, want exactly 1 — a federation callback can be double-completed (I1)", wins)
}
}
+121
View File
@@ -0,0 +1,121 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"time"
"github.com/hanzoai/iam/pkg/schema"
)
// Authorization-code lifecycle over the Token entity. A code is a short-lived,
// single-use bearer of the right to mint tokens for one (app, user); PKCE binds
// it to the client instance that started the flow, and the single-use + expiry
// guards close replay.
// codeTTL bounds how long an authorization code is redeemable (RFC 6749 §4.1.2
// recommends ≤ 10 min; we use 5).
const codeTTL = 5 * time.Minute
var (
// ErrCodeUnknown — no token row carries this code.
ErrCodeUnknown = errors.New("oauth: authorization code not found")
// ErrCodeUsed — the code was already redeemed (replay). Per RFC 6749 §4.1.2
// a reused code SHOULD also revoke previously-issued tokens; the caller does
// that when it detects this error.
ErrCodeUsed = errors.New("oauth: authorization code already used")
// ErrCodeExpired — the code is past its TTL.
ErrCodeExpired = errors.New("oauth: authorization code expired")
// ErrClientMismatch — the redeeming client_id is not the one the code was
// minted for.
ErrClientMismatch = errors.New("oauth: client_id does not match the authorization code")
)
// newOpaqueToken returns a 256-bit URL-safe random token (code / access token).
func newOpaqueToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// MintCode builds (does not persist) a Token row representing a fresh
// authorization code bound to (app, user), the PKCE challenge, scope, and
// resource. The caller persists it via the store. now is injected for
// testability.
func MintCode(app *schema.Application, userID, scope, challenge, method, resource string, now time.Time) (*schema.Token, error) {
code, err := newOpaqueToken()
if err != nil {
return nil, err
}
// If a challenge is present, pin the method to S256 — never store "plain".
if challenge != "" && method != "S256" {
return nil, ErrPKCEPlainRejected
}
// The token row is keyed by the application's OWNER (its registry owner, e.g.
// "admin"), so (Owner, Application) is the application's natural key and the
// token endpoint resolves the app back unambiguously. Organization records the
// tenant the grant belongs to.
return &schema.Token{
Owner: app.Owner,
Organization: app.Organization,
Application: app.Name,
User: userID,
Code: code,
Scope: scope,
TokenType: "Bearer",
CodeChallenge: challenge,
CodeChallengeMethod: method,
CodeIsUsed: false,
CodeExpireIn: now.Add(codeTTL).Unix(),
Resource: resource,
}, nil
}
// RedeemCode validates an authorization_code exchange against the stored token
// row and returns nil iff the code may be used. It is the single guard the
// token endpoint calls; on success the caller MUST immediately mark the row used
// (MarkUsed) inside the same transaction so a concurrent replay loses.
//
// Checks, in order (each fail-closed):
// 1. row exists (caller passes nil → ErrCodeUnknown)
// 2. not already used (replay)
// 3. not expired
// 4. client_id matches (constant-time)
// 5. PKCE: verifier derives the stored challenge (S256; plain refused; a public
// client that stored a challenge must present a verifier)
func RedeemCode(tok *schema.Token, clientAppName, verifier string, now time.Time) error {
if tok == nil {
return ErrCodeUnknown
}
if tok.CodeIsUsed {
return ErrCodeUsed
}
if tok.CodeExpireIn != 0 && now.Unix() > tok.CodeExpireIn {
return ErrCodeExpired
}
if subtle.ConstantTimeCompare([]byte(tok.Application), []byte(clientAppName)) != 1 {
return ErrClientMismatch
}
return VerifyPKCE(verifier, tok.CodeChallenge, tok.CodeChallengeMethod)
}
// IssueAccessToken fills the row with a freshly-minted access token + expiry and
// marks the code used — the atomic success step after RedeemCode. now injected
// for tests. ttlSeconds is the access-token lifetime.
func IssueAccessToken(tok *schema.Token, ttlSeconds int, now time.Time) error {
at, err := newOpaqueToken()
if err != nil {
return err
}
tok.AccessToken = at
tok.ExpiresIn = ttlSeconds
tok.CodeIsUsed = true // one-shot: any subsequent RedeemCode → ErrCodeUsed
return nil
}
+134
View File
@@ -0,0 +1,134 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
"errors"
"testing"
"time"
"github.com/hanzoai/iam/pkg/pkce"
"github.com/hanzoai/iam/pkg/schema"
)
func testApp() *schema.Application {
a := &schema.Application{Organization: "hanzo"}
a.Name = "hanzo-console"
a.ClientId = "hanzo-console"
return a
}
func TestMintCode_BindsPKCEAndExpiry(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
verifier := "verifier-abc-000000000000000000000000000000000"
ch := pkce.Challenge(verifier)
tok, err := MintCode(testApp(), "hanzo/alice", "openid profile", ch, "S256", "", now)
if err != nil {
t.Fatal(err)
}
if tok.Code == "" || len(tok.Code) < 40 {
t.Fatalf("code not a 256-bit token: %q", tok.Code)
}
if tok.CodeIsUsed {
t.Fatal("fresh code must not be used")
}
if tok.CodeExpireIn != now.Add(codeTTL).Unix() {
t.Fatalf("expiry = %d, want %d", tok.CodeExpireIn, now.Add(codeTTL).Unix())
}
if tok.Application != "hanzo-console" || tok.User != "hanzo/alice" {
t.Fatalf("binding wrong: app=%q user=%q", tok.Application, tok.User)
}
}
func TestMintCode_RefusesPlain(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
if _, err := MintCode(testApp(), "u", "", "some-challenge", "plain", "", now); !errors.Is(err, ErrPKCEPlainRejected) {
t.Fatalf("mint with plain: got %v, want ErrPKCEPlainRejected", err)
}
}
func TestRedeemCode_HappyPath(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
verifier := "verifier-happy-0000000000000000000000000000000"
tok, _ := MintCode(testApp(), "hanzo/alice", "openid", pkce.Challenge(verifier), "S256", "", now)
if err := RedeemCode(tok, "hanzo-console", verifier, now.Add(30*time.Second)); err != nil {
t.Fatalf("valid redemption rejected: %v", err)
}
}
func TestRedeemCode_ReplayRejected(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
verifier := "verifier-replay-000000000000000000000000000000"
tok, _ := MintCode(testApp(), "u", "openid", pkce.Challenge(verifier), "S256", "", now)
// First redemption + issue marks it used.
if err := RedeemCode(tok, "hanzo-console", verifier, now); err != nil {
t.Fatal(err)
}
if err := IssueAccessToken(tok, 3600, now); err != nil {
t.Fatal(err)
}
// Replay must now fail.
if err := RedeemCode(tok, "hanzo-console", verifier, now); !errors.Is(err, ErrCodeUsed) {
t.Fatalf("replay: got %v, want ErrCodeUsed", err)
}
}
func TestRedeemCode_ExpiredRejected(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
verifier := "verifier-exp-00000000000000000000000000000000000"
tok, _ := MintCode(testApp(), "u", "openid", pkce.Challenge(verifier), "S256", "", now)
past := now.Add(codeTTL + time.Second)
if err := RedeemCode(tok, "hanzo-console", verifier, past); !errors.Is(err, ErrCodeExpired) {
t.Fatalf("expired code: got %v, want ErrCodeExpired", err)
}
}
func TestRedeemCode_ClientMismatchRejected(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
verifier := "verifier-cli-00000000000000000000000000000000000"
tok, _ := MintCode(testApp(), "u", "openid", pkce.Challenge(verifier), "S256", "", now)
if err := RedeemCode(tok, "some-other-app", verifier, now); !errors.Is(err, ErrClientMismatch) {
t.Fatalf("client mismatch: got %v, want ErrClientMismatch", err)
}
}
func TestRedeemCode_WrongVerifierRejected(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
tok, _ := MintCode(testApp(), "u", "openid", pkce.Challenge("the-right-verifier-0000000000000000000000000"), "S256", "", now)
if err := RedeemCode(tok, "hanzo-console", "the-WRONG-verifier-0000000000000000000000000", now); !errors.Is(err, ErrPKCEMismatch) {
t.Fatalf("wrong verifier: got %v, want ErrPKCEMismatch", err)
}
}
func TestRedeemCode_PublicClientMustPresentVerifier(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
// Code minted WITH a challenge (public client) but token request omits the verifier.
tok, _ := MintCode(testApp(), "u", "openid", pkce.Challenge("v-000000000000000000000000000000000000000000000"), "S256", "", now)
if err := RedeemCode(tok, "hanzo-console", "", now); !errors.Is(err, ErrPKCEMissing) {
t.Fatalf("missing verifier: got %v, want ErrPKCEMissing", err)
}
}
func TestRedeemCode_UnknownCode(t *testing.T) {
if err := RedeemCode(nil, "hanzo-console", "v", time.Now()); !errors.Is(err, ErrCodeUnknown) {
t.Fatalf("nil token: got %v, want ErrCodeUnknown", err)
}
}
func TestIssueAccessToken_MintsAndMarksUsed(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
tok, _ := MintCode(testApp(), "u", "openid", "", "", "", now)
if err := IssueAccessToken(tok, 3600, now); err != nil {
t.Fatal(err)
}
if tok.AccessToken == "" || len(tok.AccessToken) < 40 {
t.Fatalf("access token not minted: %q", tok.AccessToken)
}
if !tok.CodeIsUsed {
t.Fatal("code must be marked used after issue")
}
if tok.ExpiresIn != 3600 {
t.Fatalf("expiresIn = %d, want 3600", tok.ExpiresIn)
}
}
+198
View File
@@ -0,0 +1,198 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
)
// GET/PUT /v1/iam/consent — the account-canonical data-sharing consent: the ONE
// place a user's choice is recorded. The hanzo.id signup asks it, the browser
// extension reads/writes it, and hanzo.ai edits it — all through here. It rides
// the SAME preferences blob as update-preferences, so there is one store and one
// merge (no parallel table to drift).
//
// The value type, the tri-state, and the predicate live in schema.Consent — this
// file is only the HTTP surface over them. Nothing here decides what an answer
// MEANS; it records what the user said and reads it back.
//
// SELF-SCOPED: the target is ALWAYS the caller (callerOf), never a body field. A
// caller can only ever write its own consent — not an org admin's view of a
// member's, not a platform operator's. That is deliberate: consent someone else
// can set on your behalf is not consent, and a write path that accepts a subject
// from the body is the privilege-escalation shape this endpoint refuses to have.
//
// AUDITED: a change to the record writes an AuditLog row carrying the whole
// consent before and after, ON THE SAME TRANSACTION, so a grant AND a later
// revocation are both attributable and neither can commit without its evidence.
// Overwriting a field in a JSON blob leaves no history; the audit row is what
// makes "who answered what, and when" answerable. The row is platform-written
// (schema.PlatformWritten), so the generic audit CRUD cannot forge or remove one.
const PathConsent = "/v1/iam/consent"
// consentBody is the wire shape, and every field is a POINTER so that "absent"
// and "set to the zero value" are different requests. A consent screen that saves
// only the switch it changed must not answer the other question by omission:
// with a plain bool, a body of {"training":"granted"} also says insights=false,
// silently revoking a choice the person never touched. Absent means UNTOUCHED.
//
// Training is a string rather than an Answer so an unrecognized token can be
// REFUSED with a clear message instead of coerced — a client that invents a
// spelling learns it was rejected, rather than having its user silently recorded
// as unanswered.
type consentBody struct {
Insights *bool `json:"insights"`
Training *string `json:"training"`
}
// getConsentHandler returns the calling person's own privacy and communication
// choices. Somebody who has never set them gets the defaults rather than
// nothing, so a consent screen always has something to show — insights on, and
// training UNANSWERED, which is the state that means the screen still has to ask.
func getConsentHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
owner, name, ok := callerOf(ctx, c, db)
if !ok {
return httpx.Err(c, "please sign in first")
}
user, err := store.GetUserByName(ctx, db, owner, name)
if err != nil || user == nil {
return httpx.Err(c, "server_error")
}
return httpx.Ok(c, user.Consent())
}
}
// putConsentHandler records the calling person's privacy and communication
// choices. Only their own — there is no way to set consent for somebody else.
//
// Send only the answers you are changing. A question you leave out keeps the
// answer it already had, so a screen that saves one switch never revokes the
// other, and two screens saving at once do not undo each other.
//
// An answer this version does not recognize is refused here rather than stored,
// so nothing is ever persisted for a later reader to have to interpret.
func putConsentHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
owner, name, ok := callerOf(ctx, c, db)
if !ok {
return httpx.Err(c, "please sign in first")
}
var in consentBody
if err := json.Unmarshal(c.Fiber().Body(), &in); err != nil {
return httpx.Err(c, "consent must be a JSON object")
}
// Validate at the boundary: an answer this version does not know is
// refused HERE rather than persisted for a later reader to interpret.
// A field that is ABSENT is not an answer at all and is left alone; only
// one that is present is checked, so silence can never fail validation
// and can never change the record.
var answer schema.Answer
if in.Training != nil {
answer = schema.Answer(*in.Training)
if !answer.Valid() {
return httpx.Err(c, "training must be one of: \"\", granted, refused")
}
}
// Merge FIELD-WISE onto the stored record, under the row lock, so the
// answers this request does not carry keep their committed values rather
// than the zero values a decoder invented for them.
var prior, next schema.Consent
if _, err := updateUser(ctx, db, owner, name, func(tx orm.DB, u *schema.User) error {
prior = u.Consent()
next = prior
if in.Insights != nil {
next.Insights = *in.Insights
}
if in.Training != nil {
next.Training = answer
}
if err := u.SetConsent(&next); err != nil {
return err
}
u.UpdatedTime = provisionNow()
// The evidence commits WITH the answer. Article 7(1) asks the
// controller to demonstrate that the person consented, and a grant
// whose audit row was written separately can be missing exactly when
// it is needed — a failed second write, a crash between the two, a
// row deleted later. Written on the same transaction, the record and
// its evidence are one event: both, or neither.
return auditConsent(ctx, tx, c, owner, name, prior, next)
}); err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, next)
}
}
// consentChange is the audited payload — the WHOLE record before and after, not
// just the training answer. Insights is a consent too: a withdrawal of it has to
// be as demonstrable as a grant of the other, and an audit trail that records one
// switch cannot answer "what did they consent to, and when" about the other.
type consentChange struct {
From schema.Consent `json:"from"`
To schema.Consent `json:"to"`
}
// auditConsent records a change to the consent record on the SAME transaction as
// the record itself, so the answer and the evidence for it commit together.
//
// It returns its error, and that error aborts the write. A consent this system
// cannot evidence is one it should not claim to hold: GDPR Article 7(1) puts the
// burden of demonstrating consent on the controller, so a grant we cannot show
// was given is worth less than no grant at all. Failing the request tells the
// person their answer did not land, which is true and recoverable; recording it
// silently unevidenced is neither.
//
// A request that changes NOTHING writes no row — re-saving an unchanged screen is
// not an event, and a trail padded with them is harder to read.
func auditConsent(ctx context.Context, tx orm.DB, c *zip.Ctx, owner, name string, from, to schema.Consent) error {
if from == to {
return nil
}
id, err := newOpaqueToken()
if err != nil {
return fmt.Errorf("audit consent: %w", err)
}
object, err := json.Marshal(consentChange{From: from, To: to})
if err != nil {
return fmt.Errorf("audit consent: %w", err)
}
log := orm.New[schema.AuditLog](tx)
log.Owner = owner
log.Name = id
log.CreatedTime = nowFunc().UTC().Format(time.RFC3339)
log.Organization = owner
log.User = owner + "/" + name
log.Action = schema.ActionConsentTraining
log.Object = string(object)
log.Method = "PUT"
log.RequestUri = c.Path()
// ClientIp is deliberately EMPTY. Behind hanzoai/ingress the peer address is
// the ingress pod, so the field recorded a value that identified nothing while
// still being personal data we would owe a retention answer for. A field that
// cannot support the conclusion it invites is worse than an absent one; the
// authenticated subject is the attribution that matters here, and that is
// already in User.
log.StatusCode = 200
log.IsTriggered = true
log.SetId(owner + "/" + id)
if err := log.CreateCtx(ctx); err != nil {
return fmt.Errorf("audit consent: %w", err)
}
return nil
}
+229
View File
@@ -0,0 +1,229 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
)
// Consent has ONE writer. These tests are the two ways that could stop being
// true: another endpoint reaching the same record, and this endpoint answering a
// question the request never asked.
// The preferences surface shallow-merges whatever top-level keys a client sends,
// unvalidated and unaudited. The consent record lives in that same blob — so
// without this refusal, `POST /v1/iam/preferences {"consent":{...}}` is a second
// writer of the one record that most needs a single one, and it bypasses the
// answer validation and the audit row that make the real one accountable.
func TestPreferencesRefusesTheConsentKey(t *testing.T) {
for _, patch := range []string{
`{"consent":{"training":"granted"}}`,
`{"theme":"dark","consent":{"training":"granted"}}`,
`{"consent":null}`,
`{"consent":"granted"}`,
} {
t.Run(patch, func(t *testing.T) {
_, _, err := mergePreferences(`{"consent":{"insights":true,"training":"refused"}}`, []byte(patch))
if err == nil {
t.Fatalf("the preferences surface accepted a consent patch: %s", patch)
}
if !strings.Contains(err.Error(), PathConsent) {
t.Fatalf("the refusal must say where to answer instead, got: %v", err)
}
})
}
// And it still merges everything that IS a preference.
merged, m, err := mergePreferences(`{"consent":{"training":"granted"},"theme":"light"}`, []byte(`{"theme":"dark"}`))
if err != nil {
t.Fatalf("an ordinary preference patch was refused: %v", err)
}
if got := string(m["theme"]); got != `"dark"` {
t.Fatalf("theme = %s, want \"dark\"", got)
}
// The stored consent is untouched by a write it is not part of.
if !schema.ConsentOf(merged).MayTrain() {
t.Fatalf("a preferences write altered the stored consent: %s", merged)
}
}
// putConsent takes a raw JSON body so a test can express the difference between
// "absent" and "present and false" — which is the whole property under test.
func putConsent(t *testing.T, app *zip.App, cookie, body string) (int, map[string]any) {
t.Helper()
req, err := http.NewRequest("PUT", PathConsent, strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Cookie", cookie)
resp, raw := do(t, app, req)
return resp.StatusCode, decode(t, raw)
}
func consentOnRow(t *testing.T, db orm.DB) schema.Consent {
t.Helper()
u, err := store.GetUserByName(context.Background(), db, "hanzo", "alice")
if err != nil || u == nil {
t.Fatalf("read back alice: %v", err)
}
return u.Consent()
}
// A consent screen saves the switch the person just moved. If an absent field
// meant "false", saving one switch would silently revoke the other — the person
// would answer one question and have a second answer changed on their behalf,
// which is exactly what consent may not be.
func TestConsentPutLeavesAnUnaskedQuestionAlone(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedRichUser(t, db)
cookie := sessionCookieFor(t, app)
// Establish a full record: insights on, training granted.
if status, env := putConsent(t, app, cookie, `{"insights":true,"training":"granted"}`); status != 200 || env["status"] != "ok" {
t.Fatalf("initial save: status=%d env=%v", status, env)
}
if got := consentOnRow(t, db); !got.MayTrain() || !got.Insights {
t.Fatalf("initial save did not land: %+v", got)
}
t.Run("training-only save keeps insights", func(t *testing.T) {
if status, _ := putConsent(t, app, cookie, `{"training":"refused"}`); status != 200 {
t.Fatalf("status=%d", status)
}
got := consentOnRow(t, db)
if got.Training != schema.Refused {
t.Fatalf("Training = %q, want refused", got.Training)
}
if !got.Insights {
t.Fatal("a training-only save revoked the insights consent the person never touched")
}
})
t.Run("insights-only save keeps training", func(t *testing.T) {
if status, _ := putConsent(t, app, cookie, `{"insights":false}`); status != 200 {
t.Fatalf("status=%d", status)
}
got := consentOnRow(t, db)
if got.Insights {
t.Fatal("insights=false did not land")
}
if got.Training != schema.Refused {
t.Fatalf("an insights-only save changed the training answer to %q", got.Training)
}
})
t.Run("an explicit false is still an answer", func(t *testing.T) {
// The tri-state must not turn into "absent and false are the same": a
// person who deliberately switches insights off must be recorded off.
if status, _ := putConsent(t, app, cookie, `{"insights":true}`); status != 200 {
t.Fatalf("status=%d", status)
}
if !consentOnRow(t, db).Insights {
t.Fatal("insights=true did not land")
}
if status, _ := putConsent(t, app, cookie, `{"insights":false}`); status != 200 {
t.Fatalf("status=%d", status)
}
if consentOnRow(t, db).Insights {
t.Fatal("an explicit insights=false was read as absent and ignored")
}
})
t.Run("an empty body changes nothing", func(t *testing.T) {
before := consentOnRow(t, db)
if status, _ := putConsent(t, app, cookie, `{}`); status != 200 {
t.Fatalf("status=%d", status)
}
if after := consentOnRow(t, db); after != before {
t.Fatalf("an empty body rewrote the record: %+v -> %+v", before, after)
}
})
t.Run("an unknown answer is refused and stores nothing", func(t *testing.T) {
before := consentOnRow(t, db)
status, env := putConsent(t, app, cookie, `{"training":"yes"}`)
if status == 200 && env["status"] == "ok" {
t.Fatal("training=\"yes\" was accepted")
}
if after := consentOnRow(t, db); after != before {
t.Fatalf("a refused request still wrote: %+v -> %+v", before, after)
}
})
}
// The audit row is the evidence that the answer was given, so it must carry the
// WHOLE record — an insights withdrawal is as much a consent event as a training
// grant — and it must be attributable without recording an address that only
// identifies our own ingress.
func TestConsentChangeIsAudited(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedRichUser(t, db)
cookie := sessionCookieFor(t, app)
rows := func() []*schema.AuditLog {
t.Helper()
got, err := orm.TypedQuery[schema.AuditLog](db).Filter("owner", "hanzo").GetAll(context.Background())
if err != nil {
t.Fatalf("read audit rows: %v", err)
}
return got
}
if status, _ := putConsent(t, app, cookie, `{"insights":true,"training":"granted"}`); status != 200 {
t.Fatalf("status=%d", status)
}
after := rows()
if len(after) != 1 {
t.Fatalf("a consent grant wrote %d audit rows, want 1", len(after))
}
row := after[0]
if row.Action != schema.ActionConsentTraining {
t.Fatalf("Action = %q", row.Action)
}
if !schema.PlatformWritten(row.Action) {
t.Fatal("the consent action is not reserved, so the row can be forged or deleted through the audit CRUD")
}
if row.User != "hanzo/alice" {
t.Fatalf("User = %q, want the answering subject", row.User)
}
if row.ClientIp != "" {
t.Fatalf("ClientIp = %q — behind the ingress this identifies nothing and is personal data we then owe an answer for", row.ClientIp)
}
var change consentChange
if err := json.Unmarshal([]byte(row.Object), &change); err != nil {
t.Fatalf("audited object is not a consent change: %q", row.Object)
}
if change.To.Training != schema.Granted || change.From.Training != schema.Unanswered {
t.Fatalf("the transition was not recorded: %+v", change)
}
// An insights-only change is a consent event too.
if status, _ := putConsent(t, app, cookie, `{"insights":false}`); status != 200 {
t.Fatalf("status=%d", status)
}
if got := rows(); len(got) != 2 {
t.Fatalf("an insights withdrawal wrote %d rows in total, want 2 — only the training answer is being audited", len(got))
}
// Re-saving an unchanged screen is not an event.
if status, _ := putConsent(t, app, cookie, `{"insights":false}`); status != 200 {
t.Fatalf("status=%d", status)
}
if got := rows(); len(got) != 2 {
t.Fatalf("a no-op save wrote an audit row (%d rows)", len(got))
}
}
+126
View File
@@ -0,0 +1,126 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"errors"
"testing"
"github.com/hanzoai/iam/pkg/schema"
)
// fakeSender records what it was asked to deliver and fails on demand.
type fakeSender struct {
err error
sent []string
}
func (f *fakeSender) Send(_ context.Context, channel, dest, code string) error {
f.sent = append(f.sent, channel+":"+dest+":"+code)
return f.err
}
// bindSender installs s for the duration of one test and restores the previous
// binding after, so these tests can run in any order.
func bindSender(t *testing.T, s Sender) {
t.Helper()
prev := sender
sender = s
t.Cleanup(func() { sender = prev })
}
// A code sign-in is offered only when a code can actually reach a person.
//
// Two independent facts have to hold and they were conflated into one: the
// application switch says the ORG wants email/SMS codes, and DeliveryConfigured
// says the SERVER can send one. Only the first was consulted, so every app
// advertised `code: true` while the delivery seam was unbound — measured against
// production, where a send to probe@example.invalid, an address that cannot exist,
// answered {status:"ok"}.
func TestCodeSigninNeedsBothTheSwitchAndDelivery(t *testing.T) {
for _, tc := range []struct {
name string
enabled bool
bound bool
want bool
}{
{"wanted and deliverable", true, true, true},
{"wanted but nothing can send it", true, false, false},
{"deliverable but the org said no", false, true, false},
{"neither", false, false, false},
} {
t.Run(tc.name, func(t *testing.T) {
if tc.bound {
bindSender(t, &fakeSender{})
} else {
bindSender(t, nil)
}
if got := tc.enabled && DeliveryConfigured(); got != tc.want {
t.Errorf("code offered = %v, want %v (switch=%v bound=%v)",
got, tc.want, tc.enabled, tc.bound)
}
})
}
}
// DeliveryConfigured must answer from the BOUND SENDER, never from configuration.
//
// The first version of this gate keyed on IAM_NOTIFY_ADDR. Nothing else in the
// repo read that variable, so setting it would have restored the button and
// silenced the endpoint's refusal while still sending nothing — re-arming the
// exact {status:"ok"} lie the gate exists to remove. An address is a CLAIM that
// delivery exists; a sender IS delivery.
func TestDeliveryIsDecidedByTheSenderNotAnAddress(t *testing.T) {
bindSender(t, nil)
t.Setenv("IAM_NOTIFY_ADDR", "notify.hanzo.svc:8000")
if DeliveryConfigured() {
t.Error("an address alone reported delivery configured — nothing would have been sent")
}
bindSender(t, &fakeSender{})
t.Setenv("IAM_NOTIFY_ADDR", "")
if !DeliveryConfigured() {
t.Error("a bound sender must report delivery configured, address or not")
}
}
// The login descriptor is the screen's source of truth, so the switch must be
// masked THERE too — leaving it on would draw the button whatever authMethods says.
// The org's stored setting is not modified; only what the browser is told.
func TestLoginViewMasksUndeliverableCodeSignin(t *testing.T) {
app := &schema.Application{EnableCodeSignin: true, EnablePassword: true}
bindSender(t, nil)
if v := loginView(app); v.EnableCodeSignin {
t.Error("code sign-in advertised with no delivery configured")
}
if !app.EnableCodeSignin {
t.Error("the org's stored setting was mutated; only the VIEW may be masked")
}
if v := loginView(app); !v.EnablePassword {
t.Error("password sign-in must be unaffected")
}
bindSender(t, &fakeSender{})
if v := loginView(app); !v.EnableCodeSignin {
t.Error("code sign-in must return once a sender is bound — no second switch to flip")
}
}
// A sender that fails must be reported as a failure. Answering ok because the code
// was minted recreates the same lie one layer down: the caller asked for a send.
func TestSendFailureIsReportedNotSwallowed(t *testing.T) {
f := &fakeSender{err: errors.New("twilio: 21608 unverified number")}
bindSender(t, f)
if err := sender.Send(context.Background(), "email", "someone@example.com", "123456"); err == nil {
t.Fatal("a failing sender must surface its error to the endpoint")
}
if len(f.sent) != 1 {
t.Fatalf("sender was called %d times, want 1", len(f.sent))
}
if f.sent[0] != "email:someone@example.com:123456" {
t.Errorf("sender got %q — channel, destination and code must all reach it", f.sent[0])
}
}
+469
View File
@@ -0,0 +1,469 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
"context"
"crypto/rand"
"crypto/subtle"
"errors"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/sessions"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
)
// The RFC 8628 device authorization grant: how a machine with no browser and no
// keyboard signs in (`hanzo login` on a GPU box, over ssh, in CI). Three legs,
// each landing on an EXISTING seam rather than a parallel stack:
//
// 1. POST /v1/iam/oauth/device — the device asks for a device_code + a short
// user_code and shows the human a verification URI.
// 2. POST /v1/iam/login {type:"device"} — the human, on any other machine,
// proves who they are and approves the user_code (login.go).
// 3. POST /v1/iam/oauth/token grant_type=…:device_code — the device polls and
// mints through issueTokens, the same path every other grant mints through.
//
// A device authorization IS a pending authorization code, so it is a Token row
// (Code=device_code, UserCode=user_code, User empty until approved) — not a
// process-local map, which would die on restart and never work across replicas.
//
// Client authentication follows RFC 8628 §3.1 (request) and §3.4 (poll), which
// both defer to RFC 6749 §3.2.1: a CONFIDENTIAL client (one with a registered
// secret) authenticates at both legs exactly as it would at the token endpoint;
// a PUBLIC device client (no secret — the usual CLI) is bound by its client_id
// alone. The verification_uri page a human opens is public; the JSON legs here
// are not a browser surface.
// The device grant's vocabulary. deviceCodeTTL and devicePollInterval are each
// read by the device request, the poll, and Discovery, so the lifetime a client
// is told and the lifetime enforced can never drift.
const (
// deviceGrant is the RFC 8628 grant_type identifier.
deviceGrant = "urn:ietf:params:oauth:grant-type:device_code"
// deviceCodeTTL bounds a device_code/user_code pair: long enough for a human
// to open the link on a phone, sign in, and approve. It is deliberately NOT
// codeTTL (5 min) — an authorization code is redeemed by software in seconds,
// a device code waits on a person.
deviceCodeTTL = 15 * time.Minute
// devicePollInterval is the minimum seconds between token-endpoint polls
// (RFC 8628 §3.5 `interval`).
devicePollInterval = 5
)
// user_code generation. The alphabet is RFC 8628 §6.1 "unambiguous": no I, L, O,
// 0 or 1, because a human reads this off one screen and types it into another.
// Its 32 symbols make the 5-bit mask below a UNIFORM draw — a modulo over a
// non-power-of-two alphabet would bias the code and cost entropy — so 8
// characters carry a full 40 bits. The live portal normalizes a typed code to
// exactly this alphabet, uppercasing and stripping separators
// (id pkgs/auth/src/client.ts normalizeUserCode), so the minted code is the
// canonical form: uppercase, no dashes.
const (
userCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
userCodeLen = 8
userCodeTries = 5
)
// errUserCodeExhausted — every generated user_code collided with a live one.
// Astronomically unlikely (40 bits against the handful of pending codes); it
// fails closed rather than reusing a code.
var errUserCodeExhausted = errors.New("device: could not generate a free user_code")
// deviceResponse is the RFC 8628 §3.2 device authorization response. The field
// names are load-bearing: both CLIs decode exactly this shape and hard-fail on
// an empty device_code/user_code (cloud/cli/device.go, codex-rs
// login/src/oidc_device_auth.rs).
type deviceResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationUri string `json:"verification_uri"`
VerificationUriComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
// routeDevice registers POST /v1/iam/oauth/device on the PUBLIC group r
// (registered before the Guard, exactly like the token endpoint): the endpoint
// authenticates the CLIENT inline — a confidential client by its secret, a
// public device client by its client_id — so it needs no bearer and joins no
// allow-list, membership in this group is what makes it reachable.
//
// The sibling POST names the client a pending user_code belongs to. It is on the
// same public group and authenticates the same way every browser path here does:
// by the session cookie, resolved inline.
//
// POST for a read, deliberately, and for the reason RFC 7662 introspection beside
// it is POST: the argument is a SECRET. A user_code in a request line is copied
// into ingress and proxy access logs, which a POST body is not — and this flow's
// own approval page ships a scrubUrl() to keep the code out of the address bar,
// so putting it back into every request line would undo that on the server side.
func routeDevice(r zip.Router, db orm.DB) {
r.Post(PathDevice, deviceHandler(db))
r.Post(PathDeviceInfo, deviceInfoHandler(db))
}
// deviceInfo is what the approval page must show a human: WHICH application is
// asking to sign in. Both fields come off the pending device code's own
// application — never off the portal the browser happens to be on.
type deviceInfo struct {
ClientId string `json:"clientId"`
DisplayName string `json:"displayName"`
}
// deviceInfoHandler answers "what am I approving?" for a pending device code.
//
// The approval page exists to tell a human WHICH application they are authorizing;
// a page that names the wrong one defeats the control it implements. It used to
// render the portal's own app name — a constant, `hanzo-console` for every code —
// so a device code minted by `hanzo-cli` was approved under a screen naming a
// different application entirely. The client is a property of the CODE, so it is
// read from the code's row here and nowhere else.
//
// Requires a signed-in session, and answers with the same ONE opaque refusal
// approveDevice uses. That is deliberate: the user_code is only 40 bits and is the
// one secret in this flow, so an unauthenticated lookup — or one that
// distinguished unknown from expired from already-approved — would be an oracle
// for hunting live codes. Gated and opaque, it reveals strictly less than the
// approval the same caller could already attempt.
func deviceInfoHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
setTokenCacheHeaders(c)
ctx := c.Context()
// The identity is the browser's session, exactly as the approval itself
// resolves it. Not signed in is not a refusal to explain — it is the
// page's cue to sign the human in first, so it carries the stable code
// the SPA branches on.
owner, name, ok := sessions.Resolve(ctx, c.Fiber(), db)
if !ok {
return httpx.ErrCode(c, "please sign in first", CodeLoginRequired)
}
user, err := store.GetUserByName(ctx, db, owner, name)
if err != nil || user == nil || user.IsForbidden || user.IsDeleted {
return httpx.ErrCode(c, "please sign in first", CodeLoginRequired)
}
// JSON body from the approval page, form/query for anything else — the same
// bind-then-fall-back the login front door uses, so one endpoint serves both
// without a second spelling of the request.
var f struct {
UserCode string `json:"userCode"`
}
_ = c.Bind(&f)
userCode := f.UserCode
if userCode == "" {
userCode = param(c, "userCode")
}
const refuse = "the user code is invalid or expired"
row, err := store.GetTokenByUserCode(ctx, db, userCode)
if err != nil {
return httpx.Err(c, refuse)
}
if row == nil || !isDevice(row) || row.CodeIsUsed || row.User != "" ||
expired(row.CodeExpireIn, nowFunc()) {
return httpx.Err(c, refuse)
}
// The same tenant boundary approveDevice enforces: what you may LOOK AT is
// exactly what you may approve, so this leaks nothing the caller could not
// already have learned by approving.
if row.Organization == "" {
return httpx.Err(c, refuse)
}
if !store.IsSuperAdmin(user.Owner) && user.Owner != row.Organization {
return httpx.Err(c, "your organization may not approve this device sign-in")
}
app, err := resolveTokenApp(ctx, db, row)
if err != nil || app == nil {
return httpx.Err(c, refuse)
}
label := app.DisplayName
if label == "" {
label = app.Name
}
return httpx.Ok(c, deviceInfo{ClientId: app.ClientId, DisplayName: label})
}
}
// deviceHandler starts a sign-in on a device with no browser and no keyboard —
// a TV, a CLI, a headless box. It returns a short code to show the person and
// the address to send them to on a phone or laptop.
//
// Nothing is granted until a human approves it there; until then the code is
// just a pending request.
func deviceHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
setTokenCacheHeaders(c)
ctx := c.Context()
clientID, clientSecret := clientAuth(c)
app, err := store.GetApplicationByClientId(ctx, db, clientID)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
if app == nil {
return tokenError(c, 400, "invalid_client", "client_id is invalid")
}
// A confidential client (one with a registered secret) MUST authenticate
// (RFC 8628 §3.1 → RFC 6749 §3.2.1). A public device client has no secret
// and is identified by its client_id alone.
if app.ClientSecret != "" &&
subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
return tokenErrorClient(c, "client authentication failed")
}
if !appGrants(app, deviceGrant) {
return tokenError(c, 400, "unsupported_grant_type", "the application does not permit the device grant")
}
deviceCode, err := newOpaqueToken()
if err != nil {
return tokenError(c, 500, "server_error", "")
}
userCode, err := newUserCode(ctx, db)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
// One row IS the pending authorization: Code is the device_code the
// machine polls with, UserCode the code its human transcribes, and an
// empty User means nobody has approved yet.
row := &schema.Token{
Owner: app.Owner,
Application: app.Name,
Organization: app.Organization,
Code: deviceCode,
UserCode: userCode,
Scope: param(c, "scope"),
TokenType: "Bearer",
CodeExpireIn: nowFunc().Add(deviceCodeTTL).Unix(),
}
row.Name = "dc-" + deviceCode[:24]
if err := store.PersistToken(ctx, db, row); err != nil {
return tokenError(c, 500, "server_error", "")
}
// Both URIs point at the SPA approval page a human opens, never at this
// JSON API. The complete form is a PATH segment because that is the route
// the page is registered on (/login/oauth/device/:userCode).
verify := tokenIssuer(c) + PathDeviceVerify
return c.JSON(200, deviceResponse{
DeviceCode: deviceCode,
UserCode: userCode,
VerificationUri: verify,
VerificationUriComplete: verify + "/" + userCode,
ExpiresIn: int(deviceCodeTTL.Seconds()),
Interval: devicePollInterval,
})
}
}
// deviceCodeGrant is the device's poll (RFC 8628 §3.4), dispatched from the one
// token endpoint. It authenticates the client (confidential by secret, public by
// client_id) and answers `authorization_pending` until a human approves, then
// mints exactly once. The human who authenticated and approved at the
// verification URI IS the end-user authentication.
func deviceCodeGrant(c *zip.Ctx, db orm.DB) error {
ctx := c.Context()
now := nowFunc()
presented := param(c, "device_code")
if presented == "" {
return tokenError(c, 400, "invalid_request", "device_code is required")
}
row, err := store.GetTokenByCode(ctx, db, presented)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
// Unknown, not a device authorization, or already redeemed. isDevice is what
// stops an authorization code being redeemed HERE, where neither its PKCE
// challenge nor its redirect_uri is verified.
if row == nil || !isDevice(row) || row.CodeIsUsed {
return deviceDead(c)
}
// Expired — reap it on the way past, so a dead authorization does not linger.
if expired(row.CodeExpireIn, now) {
_ = store.DeleteToken(ctx, db, row)
return deviceDead(c)
}
app, err := resolveTokenApp(ctx, db, row)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
if app == nil {
return tokenError(c, 400, "invalid_grant", "the device code is invalid")
}
clientID, clientSecret := clientAuth(c)
if deviceClientMismatch(app, clientID) {
return tokenError(c, 400, "invalid_grant", "the device_code was not issued to this client")
}
// A confidential client authenticates on EVERY poll (RFC 8628 §3.4 → RFC 6749
// §3.2.1), checked before the pending/mint split so an unauthenticated
// confidential poll never even learns the grant's approval state. A public
// device client has no secret and is bound by its client_id alone (above).
if app.ClientSecret != "" &&
subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
return tokenErrorClient(c, "client authentication failed")
}
// Re-gated at redemption, not only at the request: an application whose device
// grant was withdrawn between the two must not still mint.
if !appGrants(app, deviceGrant) {
return tokenError(c, 400, "unsupported_grant_type", "the application does not permit the device grant")
}
// Not approved yet: leave the row exactly as it is — the device keeps polling.
if row.User == "" {
return tokenError(c, 400, "authorization_pending", "the device authorization is pending approval")
}
// One-shot: burn the approval BEFORE minting, so any later poll finds the row
// already redeemed rather than minting a second token off one approval. Like
// the authorization-code grant beside it this is a read-modify-write, not a
// compare-and-swap: two polls landing inside the same write window could still
// both mint. They mint the same user, app, scope and refresh family, so the
// duplicate is contained (revoking the family revokes both) — a real CAS is a
// property the Token row would have to carry for every grant, not just this one.
row.CodeIsUsed = true
if err := store.SaveToken(ctx, db, row); err != nil {
return tokenError(c, 500, "server_error", "")
}
resp, err := issueTokens(ctx, db, c, app, row, newFamilyID(row), now)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
if err := store.SaveToken(ctx, db, row); err != nil {
return tokenError(c, 500, "server_error", "")
}
return c.JSON(200, resp)
}
// approveDevice binds an authenticated human's identity onto a pending device
// authorization — the act that lets the device's next poll mint. The row's
// application and scope stay authoritative for that mint: the portal app the
// browser happens to be on is irrelevant to WHAT is being approved, so it is
// never read here. Called from the login handler once the credential check has
// already proven who the approver is.
func approveDevice(c *zip.Ctx, db orm.DB, user *schema.User, userCode string) error {
// ONE opaque refusal for unknown / not-a-device / expired / already-approved /
// already-redeemed. The user_code is only 40 bits — the one secret in this
// flow — so an answer that distinguished those cases would turn this page into
// an oracle for hunting live codes.
const refuse = "the user code is invalid or expired"
ctx := c.Context()
row, err := store.GetTokenByUserCode(ctx, db, userCode)
if err != nil {
return httpx.Err(c, refuse)
}
if row == nil || !isDevice(row) || row.CodeIsUsed || row.User != "" ||
expired(row.CodeExpireIn, nowFunc()) {
return httpx.Err(c, refuse)
}
// Tenant boundary: a user in org A must not approve a device sign-in bound to
// an app in org B (a confused deputy — brands seed same-named superusers). The
// org compared is the DEVICE row's, captured when the code was issued. A
// SuperAdmin — a member of the reserved admin org, the one predicate — crosses
// tenants deliberately: that is the identity an operator signs a CLI into any
// brand's app with. An unresolvable tenant fails closed.
if row.Organization == "" {
return httpx.Err(c, refuse)
}
if !store.IsSuperAdmin(user.Owner) && user.Owner != row.Organization {
return httpx.Err(c, "your organization may not approve this device sign-in")
}
row.User = user.Owner + "/" + user.Name
if err := store.SaveToken(ctx, db, row); err != nil {
return httpx.Err(c, refuse)
}
return httpx.Ok(c, row.User)
}
// deviceDead is the one answer for a device_code that cannot be redeemed —
// unknown, not a device authorization, already redeemed, or expired. To the
// client those are the same fact (this code is dead, start over), so they get
// the same words: sharing one answer makes that structural rather than a
// coincidence of copied strings.
func deviceDead(c *zip.Ctx) error {
return tokenError(c, 400, "expired_token", "the device code is expired or already redeemed")
}
// isDevice reports whether a code row is an RFC 8628 device authorization rather
// than an authorization code. Both kinds live in Token.Code, so every grant
// checks the kind before redeeming: an authorization code must never be redeemed
// at the device grant, which verifies neither PKCE nor redirect_uri, and a
// device code must never be redeemed at the authorization-code grant, which
// would mint on a row no human has approved. The user_code IS the
// discriminator — only a device authorization has one.
func isDevice(tok *schema.Token) bool { return tok != nil && tok.UserCode != "" }
// deviceClientMismatch reports whether clientID is NOT the client the device
// authorization was issued to (RFC 8628 §3.4). Without this an approval for app
// A is redeemable as app B: a confused deputy that hands the caller a token for
// the wrong audience. Pure, so the binding is unit-testable.
func deviceClientMismatch(app *schema.Application, clientID string) bool {
return app == nil ||
subtle.ConstantTimeCompare([]byte(clientID), []byte(app.ClientId)) != 1
}
// appGrants reports whether app permits grant — the per-application grant gate
// (v1 IsGrantTypeValid, object/token_oauth.go:605). A grant must be DECLARED on
// the application to be usable, so an app that never enabled the device grant can
// never mint a device token. Fail-closed by construction: every live application
// declares its grant set, so an app with none permits none.
func appGrants(app *schema.Application, grant string) bool {
if app == nil {
return false
}
for _, g := range app.GrantTypes {
if g == grant {
return true
}
}
return false
}
// expired reports whether a unix deadline has passed. A zero deadline never
// expires (the v1 convention for "unset").
func expired(deadline int64, now time.Time) bool {
return deadline != 0 && now.Unix() > deadline
}
// newUserCode mints a user_code that no live row already carries. Each attempt
// REGENERATES the candidate — a loop that re-tests one fixed code could never
// clear a collision.
func newUserCode(ctx context.Context, db orm.DB) (string, error) {
for range userCodeTries {
code, err := randomUserCode()
if err != nil {
return "", err
}
row, err := store.GetTokenByUserCode(ctx, db, code)
if err != nil {
return "", err
}
if row == nil {
return code, nil
}
}
return "", errUserCodeExhausted
}
// randomUserCode draws userCodeLen symbols uniformly from userCodeAlphabet.
func randomUserCode() (string, error) {
buf := make([]byte, userCodeLen)
if _, err := rand.Read(buf); err != nil {
return "", err
}
for i := range buf {
buf[i] = userCodeAlphabet[buf[i]&0x1f]
}
return string(buf), nil
}
+183
View File
@@ -0,0 +1,183 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
"net/url"
"testing"
"github.com/zap-proto/zip"
)
// The approval page exists to tell a human WHICH application they are authorizing.
// It used to render the PORTAL's own app name — a per-brand constant — so a device
// code minted by `hanzo-cli` was approved on a screen naming a different
// application entirely. A security control that displays false information is
// worse than no control, because it manufactures the confidence it should be
// earning.
//
// These tests pin the property that fixes it: the name comes off the CODE.
// deviceInfoGet drives POST /v1/iam/oauth/device/info with an optional session.
// The code rides the BODY, never a request line — it is the one secret here.
func deviceInfoGet(t *testing.T, app *zip.App, userCode, cookie string) map[string]any {
t.Helper()
req := jsonReq("POST", PathDeviceInfo, map[string]string{"userCode": userCode})
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
_, body := do(t, app, req)
return decode(t, body)
}
// mintDeviceCode starts a device authorization and returns its user_code.
func mintDeviceCode(t *testing.T, app *zip.App, clientID string) string {
t.Helper()
resp, out := requestDevice(t, app, clientID, "openid")
if resp.StatusCode != 200 {
t.Fatalf("device request status=%d body=%v", resp.StatusCode, out)
}
code, _ := out["user_code"].(string)
if code == "" {
t.Fatalf("no user_code minted: %v", out)
}
return code
}
// The whole defect, in one assertion: two applications exist, the code is minted
// by ONE of them, and the page must be told about that one — never the portal the
// browser happens to be sitting on.
func TestDeviceInfo_NamesTheCodesClient(t *testing.T) {
app, db := newServer(t)
// The portal the browser is on. If the answer were read from here — as the
// page used to do — this is the name that would come back.
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "s3cret", redirectURIs: []string{testRedirect}})
// The client that actually asks to sign in on the device.
seedDeviceApp(t, db, "hanzo-cli")
userCode := mintDeviceCode(t, app, "hanzo-cli")
env := deviceInfoGet(t, app, userCode, signIn(t, app, "hanzo-console"))
if env["status"] != "ok" {
t.Fatalf("device info failed: %v", env)
}
data, _ := env["data"].(map[string]any)
if data["clientId"] != "hanzo-cli" {
t.Fatalf("device info named %q — it must name the client that minted the code, not the portal", data["clientId"])
}
if data["clientId"] == "hanzo-console" {
t.Fatal("device info returned the PORTAL's client — the exact defect this endpoint exists to fix")
}
if s, _ := data["displayName"].(string); s == "" {
t.Fatal("device info must carry a human-readable name to display")
}
}
// The user_code is 40 bits and is the one secret in this flow. An unauthenticated
// lookup would be an oracle for hunting live codes, so the endpoint requires a
// session — and says so in a way the page can route on, rather than demanding
// credentials the page does not collect.
func TestDeviceInfo_RequiresSession(t *testing.T) {
app, db := newServer(t)
seedDeviceApp(t, db, "hanzo-cli")
userCode := mintDeviceCode(t, app, "hanzo-cli")
env := deviceInfoGet(t, app, userCode, "")
if env["status"] != "error" {
t.Fatalf("an anonymous lookup must be refused: %v", env)
}
if env["code"] != CodeLoginRequired {
t.Fatalf("code = %v, want %q so the page can show a sign-in form", env["code"], CodeLoginRequired)
}
if data, ok := env["data"].(map[string]any); ok && data["clientId"] != nil {
t.Fatal("an anonymous refusal leaked the client")
}
}
// ONE opaque refusal for unknown / expired / already-approved. An answer that
// distinguished them would turn the page into a code-hunting oracle.
func TestDeviceInfo_OpaqueRefusal(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedDeviceApp(t, db, "hanzo-cli")
cookie := signIn(t, app, "hanzo-console")
live := mintDeviceCode(t, app, "hanzo-cli")
unknown := deviceInfoGet(t, app, "ZZZZZZZZ", cookie)
if unknown["status"] != "error" {
t.Fatalf("an unknown code must be refused: %v", unknown)
}
// Approve the live code, then look it up again: an already-approved code must
// read exactly like an unknown one.
approveFor(t, app, live, cookie)
approved := deviceInfoGet(t, app, live, cookie)
if approved["status"] != "error" {
t.Fatalf("an already-approved code must be refused: %v", approved)
}
if approved["msg"] != unknown["msg"] {
t.Fatalf("refusals differ (%q vs %q) — that difference is an oracle", approved["msg"], unknown["msg"])
}
}
// What you may LOOK AT is exactly what you may approve: a user in another org
// learns nothing about a code bound to an app they could never authorize.
func TestDeviceInfo_TenantBoundary(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "s3cret", redirectURIs: []string{testRedirect}, shared: true})
seedDeviceApp(t, db, "hanzo-cli")
seedUserInOrg(t, db, "other", "alice", "alice@other.example", "pw")
userCode := mintDeviceCode(t, app, "hanzo-cli")
// Sign in as the OTHER org's alice.
form := url.Values{
"organization": {"other"}, "application": {"hanzo-console"},
"username": {"alice"}, "password": {"pw"}, "type": {"login"},
}
resp, body := do(t, app, formReq("POST", PathLogin, form))
if resp.StatusCode != 200 || decode(t, body)["status"] != "ok" {
t.Skipf("cross-org sign-in unavailable in this harness: %s", body)
}
env := deviceInfoGet(t, app, userCode, cookieKV(resp.Header.Get("Set-Cookie")))
if env["status"] != "error" {
t.Fatalf("a user in another org must not learn the client: %v", env)
}
}
// approveFor approves a pending user_code as the signed-in browser.
func approveFor(t *testing.T, app *zip.App, userCode, cookie string) {
t.Helper()
req := jsonReq("POST", PathLogin, map[string]string{"type": "device", "userCode": userCode})
req.Header.Set("Cookie", cookie)
_, body := do(t, app, req)
if decode(t, body)["status"] != "ok" {
t.Fatalf("approval failed: %s", body)
}
}
// Defect 2: a device approval posted with NO session used to fall through to the
// credential check and answer "organization, username and password are required"
// — naming three fields the approval page has never rendered and never sends. The
// device flow exists precisely because you are approving from a DIFFERENT device,
// so a fresh browser with no session is the ordinary case. Guaranteed dead end.
func TestLogin_DeviceWithoutSessionSaysSignIn(t *testing.T) {
app, db := newServer(t)
seedDeviceApp(t, db, "hanzo-cli")
userCode := mintDeviceCode(t, app, "hanzo-cli")
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"type": "device", "userCode": userCode,
}))
env := decode(t, body)
if env["status"] != "error" {
t.Fatalf("expected a refusal, got %s", body)
}
msg, _ := env["msg"].(string)
if msg == "organization, username and password are required" {
t.Fatal("the device page renders no organization/username/password fields — demanding them is a dead end")
}
if env["code"] != CodeLoginRequired {
t.Fatalf("code = %v, want %q so the page can redirect to sign-in and return with the user_code", env["code"], CodeLoginRequired)
}
}
+672
View File
@@ -0,0 +1,672 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
"net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/pkg/pkce"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
)
// The RFC 8628 device grant, driven through the real router exactly as the two
// live CLIs drive it: client_id/scope as QUERY params on the device request
// (cloud/cli/device.go, codex-rs oidc_device_auth.rs), then a form-encoded poll
// at the one token endpoint.
// deviceGrants is the grant set a device-capable app declares — what hanzo-app
// carries in the live seed.
var deviceGrants = []string{"authorization_code", "refresh_token", deviceGrant}
// seedDeviceApp seeds a public, device-capable app plus a user in its org.
func seedDeviceApp(t *testing.T, db orm.DB, clientID string) {
t.Helper()
seedApp(t, db, appOpts{clientID: clientID, grants: deviceGrants})
seedUserInOrg(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw")
}
// requestDevice drives POST /v1/iam/oauth/device the way a PUBLIC device client
// does: client_id/scope only, no secret.
func requestDevice(t *testing.T, app *zip.App, clientID, scope string) (*http.Response, map[string]any) {
t.Helper()
return requestDeviceSecret(t, app, clientID, "", scope)
}
// requestDeviceSecret drives the device request with an optional client_secret —
// the confidential-client leg (RFC 8628 §3.1). An empty secret is the public
// case.
func requestDeviceSecret(t *testing.T, app *zip.App, clientID, secret, scope string) (*http.Response, map[string]any) {
t.Helper()
q := url.Values{"client_id": {clientID}, "scope": {scope}, "response_type": {"device_code"}}
if secret != "" {
q.Set("client_secret", secret)
}
resp, body := do(t, app, formReqNoBody("POST", PathDevice+"?"+q.Encode()))
return resp, decode(t, body)
}
// pollDevice drives one device poll at the token endpoint (public client).
func pollDevice(t *testing.T, app *zip.App, clientID, deviceCode string) (*http.Response, map[string]any) {
t.Helper()
return pollDeviceSecret(t, app, clientID, "", deviceCode)
}
// pollDeviceSecret drives one device poll with an optional client_secret — the
// confidential-client leg (RFC 8628 §3.4).
func pollDeviceSecret(t *testing.T, app *zip.App, clientID, secret, deviceCode string) (*http.Response, map[string]any) {
t.Helper()
form := url.Values{
"grant_type": {deviceGrant},
"client_id": {clientID},
"device_code": {deviceCode},
}
if secret != "" {
form.Set("client_secret", secret)
}
resp, body := do(t, app, formReq("POST", PathToken, form))
return resp, decode(t, body)
}
// approveAs drives the human approval leg: POST /v1/iam/login {type:"device"}.
func approveAs(t *testing.T, app *zip.App, org, user, userCode string) map[string]any {
t.Helper()
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"organization": org, "username": user, "password": "pw",
"type": "device", "userCode": userCode,
}))
return decode(t, body)
}
// The device response carries exactly the keys both CLIs decode, with the TTL
// and poll interval the server actually enforces. cloud/cli/device.go hard-fails
// on an empty device_code/user_code, so an error envelope here is a dead CLI.
func TestDevice_RequestShape(t *testing.T) {
app, db := newServer(t)
seedDeviceApp(t, db, "hanzo-app")
resp, m := requestDevice(t, app, "hanzo-app", "openid profile")
if resp.StatusCode != 200 {
t.Fatalf("status %d: %v", resp.StatusCode, m)
}
deviceCode, _ := m["device_code"].(string)
userCode, _ := m["user_code"].(string)
if deviceCode == "" || userCode == "" {
t.Fatalf("device_code/user_code must be non-empty: %v", m)
}
if m["expires_in"] != float64(900) {
t.Errorf("expires_in = %v, want 900", m["expires_in"])
}
if m["interval"] != float64(5) {
t.Errorf("interval = %v, want 5", m["interval"])
}
// verification_uri_complete must be the PATH form: the SPA route is
// /login/oauth/device/:userCode.
verify, _ := m["verification_uri"].(string)
if verify != "https://hanzo.id"+PathDeviceVerify {
t.Errorf("verification_uri = %q", verify)
}
if got, want := m["verification_uri_complete"], verify+"/"+userCode; got != want {
t.Errorf("verification_uri_complete = %v, want %v", got, want)
}
if resp.Header.Get("Cache-Control") != "no-store" {
t.Errorf("Cache-Control = %q, want no-store", resp.Header.Get("Cache-Control"))
}
// The user_code must be transcribable AND survive the portal's
// normalization (uppercase, separators stripped) unchanged — a code the
// portal rewrites is a code the lookup can never find.
if len(userCode) != userCodeLen {
t.Errorf("user_code %q: length %d, want %d", userCode, len(userCode), userCodeLen)
}
if got := strings.ToUpper(strings.ReplaceAll(userCode, "-", "")); got != userCode {
t.Errorf("user_code %q is not already normalized (portal would send %q)", userCode, got)
}
for _, r := range userCode {
if !strings.ContainsRune(userCodeAlphabet, r) {
t.Errorf("user_code %q contains ambiguous symbol %q", userCode, r)
}
}
// The pending grant is a persisted row, not process-local state.
row, err := store.GetTokenByCode(tctx(), db, deviceCode)
if err != nil || row == nil {
t.Fatalf("device authorization was not persisted: %v", err)
}
if row.User != "" {
t.Errorf("a fresh device authorization must be unapproved, got user %q", row.User)
}
if row.UserCode != userCode {
t.Errorf("row.UserCode = %q, want %q", row.UserCode, userCode)
}
}
// Discovery advertises the device endpoint and grant so a discovery-driven
// client can find them.
func TestDevice_Discovery(t *testing.T) {
app, _ := newServer(t)
_, body := do(t, app, formReqNoBody("GET", PathDiscovery))
d := decode(t, body)
if d["device_authorization_endpoint"] != "https://hanzo.id"+PathDevice {
t.Errorf("device_authorization_endpoint = %v, want %v", d["device_authorization_endpoint"], "https://hanzo.id"+PathDevice)
}
gts, _ := d["grant_types_supported"].([]any)
found := false
for _, g := range gts {
if g == deviceGrant {
found = true
}
}
if !found {
t.Errorf("grant_types_supported missing %q: %v", deviceGrant, gts)
}
}
// Before approval the poll answers authorization_pending and LEAVES the row —
// the CLI polls on this answer, so consuming the row would end the login.
func TestDevice_PollPendingIsRepeatable(t *testing.T) {
app, db := newServer(t)
seedDeviceApp(t, db, "hanzo-app")
_, da := requestDevice(t, app, "hanzo-app", "openid")
deviceCode := da["device_code"].(string)
for i := range 3 {
resp, m := pollDevice(t, app, "hanzo-app", deviceCode)
if resp.StatusCode != 400 {
t.Fatalf("poll %d: status %d, want 400", i, resp.StatusCode)
}
if m["error"] != "authorization_pending" {
t.Fatalf("poll %d: error = %v, want authorization_pending", i, m["error"])
}
// A 401 would send the CLI down its terminal error path.
if resp.Header.Get("WWW-Authenticate") != "" {
t.Fatalf("poll %d: a pending poll must not carry a WWW-Authenticate challenge", i)
}
}
if row, _ := store.GetTokenByCode(tctx(), db, deviceCode); row == nil {
t.Fatal("a pending poll must not consume the device authorization")
}
}
// The whole point, end to end: approve once, mint once. The SECOND poll of an
// approved code must fail — one approval is one token.
func TestDevice_ApproveThenPollMintsExactlyOnce(t *testing.T) {
app, db := newServer(t)
seedDeviceApp(t, db, "hanzo-app")
_, da := requestDevice(t, app, "hanzo-app", "openid profile")
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
if m := approveAs(t, app, "hanzo", "alice", userCode); m["status"] != "ok" {
t.Fatalf("approval failed: %v", m)
}
// The approval binds the approver onto the row — identity comes from there,
// never from the polling device.
row, _ := store.GetTokenByCode(tctx(), db, deviceCode)
if row == nil || row.User != "hanzo/alice" {
t.Fatalf("approval must bind the approver onto the row, got %+v", row)
}
resp, m := pollDevice(t, app, "hanzo-app", deviceCode)
if resp.StatusCode != 200 {
t.Fatalf("approved poll: status %d: %v", resp.StatusCode, m)
}
access, _ := m["access_token"].(string)
if access == "" {
t.Fatalf("approved poll must mint an access_token: %v", m)
}
if id, _ := m["id_token"].(string); id == "" {
t.Error("the openid scope must mint an id_token")
}
if rt, _ := m["refresh_token"].(string); rt == "" {
t.Error("the device grant must mint a refresh token")
}
// The minted token describes the approver, and is usable.
claims, err := verifyToken(tctx(), db, access)
if err != nil {
t.Fatalf("minted access token does not verify: %v", err)
}
if claims.Subject != "hanzo/alice" {
t.Errorf("sub = %q, want hanzo/alice", claims.Subject)
}
// One approval, one token: a replayed poll gets nothing.
resp2, m2 := pollDevice(t, app, "hanzo-app", deviceCode)
if resp2.StatusCode != 400 || m2["error"] != "expired_token" {
t.Fatalf("second poll: %d %v, want 400 expired_token", resp2.StatusCode, m2)
}
if _, ok := m2["access_token"]; ok {
t.Fatal("a redeemed device code must never mint twice")
}
}
// A CONFIDENTIAL device client authenticates at BOTH legs (RFC 8628 §3.1 request,
// §3.4 poll). Without its secret the request is refused and the poll — even of an
// approved code — mints nothing; with the secret both succeed.
func TestDevice_ConfidentialClientAuth(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf-cli", secret: "s3cret", grants: deviceGrants})
seedUserInOrg(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw")
// §3.1: a confidential client's device request without its secret is refused.
resp, m := requestDevice(t, app, "conf-cli", "openid")
if resp.StatusCode != 401 || m["error"] != "invalid_client" {
t.Fatalf("unauthenticated device request: %d %v, want 401 invalid_client", resp.StatusCode, m)
}
if m["device_code"] != nil {
t.Fatal("a refused device request must not mint a device_code")
}
// With the secret it succeeds.
resp, m = requestDeviceSecret(t, app, "conf-cli", "s3cret", "openid")
if resp.StatusCode != 200 {
t.Fatalf("authenticated device request: %d %v", resp.StatusCode, m)
}
deviceCode, userCode := m["device_code"].(string), m["user_code"].(string)
if am := approveAs(t, app, "hanzo", "alice", userCode); am["status"] != "ok" {
t.Fatalf("approval failed: %v", am)
}
// §3.4: the poll without the secret is refused — even though the code is
// approved — and mints nothing.
presp, pm := pollDevice(t, app, "conf-cli", deviceCode)
if presp.StatusCode != 401 || pm["error"] != "invalid_client" {
t.Fatalf("unauthenticated poll: %d %v, want 401 invalid_client", presp.StatusCode, pm)
}
if _, ok := pm["access_token"]; ok {
t.Fatal("an unauthenticated confidential poll must never mint")
}
// With the secret the poll mints.
presp, pm = pollDeviceSecret(t, app, "conf-cli", "s3cret", deviceCode)
if presp.StatusCode != 200 || pm["access_token"] == nil {
t.Fatalf("authenticated poll must mint: %d %v", presp.StatusCode, pm)
}
}
// Tenant boundary: a user in org B must not approve a device sign-in bound to an
// app in org A. A SuperAdmin — a member of the reserved admin org — may, because
// that is the identity an operator signs a CLI into any brand with.
func TestDevice_ApprovalTenantBoundary(t *testing.T) {
for _, tc := range []struct {
name string
org string // approver's org; the device app lives in "hanzo"
allow bool
}{
{"same org approves", "hanzo", true},
{"foreign org refused", "lux", false},
{"superadmin crosses tenants", "admin", true},
} {
t.Run(tc.name, func(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-app", grants: deviceGrants}) // org "hanzo"
seedUserInOrg(t, db, tc.org, "eve", "eve@"+tc.org+".example", "pw")
_, da := requestDevice(t, app, "hanzo-app", "openid")
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
m := approveAs(t, app, tc.org, "eve", userCode)
row, _ := store.GetTokenByCode(tctx(), db, deviceCode)
if !tc.allow {
if m["status"] != "error" {
t.Fatalf("cross-tenant approval must be refused, got %v", m)
}
// The store is the proof: refused means NOT approved.
if row.User != "" {
t.Fatalf("refused approval must not bind a user, got %q", row.User)
}
// And the device must still not be able to mint.
if _, p := pollDevice(t, app, "hanzo-app", deviceCode); p["error"] != "authorization_pending" {
t.Fatalf("a refused approval must leave the device pending, got %v", p)
}
return
}
if m["status"] != "ok" {
t.Fatalf("approval must succeed, got %v", m)
}
if row.User != tc.org+"/eve" {
t.Fatalf("row.User = %q, want %q", row.User, tc.org+"/eve")
}
})
}
}
// RFC 8628 §3.4: a device_code is redeemable only by the client it was issued
// to. Otherwise an approval for app A is redeemable as app B — a token for the
// wrong audience.
func TestDevice_ClientBinding(t *testing.T) {
app, db := newServer(t)
seedDeviceApp(t, db, "hanzo-app")
seedApp(t, db, appOpts{clientID: "other-app", grants: deviceGrants})
_, da := requestDevice(t, app, "hanzo-app", "openid")
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
if m := approveAs(t, app, "hanzo", "alice", userCode); m["status"] != "ok" {
t.Fatalf("approval failed: %v", m)
}
resp, m := pollDevice(t, app, "other-app", deviceCode)
if resp.StatusCode != 400 || m["error"] != "invalid_grant" {
t.Fatalf("foreign client redemption: %d %v, want 400 invalid_grant", resp.StatusCode, m)
}
if _, ok := m["access_token"]; ok {
t.Fatal("a device_code must never be redeemable by another client")
}
// The rightful client can still redeem — the binding refused, it did not burn.
if _, own := pollDevice(t, app, "hanzo-app", deviceCode); own["access_token"] == nil {
t.Fatalf("the issuing client must still redeem its own code: %v", own)
}
}
// An expired device_code is dead even once approved.
func TestDevice_Expiry(t *testing.T) {
app, db := newServer(t)
seedDeviceApp(t, db, "hanzo-app")
start := time.Unix(1_800_000_000, 0)
nowFuncSet(t, start)
_, da := requestDevice(t, app, "hanzo-app", "openid")
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
if m := approveAs(t, app, "hanzo", "alice", userCode); m["status"] != "ok" {
t.Fatalf("approval failed: %v", m)
}
nowFuncSet(t, start.Add(deviceCodeTTL+time.Second))
resp, m := pollDevice(t, app, "hanzo-app", deviceCode)
if resp.StatusCode != 400 || m["error"] != "expired_token" {
t.Fatalf("expired poll: %d %v, want 400 expired_token", resp.StatusCode, m)
}
if _, ok := m["access_token"]; ok {
t.Fatal("an expired device code must never mint")
}
if row, _ := store.GetTokenByCode(tctx(), db, deviceCode); row != nil {
t.Error("an expired device authorization should be reaped on the poll that finds it")
}
}
// The per-application grant gate: an app that never declared the device grant
// can neither start a device flow nor redeem one. Gated at BOTH ends, so a row
// created while the grant was enabled cannot mint after it is withdrawn.
func TestDevice_GrantGate(t *testing.T) {
app, db := newServer(t)
// A real app, fully functional — it simply never declared the device grant.
seedApp(t, db, appOpts{clientID: "web-only", grants: []string{"authorization_code", "refresh_token"}})
seedUserInOrg(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw")
resp, m := requestDevice(t, app, "web-only", "openid")
if resp.StatusCode != 400 || m["error"] != "unsupported_grant_type" {
t.Fatalf("request: %d %v, want 400 unsupported_grant_type", resp.StatusCode, m)
}
if m["device_code"] != nil {
t.Fatal("a refused device request must not mint a device_code")
}
// And at the poll: forge a device row for the app, as if the grant had been
// enabled and then withdrawn, and prove the redemption is still refused.
seedApp(t, db, appOpts{clientID: "was-enabled", grants: deviceGrants})
_, da := requestDevice(t, app, "was-enabled", "openid")
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
if am := approveAs(t, app, "hanzo", "alice", userCode); am["status"] != "ok" {
t.Fatalf("approval failed: %v", am)
}
withdrawGrants(t, db, "was-enabled")
resp2, m2 := pollDevice(t, app, "was-enabled", deviceCode)
if resp2.StatusCode != 400 || m2["error"] != "unsupported_grant_type" {
t.Fatalf("poll after withdrawal: %d %v, want 400 unsupported_grant_type", resp2.StatusCode, m2)
}
if _, ok := m2["access_token"]; ok {
t.Fatal("an app without the device grant must never mint a device token")
}
}
// The user_code is the only secret in the approval flow (40 bits), so unknown,
// expired, and already-approved codes must be indistinguishable — otherwise the
// approval page is an oracle for hunting live codes.
func TestDevice_UserCodeRefusalIsNonDifferential(t *testing.T) {
app, db := newServer(t)
seedDeviceApp(t, db, "hanzo-app")
start := time.Unix(1_800_000_000, 0)
nowFuncSet(t, start)
// (a) unknown
unknown := approveAs(t, app, "hanzo", "alice", "ZZZZZZZZ")
// (b) already approved
_, da := requestDevice(t, app, "hanzo-app", "openid")
if m := approveAs(t, app, "hanzo", "alice", da["user_code"].(string)); m["status"] != "ok" {
t.Fatalf("first approval must succeed: %v", m)
}
reapproved := approveAs(t, app, "hanzo", "alice", da["user_code"].(string))
// (c) expired
_, da2 := requestDevice(t, app, "hanzo-app", "openid")
nowFuncSet(t, start.Add(deviceCodeTTL+time.Second))
expiredCode := approveAs(t, app, "hanzo", "alice", da2["user_code"].(string))
for _, m := range []map[string]any{unknown, reapproved, expiredCode} {
if m["status"] != "error" {
t.Fatalf("must be refused: %v", m)
}
}
if unknown["msg"] != reapproved["msg"] || unknown["msg"] != expiredCode["msg"] {
t.Fatalf("refusals differ — an oracle: unknown=%q reapproved=%q expired=%q",
unknown["msg"], reapproved["msg"], expiredCode["msg"])
}
}
// An AUTHORIZATION code must never be redeemable at the device grant. The device
// grant verifies neither PKCE nor redirect_uri, so accepting one there would
// defeat both for any app that permits the device grant — a stolen code would
// mint tokens with no verifier.
func TestDevice_AuthorizationCodeIsNotRedeemableAsDeviceCode(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-app", grants: deviceGrants, redirectURIs: []string{testRedirect}})
seedUserInOrg(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw")
verifier := "device-xchg-verifier-00000000000000000000000000000"
code, _, _ := loginForCode(t, app, map[string]string{
"organization": "hanzo", "username": "alice", "password": "pw",
"clientId": "hanzo-app", "redirectUri": testRedirect, "scope": "openid",
"codeChallenge": pkce.Challenge(verifier), "codeChallengeMethod": "S256",
})
if code == "" {
t.Fatal("setup: no authorization code minted")
}
resp, m := pollDevice(t, app, "hanzo-app", code)
if _, ok := m["access_token"]; ok {
t.Fatal("PKCE BYPASS: an authorization code was redeemed at the device grant")
}
if resp.StatusCode != 400 || m["error"] != "expired_token" {
t.Fatalf("got %d %v, want 400 expired_token", resp.StatusCode, m)
}
// The real exchange still works — the guard refused, it did not burn the code.
if _, tm := exchangeCode(t, app, url.Values{
"code": {code}, "client_id": {"hanzo-app"},
"code_verifier": {verifier}, "redirect_uri": {testRedirect},
}); tm["access_token"] == nil {
t.Fatalf("the legitimate code exchange must still succeed: %v", tm)
}
}
// The mirror image: a DEVICE code must never be redeemable at the
// authorization-code grant, which would mint on a row no human has approved.
func TestDevice_DeviceCodeIsNotRedeemableAsAuthorizationCode(t *testing.T) {
app, db := newServer(t)
// A CONFIDENTIAL app: its secret would otherwise satisfy the code grant's
// client check, and an unapproved device row carries no PKCE challenge to
// stop it. The device request authenticates that same secret (§3.1).
seedApp(t, db, appOpts{clientID: "conf-app", secret: "s3cret", grants: deviceGrants})
seedUserInOrg(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw")
_, da := requestDeviceSecret(t, app, "conf-app", "s3cret", "openid")
deviceCode := da["device_code"].(string)
_, m := exchangeCode(t, app, url.Values{
"code": {deviceCode}, "client_id": {"conf-app"}, "client_secret": {"s3cret"},
})
if _, ok := m["access_token"]; ok {
t.Fatal("APPROVAL BYPASS: an unapproved device code minted at the authorization-code grant")
}
if m["error"] != "invalid_grant" {
t.Fatalf("error = %v, want invalid_grant", m["error"])
}
}
// An unknown client_id is refused — and mints nothing.
func TestDevice_UnknownClient(t *testing.T) {
app, _ := newServer(t)
resp, m := requestDevice(t, app, "no-such-client", "openid")
if resp.StatusCode != 400 || m["error"] != "invalid_client" {
t.Fatalf("got %d %v, want 400 invalid_client", resp.StatusCode, m)
}
if m["device_code"] != nil {
t.Fatal("an unknown client must not mint a device_code")
}
}
// appGrants is the pure gate both ends call.
func TestAppGrants(t *testing.T) {
for _, tc := range []struct {
name string
declare []string
want bool
}{
{"declared", deviceGrants, true},
{"not declared", []string{"authorization_code", "refresh_token"}, false},
{"none declared", nil, false},
} {
t.Run(tc.name, func(t *testing.T) {
if got := appGrants(&schema.Application{GrantTypes: tc.declare}, deviceGrant); got != tc.want {
t.Fatalf("appGrants(%v) = %v, want %v", tc.declare, got, tc.want)
}
})
}
if appGrants(nil, deviceGrant) {
t.Fatal("a nil application must permit nothing")
}
}
// user_codes are drawn fresh each time — a generator that reuses one value could
// never clear a collision.
func TestRandomUserCode_Distinct(t *testing.T) {
seen := map[string]bool{}
for range 64 {
code, err := randomUserCode()
if err != nil {
t.Fatal(err)
}
if seen[code] {
t.Fatalf("user_code %q repeated — the draw is not random", code)
}
seen[code] = true
}
}
// withdrawGrants strips an application's declared grants in place.
func withdrawGrants(t *testing.T, db orm.DB, name string) {
t.Helper()
a, err := orm.Get[schema.Application](db, "admin/"+name)
if err != nil {
t.Fatalf("load app %s: %v", name, err)
}
a.GrantTypes = nil
if err := a.UpdateCtx(tctx()); err != nil {
t.Fatalf("withdraw grants: %v", err)
}
}
// approveWithSessionOnly approves the way the approval PAGE actually does: a
// session cookie and the user_code, and NOT one credential field. Every other
// device test here posts organization+username+password (approveAs), which is a
// shape the product never sends — the page exists precisely because the human is
// already signed in.
func approveWithSessionOnly(t *testing.T, app *zip.App, cookie, userCode string) map[string]any {
t.Helper()
req := jsonReq("POST", PathLogin, map[string]string{
"type": "device", "userCode": userCode, "application": "hanzo-app",
})
req.Header.Set("Cookie", cookie)
_, body := do(t, app, req)
return decode(t, body)
}
// A signed-in human approves a device WITHOUT re-entering a password.
//
// This is the whole RFC 8628 terminal leg and it was broken: the session branch
// in login.go was gated to type=code, so a credential-less type=device post fell
// through to the credential check and answered "organization, username and
// password are required" — with HTTP 200, so nothing looked wrong. `hanzo login`
// hung at "Waiting for approval…" forever and no test noticed, because every
// device test approved with a full password.
func TestDevice_ApproveFromSessionWithoutCredentials(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedRichUser(t, db)
seedDeviceApp(t, db, "hanzo-app")
_, da := requestDevice(t, app, "hanzo-app", "openid profile")
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
cookie := sessionCookieFor(t, app)
if m := approveWithSessionOnly(t, app, cookie, userCode); m["status"] != "ok" {
t.Fatalf("a signed-in human must approve without re-entering a password, got: %v", m)
}
// The approval binds the SESSION's identity onto the row — the same binding
// the credentialed path makes, so the device is signed in as the approver.
row, _ := store.GetTokenByCode(tctx(), db, deviceCode)
if row == nil || row.User != "hanzo/alice" {
t.Fatalf("approval must bind the approver onto the row, got %+v", row)
}
// And the device's poll now completes, which is the point of the whole flow.
resp, m := pollDevice(t, app, "hanzo-app", deviceCode)
if resp.StatusCode != 200 {
t.Fatalf("poll after session approval: status %d: %v", resp.StatusCode, m)
}
if access, _ := m["access_token"].(string); access == "" {
t.Fatalf("an approved device must mint a token: %v", m)
}
}
// Anonymous is still refused. Dropping the type=code restriction must not turn
// the approval endpoint into one an unauthenticated caller can drive: without a
// session there is no identity to bind, and a device that could be approved by
// nobody would be a device anybody could take over.
func TestDevice_ApproveWithoutSessionIsRefused(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedRichUser(t, db)
seedDeviceApp(t, db, "hanzo-app")
_, da := requestDevice(t, app, "hanzo-app", "openid profile")
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
// No Cookie header at all.
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"type": "device", "userCode": userCode, "application": "hanzo-app",
}))
if m := decode(t, body); m["status"] != "error" {
t.Fatalf("anonymous device approval must be refused, got: %v", m)
}
// Nothing was bound, and the device is still waiting.
row, _ := store.GetTokenByCode(tctx(), db, deviceCode)
if row != nil && row.User != "" {
t.Fatalf("a refused approval must bind nobody, got user=%q", row.User)
}
resp, m := pollDevice(t, app, "hanzo-app", deviceCode)
if resp.StatusCode == 200 {
t.Fatalf("an unapproved device must not mint: %v", m)
}
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
"testing"
)
// Discovery is served at both well-known paths, host-relative, advertising only
// what iam implements — matching the live hanzo.id surface so a client's
// discovery step is unchanged across the backend swap.
func TestDiscovery_ShapeAtBothPaths(t *testing.T) {
app, _ := newServer(t)
for _, path := range []string{PathDiscovery, PathDiscoveryV1} {
resp, body := do(t, app, formReqNoBody("GET", path))
if resp.StatusCode != 200 {
t.Fatalf("%s: status %d", path, resp.StatusCode)
}
d := decode(t, body)
if d["issuer"] != "https://hanzo.id" {
t.Errorf("%s: issuer = %v, want https://hanzo.id", path, d["issuer"])
}
if d["authorization_endpoint"] != "https://hanzo.id"+PathAuthorize {
t.Errorf("%s: authorization_endpoint = %v", path, d["authorization_endpoint"])
}
if d["token_endpoint"] != "https://hanzo.id"+PathToken {
t.Errorf("%s: token_endpoint = %v", path, d["token_endpoint"])
}
if d["userinfo_endpoint"] != "https://hanzo.id"+PathUserInfo {
t.Errorf("%s: userinfo_endpoint = %v", path, d["userinfo_endpoint"])
}
if d["jwks_uri"] != "https://hanzo.id"+PathJWKS {
t.Errorf("%s: jwks_uri = %v", path, d["jwks_uri"])
}
if !containsStr(d["code_challenge_methods_supported"], "S256") {
t.Errorf("%s: S256 not advertised", path)
}
if containsStr(d["code_challenge_methods_supported"], "plain") {
t.Errorf("%s: plain must never be advertised", path)
}
for _, alg := range []string{"RS256", "ES256", "MLDSA65"} {
if !containsStr(d["id_token_signing_alg_values_supported"], alg) {
t.Errorf("%s: signing alg %s not advertised", path, alg)
}
}
for _, gt := range []string{"authorization_code", "refresh_token", "client_credentials"} {
if !containsStr(d["grant_types_supported"], gt) {
t.Errorf("%s: grant %s not advertised", path, gt)
}
}
}
}
// The issuer NEVER follows X-Forwarded-Host: it is resolved from the TRUSTED
// request host (zip.Ctx.Host(), which ignores X-Forwarded-Host) through the pinned
// issuer resolver, so a client-supplied X-Forwarded-Host cannot steer `iss`. Here
// the trusted host is hanzo.id (formReqNoBody) and no issuer map is configured, so
// the spoofed header is discarded and the issuer stays host-relative to hanzo.id.
func TestDiscovery_IssuerIgnoresForwardedHost(t *testing.T) {
app, _ := newServer(t)
req := formReqNoBody("GET", PathDiscovery)
req.Header.Set("X-Forwarded-Host", "id.example.test")
resp, body := do(t, app, req)
if resp.StatusCode != 200 {
t.Fatalf("status %d", resp.StatusCode)
}
if got := decode(t, body)["issuer"]; got != "https://hanzo.id" {
t.Fatalf("issuer = %v, want https://hanzo.id (X-Forwarded-Host must not steer iss)", got)
}
}
func containsStr(v any, want string) bool {
list, ok := v.([]any)
if !ok {
return false
}
for _, s := range list {
if s == want {
return true
}
}
return false
}
+636
View File
@@ -0,0 +1,636 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"net/url"
"strconv"
"strings"
"time"
"github.com/hanzoai/orm"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/mfa/factor"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/users"
)
// Identity federation — iam as an OIDC/OAuth2 Relying Party to external IdPs.
//
// A social sign-in is a DETOUR inside the ordinary authorization-code flow. The
// authorize endpoint, having already validated the client and its EXACT
// redirect_uri (so there is a trusted target before anything is trusted), hands
// a request that names a `provider` to beginFederation, which stashes the whole
// app-leg request server-side and sends the browser to the IdP. When the IdP
// returns to the fixed callback, iam verifies the response, LINKS or PROVISIONS
// a local user, and mints ITS OWN authorization code — bound to the original
// PKCE challenge, redirect_uri, and nonce — exactly as a password login would.
// The relying party's existing PKCE code→token exchange then completes unchanged.
//
// The whole surface lives on the PUBLIC group (before the Guard): it
// self-authenticates through the single-use, browser-bound, expiring state, not
// a bearer. Every failure is fail-closed; no IdP token or secret is ever logged.
// PathFederationCallback is the fixed IdP return endpoint. One callback for every
// provider — the provider is recovered from the server-side transaction the
// state keys, never from a spoofable URL segment. It is the redirect_uri iam
// registers with each external IdP.
const PathFederationCallback = "/v1/iam/oauth/callback"
// PathMfaVerify is the hosted 2FA PAGE (a route in the SPA, not an API path) the
// federation callback sends a second-factor-enrolled user's browser to. The page
// collects the factor and POSTs it to PathFederationMfa; the challenge id rides
// the httpOnly cookie the callback set, never a URL segment.
const PathMfaVerify = "/login/mfa"
// fedCookieName is the per-transaction anti-forgery cookie the begin leg sets and
// the callback checks — the browser binding that defeats login-CSRF.
const fedCookieName = "hanzo_fed"
// fedStateTTL bounds how long a federation transaction (and its cookie) is
// redeemable. Short, because it only has to survive one IdP round-trip.
const fedStateTTL = 10 * time.Minute
// routeFederation registers the IdP callback on the PUBLIC group r. GET only: the
// IdP returns via a top-level browser redirect (Google/GitHub), on which the
// SameSite=Lax browser-binding cookie IS sent. A cross-site form_post (POST) would
// NOT carry a Lax cookie, so the bind check would fail closed — rather than ship a
// half-working POST path, form_post support is a deliberate future change (it needs
// SameSite=None + its own CSRF analysis). The callback self-authenticates via the
// single-use state + the browser cookie.
func routeFederation(r zip.Router, db orm.DB) {
r.Get(PathFederationCallback, federationCallbackHandler(db))
}
// beginFederation starts an Authorization-Code federation. It is entered from
// authorizeHandler ONLY after the client_id and exact redirect_uri are validated
// and the response_type/PKCE policy is enforced, so a protocol error may now be
// redirected to the trusted redirect_uri (RFC 6749 §4.1.2.1). It resolves the
// named provider, mints a single-use transaction, sets the browser-binding
// cookie, and sends the browser to the IdP.
func beginFederation(c *zip.Ctx, db orm.DB, app *schema.Application, q authorizeRequest, method string) error {
ctx := c.Context()
// A federated (external) identity may never be minted into a reserved system
// org (the SuperAdmin vector) nor into a tenant an attacker-owned app has no
// right to serve. Refuse BEFORE starting the round-trip (fail fast, no IdP
// traffic) — defense in depth behind the application-write org authorization.
if !federationOrgAllowed(app) {
return authorizeErrorRedirect(c, q, "access_denied", "federation is not permitted for this application")
}
store.EnrichProviders(ctx, db, app)
prov := federationProvider(app, q.provider)
if prov == nil {
return authorizeErrorRedirect(c, q, "invalid_request", "unknown or unavailable provider")
}
if idpKind(prov) == "" {
return authorizeErrorRedirect(c, q, "invalid_request", "provider is not a supported federation type")
}
if _, ok := connectorFor(prov.Type); !ok {
return authorizeErrorRedirect(c, q, "invalid_request", "provider has no local identity binding")
}
state, err := newOpaqueToken()
if err != nil {
return authorizeErrorRedirect(c, q, "server_error", "")
}
verifier, err := newOpaqueToken()
if err != nil {
return authorizeErrorRedirect(c, q, "server_error", "")
}
nonce, err := newOpaqueToken()
if err != nil {
return authorizeErrorRedirect(c, q, "server_error", "")
}
bindSecret, err := newOpaqueToken()
if err != nil {
return authorizeErrorRedirect(c, q, "server_error", "")
}
now := nowFunc()
st := &schema.FederationState{
Owner: providerOwner(prov),
Name: state,
CreatedTime: now.UTC().Format(time.RFC3339),
Provider: prov.Name,
ClientId: q.clientID,
RedirectUri: q.redirectURI,
AppState: q.state,
Scope: q.scope,
AppNonce: q.nonce,
CodeChallenge: q.codeChallenge,
CodeChallengeMethod: method,
Resource: q.resource,
IdpVerifier: verifier,
IdpNonce: nonce,
BindHash: hashToken(bindSecret),
ExpireIn: now.Add(fedStateTTL).Unix(),
}
// Build the IdP authorize URL BEFORE persisting so a discovery/config failure
// never leaves an orphaned transaction row.
idpURL, err := idpAuthorizeURL(ctx, prov, st, federationCallbackURL(c))
if err != nil {
return authorizeErrorRedirect(c, q, "temporarily_unavailable", "the identity provider is unavailable")
}
if err := store.PersistFederationState(ctx, db, st); err != nil {
return authorizeErrorRedirect(c, q, "server_error", "")
}
setBindCookie(c, bindSecret)
return c.Redirect(302, idpURL)
}
// federationCallbackHandler completes the round-trip: it resolves and burns the
// single-use transaction (checking expiry + browser binding), exchanges and
// verifies the IdP response, links or provisions the local user, and mints the
// iam authorization code the relying party expects — then redirects to the
// original redirect_uri with code + state.
func federationCallbackHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
now := nowFunc()
state := param(c, "state")
if state == "" {
return authorizeUserError(c, "missing state")
}
st, err := store.GetFederationState(ctx, db, state)
if err != nil {
return authorizeUserError(c, "internal error")
}
// Until the state resolves there is NO trusted redirect target, so an
// invalid/expired/replayed state is answered in place, never redirected.
if st == nil || st.Used || (st.ExpireIn != 0 && now.Unix() > st.ExpireIn) {
return authorizeUserError(c, "the federation session is invalid or expired")
}
// Browser binding: the callback must present the same anti-forgery cookie
// the begin leg set in THIS browser (constant-time) — the login-CSRF /
// session-fixation defense. A stolen or injected state without the cookie
// stops here.
raw := readBindCookie(c)
if raw == "" || subtle.ConstantTimeCompare([]byte(hashToken(raw)), []byte(st.BindHash)) != 1 {
return authorizeUserError(c, "the federation session could not be verified")
}
// Burn the transaction now (single-use), ATOMICALLY: the find-and-burn runs under
// a row lock (GetForUpdate), so two concurrent callbacks on one state cannot both
// win — the loser is refused (mirrors the wallet challenge burn / TakeChallenge).
// A replay finds it spent. The bind-cookie check above already gated this request,
// so a CSRF-failed replay never reaches the burn.
if _, err := store.BurnFederationState(ctx, db, state, now); err != nil {
if errors.Is(err, store.ErrFederationConsumed) {
return authorizeUserError(c, "the federation session is invalid or expired")
}
return authorizeUserError(c, "internal error")
}
st.Used = true
clearBindCookie(c)
// Resolve the relying-party app (the trusted redirect target) and re-check
// its redirect_uri against the live allow-list — never trust the stored
// value blindly (defense in depth against a tampered row).
app, err := store.GetApplicationByClientId(ctx, db, st.ClientId)
if err != nil || app == nil {
return authorizeUserError(c, "the client application is unavailable")
}
if !app.IsRedirectUriValid(st.RedirectUri) {
return authorizeUserError(c, "invalid redirect_uri")
}
// Re-assert the reserved-org / tenant-legitimacy gate at the mint boundary,
// never trusting that the begin leg still holds or that the app row is honest.
if !federationOrgAllowed(app) {
return fedErrorRedirect(c, st, "access_denied", "federation is not permitted for this application")
}
prov, err := store.GetProvider(ctx, db, st.Owner, st.Provider)
if err != nil || prov == nil {
return fedErrorRedirect(c, st, "temporarily_unavailable", "the identity provider is unavailable")
}
// An IdP-reported denial (user declined / error) is surfaced to the RP as
// access_denied, not a server error.
if e := param(c, "error"); e != "" {
return fedErrorRedirect(c, st, "access_denied", "the identity provider denied the request")
}
code := param(c, "code")
if code == "" {
return fedErrorRedirect(c, st, "invalid_request", "the identity provider returned no code")
}
identity, err := idpExchange(ctx, prov, st, code, federationCallbackURL(c), now)
if err != nil || identity.subject == "" {
return fedErrorRedirect(c, st, "access_denied", "the identity provider could not be verified")
}
user, err := linkOrProvision(ctx, db, app, prov, identity)
if err != nil {
return fedErrorRedirect(c, st, "server_error", "")
}
if user.IsForbidden || user.IsDeleted {
return fedErrorRedirect(c, st, "access_denied", "the account is not permitted")
}
// The resume parameters — the ORIGINAL authorize request — pinned so the mint
// (now, or after a second factor) uses exactly these and nothing a later
// request could supply.
p := fedResumeParams{
ClientId: st.ClientId,
RedirectUri: st.RedirectUri,
AppState: st.AppState,
Scope: st.Scope,
AppNonce: st.AppNonce,
CodeChallenge: st.CodeChallenge,
CodeChallengeMethod: st.CodeChallengeMethod,
Resource: st.Resource,
}
// Second-factor gate: a federated login must NOT skip the factor a password
// login would demand (the MFA gate, mfa_gate.go). If the resolved user owes a
// factor, mint NOTHING here — park the resume, bound to the user and these
// pinned params, and send the browser to the hosted 2FA page.
org, err := store.GetOrganizationByName(ctx, db, user.Owner)
if err != nil {
return fedErrorRedirect(c, st, "server_error", "")
}
if factor.Prompt(org, user) {
// The organization requires a factor this federated user has not enrolled;
// a federated login cannot enroll one inline, so it fails closed.
return fedErrorRedirect(c, st, "access_denied", "two-factor authentication must be set up before signing in")
}
if factor.Enabled(user) && !remembered(user, now) {
return federationChallenge(c, db, st, user, p, now)
}
// No second factor owed — complete exactly as before, through the one mint.
loc, err := federationMint(ctx, db, app, user, p, now)
if err != nil {
return fedMintErrorRedirect(c, st, err)
}
return c.Redirect(302, loc)
}
}
// fedResumeParams is the ORIGINAL iam authorize request, pinned server-side so
// the code minted after a federated login (immediately, or after a second factor)
// binds to exactly these values — never to anything a later request supplies.
type fedResumeParams struct {
ClientId string `json:"clientId"`
RedirectUri string `json:"redirectUri"`
AppState string `json:"appState"`
Scope string `json:"scope"`
AppNonce string `json:"appNonce"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
Resource string `json:"resource"`
}
// errPKCERequired is the one distinguished mint error a caller maps to an OAuth
// invalid_request; every other mint failure is an opaque server_error.
var errPKCERequired = errors.New("federation: PKCE is required for public clients")
// federationMint mints iam's own authorization code — the SAME artifact a
// password login mints — bound to the pinned app-leg PKCE, redirect_uri and nonce,
// and returns the RP redirect (redirect_uri?code&state). It is the ONE mint path
// both the no-factor completion and the post-2FA resume reach, so a federated code
// can never be minted two different ways.
func federationMint(ctx context.Context, db orm.DB, app *schema.Application, user *schema.User, p fedResumeParams, now time.Time) (string, error) {
// A public client must have carried a PKCE challenge, re-asserted at the mint so
// a minted code is never redeemable without proof.
if app.ClientSecret == "" && p.CodeChallenge == "" {
return "", errPKCERequired
}
userID := user.Owner + "/" + user.Name
codeRow, err := MintCode(app, userID, p.Scope, p.CodeChallenge, p.CodeChallengeMethod, p.Resource, now)
if err != nil {
return "", err
}
codeRow.RedirectUri = p.RedirectUri
codeRow.Nonce = p.AppNonce
if err := store.PersistToken(ctx, db, codeRow); err != nil {
return "", err
}
v := url.Values{}
v.Set("code", codeRow.Code)
setIfPresent(v, "state", p.AppState)
return joinQuery(p.RedirectUri, v), nil
}
// federationChallenge parks a resolved-but-not-yet-second-factored federated login.
// The pending state IS a LoginChallenge (KindFederation) — the same single-use,
// expiring, subject-pinned lifecycle the password MFA gate uses, so there is ONE
// challenge concept — carrying the resume params as its payload. The browser is
// sent to the hosted 2FA page; the challenge id rides the httpOnly cookie, never a
// URL segment.
func federationChallenge(c *zip.Ctx, db orm.DB, st *schema.FederationState, user *schema.User, p fedResumeParams, now time.Time) error {
payload, err := json.Marshal(p)
if err != nil {
return fedErrorRedirect(c, st, "server_error", "")
}
id, err := MintChallenge(c.Context(), db, KindFederation, user.Owner+"/"+user.Name, string(payload), now)
if err != nil {
return fedErrorRedirect(c, st, "server_error", "")
}
SetChallenge(c, id)
return c.Redirect(302, federationBaseURL(c)+PathMfaVerify)
}
// fedMintErrorRedirect maps a federationMint error to the RP redirect_uri.
func fedMintErrorRedirect(c *zip.Ctx, st *schema.FederationState, err error) error {
if err == errPKCERequired {
return fedErrorRedirect(c, st, "invalid_request", "PKCE is required for public clients")
}
return fedErrorRedirect(c, st, "server_error", "")
}
// linkOrProvision resolves the local identity for a verified federated login,
// PROVISION-DON'T-PROMOTE: (1) an account already linked to this provider
// subject, else (2) an existing account matched by a VERIFIED IdP email (linked
// now), else (3) a freshly provisioned account. It NEVER sets isAdmin and never
// grants an existing account anything — federation only authenticates.
func linkOrProvision(ctx context.Context, db orm.DB, app *schema.Application, prov *schema.Provider, id federatedIdentity) (*schema.User, error) {
// Innermost guard on the mint itself: never provision/link a federated identity
// into a reserved system org (SuperAdmin) or a tenant this app may not serve.
// This layer assumes the two before it (app-write authorization + the begin/
// callback checks) both failed.
if !federationOrgAllowed(app) {
return nil, errors.New("federation: provisioning into this organization is not permitted")
}
org := app.Organization
binding, ok := connectorFor(prov.Type)
if !ok {
return nil, errors.New("federation: provider has no local identity binding")
}
// 1. Already linked by the provider's stable subject — the authoritative match
// for a returning federated user (immune to email churn/ambiguity).
if u, err := store.GetUserByConnector(ctx, db, org, binding.field, id.subject); err != nil {
return nil, err
} else if u != nil {
return u, nil
}
// 2. Link to an existing account ONLY on a VERIFIED IdP email. An unverified
// email never links (it would let an unproven address take over an account).
if id.emailVerified && id.email != "" {
if u, err := store.GetUserByEmail(ctx, db, org, id.email); err != nil {
return nil, err
} else if u != nil {
linked, err := updateUser(ctx, db, u.Owner, u.Name, func(_ orm.DB, fresh *schema.User) error {
*binding.ref(fresh) = id.subject
fresh.EmailVerified = true
return nil
})
if err != nil {
return nil, err
}
return linked, nil
}
}
// 3. Provision a fresh account. Federated accounts carry NO password (the
// digest stays empty, so password login fails closed) and are never admin.
return provisionFederatedUser(ctx, db, app, prov, binding, id)
}
// provisionFederatedUser creates a new federated account through the ONE
// canonical user-create path (users.Create, no password → no login-able digest),
// stamping the provider subject on its connector column. The username is derived
// from the EMAIL and collision-checked; the email's verified flag is carried
// straight from the IdP.
//
// What an IdP hands over is an address and a display name, and only the address
// may become an identity: a Google profile says "Zach Kelling", which is not a
// username in any spelling and must never be turned into one. schema.Handle takes
// the local part; the display name reaches DisplayName and stops there.
func provisionFederatedUser(ctx context.Context, db orm.DB, app *schema.Application, prov *schema.Provider, binding connectorBinding, id federatedIdentity) (*schema.User, error) {
org := app.Organization
for attempt := 1; attempt <= federatedNameAttempts; attempt++ {
name := federatedUsername(id.email, prov.Type, attempt)
taken, err := userExists(ctx, db, org, name)
if err != nil {
return nil, err
}
if taken {
continue
}
u := schema.User{
Owner: org,
Name: name,
Type: "normal-user",
DisplayName: firstNonEmpty(id.displayName, name),
Email: id.email,
EmailVerified: id.emailVerified,
Avatar: id.avatar,
SignupApplication: app.Name,
RegisterType: "Federation",
RegisterSource: org + "/" + prov.Name,
}
*binding.ref(&u) = id.subject
return users.New(db).Create(ctx, &users.CreateInput{User: u})
}
return nil, errors.New("federation: could not allocate a unique username")
}
// federationProvider resolves the app's ProviderItem named name to its shared
// Provider record, requiring the link to be sign-in-enabled and configured with
// real credentials — otherwise the request never dead-ends at the IdP.
func federationProvider(app *schema.Application, name string) *schema.Provider {
if name == "" {
return nil
}
for _, it := range app.Providers {
if it == nil || it.Name != name || !it.CanSignIn || it.Provider == nil {
continue
}
if !offerable(it.Provider) {
continue
}
return it.Provider
}
return nil
}
// providerOwner is the Provider record's owner, defaulting to the admin org where
// providers are seeded.
func providerOwner(p *schema.Provider) string {
if p.Owner != "" {
return p.Owner
}
return "admin"
}
// federationCallbackURL is the iam callback iam registers with the IdP and
// re-presents at the token exchange. It is PINNED from config, never steered by a
// request header, so an attacker cannot redirect the IdP leg via X-Forwarded-Host.
func federationCallbackURL(c *zip.Ctx) string {
return federationBaseURL(c) + PathFederationCallback
}
// federationBaseURL is the pinned public origin the IdP callback is registered
// under — the SAME per-brand issuer the tokens carry, resolved through the ONE
// issuer resolver (issuer.go) keyed on the TRUSTED request host (c.Host(), which
// ignores X-Forwarded-Host). So a brand's federation callback is registered at
// that brand's pinned origin, header-immune and never steered to an attacker
// origin. See resolveIssuer for the fail-closed resolution order.
func federationBaseURL(c *zip.Ctx) string {
return resolveFederationOrigin(c.Host())
}
// federationOrgAllowed reports whether a federated (external) identity may be
// provisioned or linked into the application's Organization. Two invariants,
// both fail-closed:
//
// 1. NEVER a reserved system org (admin/built-in/app). A social sign-in that
// landed a user in the admin org would make that user a SuperAdmin — the
// critical escalation. Federation is customer sign-in; system orgs are seeded
// / onboarded / SuperAdmin-managed, never reached by an external login.
// 2. Tenant legitimacy. A platform app (admin/built-in-owned, and thus only
// SuperAdmin-creatable) may serve any non-reserved tenant. A tenant-registered
// app may only land users in the org it legitimately serves — its OWN org, a
// shared app, or one with an explicit org-choice mode — mirroring the
// login/signup tenant gate, so an attacker-owned app cannot mint or link
// identities into a victim tenant.
//
// This is defense in depth behind the application-write authorization (authz
// authorizes the Organization field on create/update); this layer assumes that
// one was bypassed and still refuses the escalation.
func federationOrgAllowed(app *schema.Application) bool {
org := strings.TrimSpace(app.Organization)
if org == "" || store.IsReservedOrg(org) {
return false
}
if store.IsSigningCertOwner(app.Owner) {
return true // platform app — SuperAdmin-configured, may serve any tenant
}
if app.IsShared || app.OrgChoiceMode != "" {
return true
}
return org == app.Owner
}
// fedSuccessRedirect returns the browser to the relying party's redirect_uri with
// the iam authorization code and the original app state (RFC 6749 §4.1.2).
func fedSuccessRedirect(c *zip.Ctx, st *schema.FederationState, code string) error {
v := url.Values{}
v.Set("code", code)
setIfPresent(v, "state", st.AppState)
return c.Redirect(302, joinQuery(st.RedirectUri, v))
}
// fedErrorRedirect returns an OAuth error to the relying party's redirect_uri
// (already allow-list-validated) with the original app state.
func fedErrorRedirect(c *zip.Ctx, st *schema.FederationState, code, desc string) error {
v := url.Values{}
v.Set("error", code)
setIfPresent(v, "error_description", desc)
setIfPresent(v, "state", st.AppState)
return c.Redirect(302, joinQuery(st.RedirectUri, v))
}
// setBindCookie writes the per-transaction anti-forgery cookie: HttpOnly + Secure,
// SameSite=Lax (so it IS sent on the IdP's top-level GET back to the callback),
// scoped to the callback path, expiring with the transaction.
func setBindCookie(c *zip.Ctx, value string) {
c.Fiber().Cookie(&fiber.Cookie{
Name: fedCookieName,
Value: value,
Path: PathFederationCallback,
MaxAge: int(fedStateTTL / time.Second),
Secure: true,
HTTPOnly: true,
SameSite: fiber.CookieSameSiteLaxMode,
})
}
// readBindCookie returns the anti-forgery cookie value, or "" when absent.
func readBindCookie(c *zip.Ctx) string { return c.Fiber().Cookies(fedCookieName) }
// clearBindCookie expires the anti-forgery cookie once the transaction is
// consumed, so it can never be replayed.
func clearBindCookie(c *zip.Ctx) {
c.Fiber().Cookie(&fiber.Cookie{
Name: fedCookieName,
Value: "",
Path: PathFederationCallback,
MaxAge: -1,
Secure: true,
HTTPOnly: true,
SameSite: fiber.CookieSameSiteLaxMode,
})
}
// connectorBinding ties a provider type to the User's per-connector identity
// column: the EXACT lowercase orm/json field name to filter on and a pointer
// accessor to read/set the stored subject.
type connectorBinding struct {
field string
ref func(*schema.User) *string
}
// connectorRegistry maps a provider Type to its User connector column. The field
// name is the EXACT json/orm name (already lowercase): orm's filter lowercases
// only the FIRST rune, so a Go field name like "GitHub" would query '$.gitHub'
// (the tag is 'github') — passing the exact json name is the one correct way, and
// this registry is its single source of truth. Only the connectors iam can
// federate are listed; anything else fails closed.
var connectorRegistry = map[string]connectorBinding{
"google": {"google", func(u *schema.User) *string { return &u.Google }},
"github": {"github", func(u *schema.User) *string { return &u.GitHub }},
"gitlab": {"gitlab", func(u *schema.User) *string { return &u.Gitlab }},
"gitee": {"gitee", func(u *schema.User) *string { return &u.Gitee }},
"bitbucket": {"bitbucket", func(u *schema.User) *string { return &u.Bitbucket }},
"facebook": {"facebook", func(u *schema.User) *string { return &u.Facebook }},
"apple": {"apple", func(u *schema.User) *string { return &u.Apple }},
"linkedin": {"linkedin", func(u *schema.User) *string { return &u.LinkedIn }},
"discord": {"discord", func(u *schema.User) *string { return &u.Discord }},
"slack": {"slack", func(u *schema.User) *string { return &u.Slack }},
"okta": {"okta", func(u *schema.User) *string { return &u.Okta }},
"azuread": {"azuread", func(u *schema.User) *string { return &u.AzureAD }},
"microsoftonline": {"microsoftonline", func(u *schema.User) *string { return &u.MicrosoftOnline }},
}
// connectorFor resolves the connector binding for a provider type (case-folded),
// or (zero, false) when the type has no local identity column.
func connectorFor(providerType string) (connectorBinding, bool) {
b, ok := connectorRegistry[strings.ToLower(strings.TrimSpace(providerType))]
return b, ok
}
// federatedNameAttempts bounds the dedupe walk: the first free suffix wins, and a
// name that is still taken after this many tries means something is wrong with the
// derivation, not that the org is full.
const federatedNameAttempts = 32
// federatedUsername derives the username for a provisioned account from the email
// local part (schema.Handle), falling back to the provider name and then "user"
// when nothing usable survives. attempt 1 asks for the bare handle; each later
// attempt appends its number, so z@hanzo.ai becomes "z", then "z2", "z3" — a
// person gets the name they would have chosen, and the suffix appears only when it
// has to.
//
// It replaced a random 8-hex suffix on EVERY name ("z-3f9ab21c"), which made
// collisions impossible by making every name unrecognisable. Collisions are the
// caller's loop to handle; a username is meant to be typed and read.
func federatedUsername(email, providerType string, attempt int) string {
base := schema.Handle(email)
if base == "" {
// No usable address. The provider TYPE ("google", "github") is the only other
// value here that is not a human's name — the display name is deliberately
// never consulted, on any branch.
base, _ = schema.Username(providerType)
}
if base == "" {
base = "user"
}
if attempt > 1 {
base += strconv.Itoa(attempt)
}
return base
}
+92
View File
@@ -0,0 +1,92 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import "testing"
// The federation callback is not an internal detail. It is the redirect_uri iam
// hands every external IdP, and an IdP refuses any value it was not told about in
// advance. So this string is a CONTRACT held in two places at once: here, and in
// each provider's own console.
//
// Nothing in this package can observe the other half. When federation moved off
// Casdoor's `<iam host>/callback` to the canonical path below, the GitHub App's
// callback list was updated and Google's OAuth client was not — so Google refused
// sign-in on EVERY brand with `Error 400: redirect_uri_mismatch` while this suite
// stayed green, GitHub kept working, and the only report was a person who could
// not log in.
//
// ⚠️ ASSERT THROUGH resolveFederationOrigin, NEVER resolveIssuer. The two were one
// value until the origin was unbraided from the issuer; today, with no
// IAM_FEDERATION_ORIGIN set, the federation resolver FALLS BACK to the issuer, so
// both spellings pass and the wrong one is indistinguishable from the right one.
// The moment an origin is pinned — which is the entire point of that split — a
// test written against resolveIssuer keeps passing while the real callback moves.
// That is the exact false green this file exists to prevent, so it is worth the
// one line of care.
func TestFederationCallbackIsTheRegisteredContract(t *testing.T) {
installIssuerResolver(t, "https://hanzo.id", testIssuerMap)
for host, want := range map[string]string{
"hanzo.id": "https://hanzo.id/v1/iam/oauth/callback",
"iam.hanzo.ai": "https://hanzo.id/v1/iam/oauth/callback",
"lux.id": "https://lux.id/v1/iam/oauth/callback",
"iam.lux.network": "https://lux.id/v1/iam/oauth/callback",
"id.zoo.network": "https://id.zoo.network/v1/iam/oauth/callback",
"pars.id": "https://pars.id/v1/iam/oauth/callback",
} {
// The composition federationCallbackURL performs, through the same seam a
// live request takes.
if got := resolveFederationOrigin(host) + PathFederationCallback; got != want {
t.Errorf("federation callback for %s = %s, want %s\n"+
"If this change is intended, register the new URI with EVERY external IdP "+
"(the Google OAuth client AND the GitHub App) BEFORE shipping — each refuses "+
"any redirect_uri it does not already hold, and neither failure is visible from here.",
host, got, want)
}
}
}
// What a pinned origin WOULD buy, and why it is not on offer yet.
//
// I wrote this test asserting that every host of one org folds onto ONE callback,
// so a provider console holds one redirect_uri per org rather than one per brand
// host. That property is desirable and it is NOT reachable: the begin leg sets the
// `hanzo_fed` browser-binding cookie on the host that served it, host-only, and the
// callback refuses an empty cookie — so a callback on a different host is never
// given the cookie and every social sign-in on that brand fails closed. Asserting
// it here made a broken configuration look supported.
//
// InitFederationResolver now refuses that config at boot
// (TestFederationOriginCrossHostFoldIsRefusedAtBoot pins the refusal and its
// wording). What remains true, and what this pins, is that a SAME-HOST map is a
// no-op: each brand keeps its own callback, which is the list actually registered
// with Google and GitHub today.
func TestFederationCallbackPerBrandUnderASameHostMap(t *testing.T) {
t.Setenv("IAM_ISSUER", "https://hanzo.id")
t.Setenv("IAM_ISSUER_MAP", `{"hanzo.id":"https://hanzo.id","lux.id":"https://lux.id"}`)
t.Setenv("IAM_FEDERATION_ORIGIN", "https://hanzo.id")
t.Setenv("IAM_FEDERATION_ORIGIN_MAP", `{"hanzo.id":"https://hanzo.id","lux.id":"https://lux.id"}`)
prevIss, prevFed := activeResolver.Load(), activeFederationResolver.Load()
t.Cleanup(func() { activeResolver.Store(prevIss); activeFederationResolver.Store(prevFed) })
activeResolver.Store(nil)
activeFederationResolver.Store(nil)
if err := InitIssuerResolver(); err != nil {
t.Fatalf("InitIssuerResolver: %v", err)
}
if err := InitFederationResolver(); err != nil {
t.Fatalf("InitFederationResolver: %v", err)
}
for host, want := range map[string]string{
"hanzo.id": "https://hanzo.id/v1/iam/oauth/callback",
"lux.id": "https://lux.id/v1/iam/oauth/callback",
} {
if got := resolveFederationOrigin(host) + PathFederationCallback; got != want {
t.Errorf("callback for %s = %s, want %s — each brand keeps its own until the "+
"begin leg can set the cookie on a folded origin", host, got, want)
}
}
}
+751
View File
@@ -0,0 +1,751 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"syscall"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/iam/pkg/pkce"
"github.com/hanzoai/iam/pkg/schema"
)
// The Relying-Party side of federation: iam as an OIDC/OAuth2 CLIENT of an
// external identity provider. Two dialects, one contract (federatedIdentity):
//
// - OIDC (Google + any provider with an IssuerUrl): OIDC Discovery resolves the
// endpoints and JWKS; the end user is authenticated by the id_token, whose
// SIGNATURE (against the published JWKS), issuer, audience, expiry, and nonce
// are all verified before a single claim is trusted. email_verified is read
// from the signed token.
// - GitHub (OAuth2, no id_token): the code is exchanged for an access token,
// then the user + verified-email endpoints are read. Only a GitHub-verified,
// primary email is treated as verified.
//
// Every outbound call is hardened: a bounded-timeout client that never follows
// redirects (a 3xx on a token/JWKS endpoint is answered as a failure, not
// chased), a response-body size cap, an https-except-loopback URL guard, and
// alg-pinned JWT verification (RS/ES only — never `none`, never an HMAC that a
// public key could be abused as the secret for). No secret or token is logged.
// federatedIdentity is the VERIFIED identity an external IdP asserts about the
// end user — the only thing the broker trusts out of the round-trip. Subject is
// the IdP's stable, opaque user id (the connector-column value); Email is linked
// against a local account ONLY when EmailVerified is true.
type federatedIdentity struct {
subject string
email string
emailVerified bool
displayName string
avatar string
}
// federationHTTPClient is the hardened client every IdP call rides. The timeout
// bounds a slow/hostile IdP; CheckRedirect refuses to chase a redirect (an IdP
// token/userinfo/JWKS endpoint answering 3xx is a fault, not a hop), closing the
// SSRF-via-redirect vector; and the dialer Control refuses to connect to a
// private/loopback/link-local/metadata address AT DIAL TIME — after DNS
// resolution, on the ACTUAL connecting IP — so a hostile IssuerUrl/Custom*Url (or
// a DNS-rebinding hostname) cannot make iam reach an internal service or the
// cloud metadata endpoint.
var federationHTTPClient = &http.Client{
Timeout: 12 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
Control: federationDialControl,
}).DialContext,
TLSHandshakeTimeout: 8 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
MaxIdleConns: 8,
IdleConnTimeout: 30 * time.Second,
},
}
// federationDialAllowsPrivate relaxes the SSRF dial guard to permit
// private/loopback addresses. It is a TEST SEAM ONLY (the mock IdPs bind to
// 127.0.0.1); production code never sets it, so the guard is always fully armed
// in a real deployment.
var federationDialAllowsPrivate = false
// federationDialControl is the net.Dialer.Control hook: it inspects the resolved
// address every connection actually dials and refuses a private, loopback,
// link-local, ULA, unspecified, multicast, or CGNAT target — the SSRF gate that a
// literal-URL check cannot provide because it sees the post-DNS IP (defeating
// DNS-rebinding). Fails closed on an unparseable address.
func federationDialControl(_, address string, _ syscall.RawConn) error {
if federationDialAllowsPrivate {
return nil
}
host, _, err := net.SplitHostPort(address)
if err != nil {
return errors.New("federation: refusing an unparseable dial address")
}
ip := net.ParseIP(host)
if ip == nil {
return errors.New("federation: dial host did not resolve to an IP")
}
if ipBlockedForFederation(ip) {
return errors.New("federation: refusing to dial a private/loopback/link-local address")
}
return nil
}
// ipBlockedForFederation reports whether an IP is in a range iam must never
// fetch from during federation. net.IP.IsPrivate covers RFC1918 and IPv6 ULA
// (fc00::/7); IsLinkLocalUnicast covers 169.254.0.0/16 (incl. the 169.254.169.254
// cloud-metadata address) and fe80::/10.
func ipBlockedForFederation(ip net.IP) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() || ip.IsMulticast() || isCGNAT(ip)
}
// isCGNAT reports whether ip is in 100.64.0.0/10 (carrier-grade NAT), a shared
// range net.IP.IsPrivate does not cover.
func isCGNAT(ip net.IP) bool {
v4 := ip.To4()
return v4 != nil && v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127
}
// maxIdPBodyBytes caps every IdP response read — a hostile or broken IdP cannot
// exhaust memory (discovery/JWKS/token/userinfo are all a few KB).
const maxIdPBodyBytes = 1 << 20 // 1 MiB
// defaultGoogleIssuer is the OIDC issuer for a Google provider that pins none of
// its own — the value the id_token carries as `iss` and the discovery origin.
const defaultGoogleIssuer = "https://accounts.google.com"
// GitHub's fixed OAuth2 endpoints (overridable per-Provider for GitHub
// Enterprise / tests via Custom{Auth,Token,UserInfo}Url).
const (
githubAuthorizeEndpoint = "https://github.com/login/oauth/authorize"
githubTokenEndpoint = "https://github.com/login/oauth/access_token"
githubUserEndpoint = "https://api.github.com/user"
)
// idpKind classifies a provider into its federation dialect. A provider with an
// explicit OIDC issuer — or Google — is OIDC; GitHub is OAuth2+userinfo.
// Anything else is unsupported and fails closed (""), never guessed.
func idpKind(p *schema.Provider) string {
switch {
case strings.EqualFold(p.Type, "GitHub"):
return "github"
case strings.EqualFold(p.Type, "Google") || strings.TrimSpace(p.IssuerUrl) != "":
return "oidc"
default:
return ""
}
}
// idpAuthorizeURL builds the IdP authorization-endpoint URL the browser is sent
// to at the begin leg — dialect-dispatched, with iam's callback as the IdP
// redirect_uri, our single-use state, IdP-leg PKCE, and (OIDC) the nonce.
func idpAuthorizeURL(ctx context.Context, p *schema.Provider, st *schema.FederationState, callback string) (string, error) {
switch idpKind(p) {
case "oidc":
cfg, err := oidcResolve(ctx, p)
if err != nil {
return "", err
}
return oidcAuthorizeURL(cfg, p, st, callback), nil
case "github":
return githubAuthorizeURL(p, st, callback), nil
default:
return "", fmt.Errorf("federation: provider %q is not a supported federation type", p.Name)
}
}
// idpExchange completes the callback leg: it exchanges the IdP authorization
// code and returns the VERIFIED identity, or an error if any verification fails.
func idpExchange(ctx context.Context, p *schema.Provider, st *schema.FederationState, code, callback string, now time.Time) (federatedIdentity, error) {
switch idpKind(p) {
case "oidc":
cfg, err := oidcResolve(ctx, p)
if err != nil {
return federatedIdentity{}, err
}
return oidcExchange(ctx, cfg, p, st, code, callback, now)
case "github":
return githubExchange(ctx, p, st, code, callback)
default:
return federatedIdentity{}, fmt.Errorf("federation: provider %q is not a supported federation type", p.Name)
}
}
// --- OIDC dialect (Google + any IssuerUrl provider) ---
// oidcConfig is the resolved OIDC endpoint set for a provider.
type oidcConfig struct {
issuer string
authURL string
tokenURL string
jwksURL string
}
// oidcResolve determines the issuer and runs OIDC Discovery to fill the endpoint
// set. A per-Provider Custom{Auth,Token}Url overrides the discovered
// authorize/token endpoint (a provider that publishes discovery but pins a
// vanity endpoint); the JWKS URI always comes from the signed discovery document
// so id_token verification keys are never attacker-chosen.
func oidcResolve(ctx context.Context, p *schema.Provider) (oidcConfig, error) {
issuer := strings.TrimRight(strings.TrimSpace(p.IssuerUrl), "/")
if issuer == "" && strings.EqualFold(p.Type, "Google") {
issuer = defaultGoogleIssuer
}
if issuer == "" {
return oidcConfig{}, errors.New("federation: OIDC provider has no issuerUrl")
}
disco, err := oidcDiscover(ctx, issuer)
if err != nil {
return oidcConfig{}, err
}
cfg := oidcConfig{
issuer: issuer,
authURL: disco.AuthorizationEndpoint,
tokenURL: disco.TokenEndpoint,
jwksURL: disco.JwksURI,
}
if v := strings.TrimSpace(p.CustomAuthUrl); v != "" {
cfg.authURL = v
}
if v := strings.TrimSpace(p.CustomTokenUrl); v != "" {
cfg.tokenURL = v
}
if cfg.authURL == "" || cfg.tokenURL == "" || cfg.jwksURL == "" {
return oidcConfig{}, errors.New("federation: OIDC discovery is missing required endpoints")
}
return cfg, nil
}
// oidcDiscoveryDocument is the subset of the OIDC Discovery document iam reads.
type oidcDiscoveryDocument struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
JwksURI string `json:"jwks_uri"`
}
// oidcDiscover fetches and validates the issuer's discovery document. The
// document's own `issuer` MUST equal the configured issuer (OIDC Discovery §4.3)
// — a mismatch means the origin is impersonating another issuer, so it fails
// closed.
func oidcDiscover(ctx context.Context, issuer string) (oidcDiscoveryDocument, error) {
var doc oidcDiscoveryDocument
if err := getJSON(ctx, issuer+"/.well-known/openid-configuration", &doc); err != nil {
return doc, err
}
if strings.TrimRight(doc.Issuer, "/") != strings.TrimRight(issuer, "/") {
return oidcDiscoveryDocument{}, fmt.Errorf("federation: discovery issuer mismatch")
}
return doc, nil
}
// oidcAuthorizeURL builds the OIDC authorization request: response_type=code,
// the app-or-default scope, our callback, the single-use state, S256 PKCE, and
// the nonce that the returned id_token must echo.
func oidcAuthorizeURL(cfg oidcConfig, p *schema.Provider, st *schema.FederationState, callback string) string {
v := url.Values{}
v.Set("response_type", "code")
v.Set("client_id", p.ClientId)
v.Set("redirect_uri", callback)
// The OIDC leg MUST request openid, or the IdP returns no id_token and the
// exchange fails closed — force it in even if the provider's configured scopes
// omit it, so a scope misconfiguration can never silently disable verification.
v.Set("scope", ensureOpenID(providerScopes(p, "openid email profile")))
v.Set("state", st.Name)
v.Set("nonce", st.IdpNonce)
v.Set("code_challenge", pkce.Challenge(st.IdpVerifier))
v.Set("code_challenge_method", "S256")
return joinQuery(cfg.authURL, v)
}
// oidcTokenResponse is the token-endpoint response the OIDC exchange reads.
type oidcTokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
}
// oidcExchange redeems the code at the token endpoint (proving the IdP-leg PKCE
// verifier), then VERIFIES the id_token — signature against the discovered JWKS,
// issuer, audience (== our client id), expiry, and nonce — before trusting any
// claim. The identity comes from the signed id_token, never from an unverified
// userinfo body.
func oidcExchange(ctx context.Context, cfg oidcConfig, p *schema.Provider, st *schema.FederationState, code, callback string, now time.Time) (federatedIdentity, error) {
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", callback)
form.Set("client_id", p.ClientId)
form.Set("client_secret", p.ClientSecret)
form.Set("code_verifier", st.IdpVerifier)
var tr oidcTokenResponse
if err := postFormJSON(ctx, cfg.tokenURL, form, nil, &tr); err != nil {
return federatedIdentity{}, err
}
if tr.IDToken == "" {
return federatedIdentity{}, errors.New("federation: OIDC token response carried no id_token")
}
claims, err := verifyIDToken(ctx, tr.IDToken, cfg.jwksURL, cfg.issuer, p.ClientId, st.IdpNonce, now)
if err != nil {
return federatedIdentity{}, err
}
return federatedIdentity{
subject: claims.Subject,
email: strings.ToLower(strings.TrimSpace(claims.Email)),
emailVerified: truthy(claims.EmailVerified),
displayName: claims.Name,
avatar: claims.Picture,
}, nil
}
// idTokenClaims is the id_token claim set iam reads. Nonce is a top-level OIDC
// claim (not a registered JWT claim), verified against the transaction's stored
// nonce. email_verified is `any` because providers send it as a JSON bool or
// (legacy) the string "true".
type idTokenClaims struct {
jwt.RegisteredClaims
Nonce string `json:"nonce"`
Email string `json:"email"`
EmailVerified any `json:"email_verified"`
Name string `json:"name"`
Picture string `json:"picture"`
}
// verifyIDToken parses and fully validates an id_token. The signing method is
// PINNED to the asymmetric set (RS/ES) so a `none` token or an HMAC-with-public-
// key confusion attack is rejected outright; the key comes from the issuer's
// JWKS, selected by `kid`; issuer, audience, and expiry are enforced by the
// parser (now is injected for testability); and the nonce is compared in
// constant time. A subject-less token is refused.
func verifyIDToken(ctx context.Context, idToken, jwksURL, issuer, audience, nonce string, now time.Time) (idTokenClaims, error) {
var claims idTokenClaims
tok, err := jwt.ParseWithClaims(idToken, &claims, jwksKeyfunc(ctx, jwksURL),
jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "ES256", "ES384", "ES512"}),
jwt.WithIssuer(issuer),
jwt.WithAudience(audience),
jwt.WithExpirationRequired(),
jwt.WithTimeFunc(func() time.Time { return now }),
)
if err != nil || !tok.Valid {
return idTokenClaims{}, fmt.Errorf("federation: id_token verification failed")
}
if subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(nonce)) != 1 {
return idTokenClaims{}, errors.New("federation: id_token nonce mismatch")
}
if strings.TrimSpace(claims.Subject) == "" {
return idTokenClaims{}, errors.New("federation: id_token has no subject")
}
return claims, nil
}
// --- GitHub dialect (OAuth2 + userinfo) ---
// githubAuthorizeURL builds GitHub's OAuth2 authorization request. GitHub OAuth
// Apps support neither PKCE nor a nonce, so the single-use, browser-bound state
// carries the CSRF defense; PKCE is added only when the provider opts in
// (EnablePkce) for a compatible deployment.
func githubAuthorizeURL(p *schema.Provider, st *schema.FederationState, callback string) string {
v := url.Values{}
v.Set("client_id", p.ClientId)
v.Set("redirect_uri", callback)
v.Set("scope", providerScopes(p, "read:user user:email"))
v.Set("state", st.Name)
v.Set("allow_signup", "true")
if p.EnablePkce {
v.Set("code_challenge", pkce.Challenge(st.IdpVerifier))
v.Set("code_challenge_method", "S256")
}
return joinQuery(firstNonEmpty(p.CustomAuthUrl, githubAuthorizeEndpoint), v)
}
// githubTokenResponse is GitHub's (JSON, via Accept) token response.
type githubTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
Error string `json:"error"`
}
// githubUser / githubEmail are the userinfo shapes iam reads.
type githubUser struct {
ID int64 `json:"id"`
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
}
type githubEmail struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
}
// githubExchange redeems the code for an access token, then reads the user and
// the verified-email list. The subject is GitHub's immutable numeric id; an
// email is treated as verified ONLY when GitHub reports it verified (primary
// preferred), so a local account is never linked to an unproven address.
func githubExchange(ctx context.Context, p *schema.Provider, st *schema.FederationState, code, callback string) (federatedIdentity, error) {
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", callback)
form.Set("client_id", p.ClientId)
form.Set("client_secret", p.ClientSecret)
if p.EnablePkce {
form.Set("code_verifier", st.IdpVerifier)
}
var tr githubTokenResponse
if err := postFormJSON(ctx, firstNonEmpty(p.CustomTokenUrl, githubTokenEndpoint), form, http.Header{"Accept": {"application/json"}}, &tr); err != nil {
return federatedIdentity{}, err
}
if tr.Error != "" || tr.AccessToken == "" {
return federatedIdentity{}, errors.New("federation: GitHub token exchange failed")
}
userURL := firstNonEmpty(p.CustomUserInfoUrl, githubUserEndpoint)
var gu githubUser
if err := getJSONBearer(ctx, userURL, tr.AccessToken, &gu); err != nil {
return federatedIdentity{}, err
}
if gu.ID == 0 {
return federatedIdentity{}, errors.New("federation: GitHub user has no id")
}
email, verified := githubPrimaryEmail(ctx, userURL, tr.AccessToken, gu.Email)
name := gu.Name
if name == "" {
name = gu.Login
}
return federatedIdentity{
subject: strconv.FormatInt(gu.ID, 10),
email: strings.ToLower(strings.TrimSpace(email)),
emailVerified: verified,
displayName: name,
avatar: gu.AvatarURL,
}, nil
}
// githubPrimaryEmail resolves the address to link on: the GitHub /user/emails
// list's primary-and-verified entry (then any verified entry). It returns
// (email, verified); when nothing is verified it returns verified=false so the
// broker provisions a fresh account rather than link by an unproven email. A
// failure to read the list is not fatal — it degrades to unverified.
func githubPrimaryEmail(ctx context.Context, userURL, token, fallback string) (string, bool) {
var emails []githubEmail
if err := getJSONBearer(ctx, strings.TrimRight(userURL, "/")+"/emails", token, &emails); err == nil {
var anyVerified string
for _, e := range emails {
if !e.Verified {
continue
}
if e.Primary {
return e.Email, true
}
if anyVerified == "" {
anyVerified = e.Email
}
}
if anyVerified != "" {
return anyVerified, true
}
}
// No verified address available — the profile email is unproven.
return fallback, false
}
// --- hardened HTTP + JWKS ---
// getJSON GETs a URL and decodes a JSON body, with the safety guard, a 200-only
// contract, and a body-size cap.
func getJSON(ctx context.Context, rawURL string, out any) error {
req, err := newIdPRequest(ctx, http.MethodGet, rawURL, nil, nil)
if err != nil {
return err
}
return doJSON(req, out)
}
// getJSONBearer GETs a bearer-authenticated JSON endpoint (GitHub userinfo).
func getJSONBearer(ctx context.Context, rawURL, token string, out any) error {
req, err := newIdPRequest(ctx, http.MethodGet, rawURL, nil, http.Header{
"Authorization": {"Bearer " + token},
"Accept": {"application/vnd.github+json"},
})
if err != nil {
return err
}
return doJSON(req, out)
}
// postFormJSON POSTs a urlencoded form and decodes a JSON body.
func postFormJSON(ctx context.Context, rawURL string, form url.Values, header http.Header, out any) error {
req, err := newIdPRequest(ctx, http.MethodPost, rawURL, strings.NewReader(form.Encode()), header)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if req.Header.Get("Accept") == "" {
req.Header.Set("Accept", "application/json")
}
return doJSON(req, out)
}
// newIdPRequest builds a context-bound request to a guard-checked URL with a
// stable User-Agent and the caller's headers.
func newIdPRequest(ctx context.Context, method, rawURL string, body io.Reader, header http.Header) (*http.Request, error) {
safe, err := requireSafeURL(rawURL)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, method, safe, body)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "hanzo-iam-federation")
for k, vs := range header {
for _, v := range vs {
req.Header.Add(k, v)
}
}
return req, nil
}
// doJSON executes a request and decodes a 200 JSON body under the size cap. A
// non-200 status is a hard failure — no partial trust in an error body.
func doJSON(req *http.Request, out any) error {
resp, err := federationHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("federation: idp request failed")
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxIdPBodyBytes))
if err != nil {
return fmt.Errorf("federation: reading idp response failed")
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("federation: idp returned status %d", resp.StatusCode)
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("federation: decoding idp response failed")
}
return nil
}
// requireSafeURL parses rawURL and enforces the transport guard: http(s) only,
// a non-empty host, and https EXCEPT for loopback (so the production path is
// always TLS while tests may target 127.0.0.1). This also rejects file://, and
// any non-web scheme — an SSRF/exfiltration hygiene gate on the (admin-supplied)
// endpoint configuration.
func requireSafeURL(rawURL string) (string, error) {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return "", fmt.Errorf("federation: invalid idp url")
}
if u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return "", fmt.Errorf("federation: idp url must be http(s) with a host")
}
if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) {
return "", fmt.Errorf("federation: idp url must use https")
}
return u.String(), nil
}
// isLoopbackHost reports whether host is a loopback name/address.
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
}
if ip := net.ParseIP(host); ip != nil {
return ip.IsLoopback()
}
return false
}
// jwkSet / jwk are the JSON Web Key Set shapes iam verifies id_tokens against.
type jwkSet struct {
Keys []jwk `json:"keys"`
}
type jwk struct {
Kty string `json:"kty"`
Kid string `json:"kid"`
N string `json:"n"`
E string `json:"e"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
}
// jwksKeyfunc returns a jwt.Keyfunc that fetches the issuer's JWKS and selects
// the verification key by the token's `kid`. When the token carries a kid, an
// exact match is required; a kid-less token is accepted only against a
// single-key set. The fetch happens inside the closure so it is bounded by the
// same hardened client and request context.
func jwksKeyfunc(ctx context.Context, jwksURL string) jwt.Keyfunc {
return func(t *jwt.Token) (any, error) {
var set jwkSet
if err := getJSON(ctx, jwksURL, &set); err != nil {
return nil, err
}
kid, _ := t.Header["kid"].(string)
if kid == "" {
if len(set.Keys) != 1 {
return nil, errors.New("federation: id_token has no kid and JWKS is not single-key")
}
return set.Keys[0].publicKey()
}
for _, k := range set.Keys {
if k.Kid == kid {
return k.publicKey()
}
}
return nil, errors.New("federation: no JWKS key matches the id_token kid")
}
}
// publicKey materializes a JWK into a crypto public key (RSA or EC). Only the
// two families iam signs with are supported; any other key type is refused.
func (k jwk) publicKey() (any, error) {
switch k.Kty {
case "RSA":
n, err := b64uBigInt(k.N)
if err != nil {
return nil, err
}
eb, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(k.E, "="))
if err != nil {
return nil, errors.New("federation: bad JWKS RSA exponent")
}
e := 0
for _, b := range eb {
e = e<<8 | int(b)
}
if e == 0 {
return nil, errors.New("federation: zero JWKS RSA exponent")
}
return &rsa.PublicKey{N: n, E: e}, nil
case "EC":
curve, err := ecCurve(k.Crv)
if err != nil {
return nil, err
}
x, err := b64uBigInt(k.X)
if err != nil {
return nil, err
}
y, err := b64uBigInt(k.Y)
if err != nil {
return nil, err
}
return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil
default:
return nil, fmt.Errorf("federation: unsupported JWKS key type %q", k.Kty)
}
}
// ecCurve maps a JWK curve name to its elliptic.Curve.
func ecCurve(crv string) (elliptic.Curve, error) {
switch crv {
case "P-256":
return elliptic.P256(), nil
case "P-384":
return elliptic.P384(), nil
case "P-521":
return elliptic.P521(), nil
default:
return nil, fmt.Errorf("federation: unsupported JWKS curve %q", crv)
}
}
// b64uBigInt decodes a base64url (unpadded) big-endian integer — the JWK
// encoding for RSA modulus/exponent and EC coordinates.
func b64uBigInt(s string) (*big.Int, error) {
b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "="))
if err != nil {
return nil, errors.New("federation: bad JWKS integer encoding")
}
return new(big.Int).SetBytes(b), nil
}
// --- small shared helpers ---
// providerScopes returns the provider's configured scopes, or a dialect default
// when it configures none.
func providerScopes(p *schema.Provider, fallback string) string {
if s := strings.TrimSpace(p.Scopes); s != "" {
return s
}
return fallback
}
// ensureOpenID guarantees the space-delimited scope contains "openid" (the OIDC
// requirement for an id_token), prepending it when absent.
func ensureOpenID(scope string) string {
for _, s := range strings.Fields(scope) {
if s == "openid" {
return scope
}
}
return strings.TrimSpace("openid " + scope)
}
// joinQuery appends encoded query values to a base URL, honoring an existing
// query string.
func joinQuery(base string, v url.Values) string {
sep := "?"
if strings.Contains(base, "?") {
sep = "&"
}
return base + sep + v.Encode()
}
// firstNonEmpty returns the first non-blank string.
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// truthy interprets an id_token email_verified value (bool or string form).
func truthy(v any) bool {
switch t := v.(type) {
case bool:
return t
case string:
return strings.EqualFold(t, "true")
default:
return false
}
}

Some files were not shown because too many files have changed in this diff Show More