Compare commits

...
46 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
284 changed files with 9656 additions and 1439 deletions
-51
View File
@@ -1,51 +0,0 @@
name: sync
# The ONLY GitHub Action this repo is allowed to run.
#
# 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, and platform.hanzo.ai / hanzo-cd for rollout. GitHub Actions must
# never build, never push 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 -> git.hanzo.ai. 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.
on:
push:
branches: ['**']
tags: ['**']
workflow_dispatch:
concurrency:
group: sync-${{ github.ref }}
cancel-in-progress: false
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout (full history + tags — a shallow push loses commits)
uses: actions/checkout@v4
with:
fetch-depth: 0
filter: tree:0
- name: Push to git.hanzo.ai
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
FORGE_USER: ${{ vars.FORGE_USER || 'hanzo-dev' }}
run: |
set -euo pipefail
if [ -z "${FORGE_TOKEN:-}" ]; then
echo "::error::FORGE_TOKEN is not set — refusing to sync silently."
exit 1
fi
# Credential stays in the helper, never in a remote URL: a token baked
# into .git/config leaks on any `git remote -v`.
git config --global credential.helper store
printf 'https://%s:%s@git.hanzo.ai\n' "$FORGE_USER" "$FORGE_TOKEN" > ~/.git-credentials
chmod 600 ~/.git-credentials
git remote add forge "https://git.hanzo.ai/${GITHUB_REPOSITORY}.git" 2>/dev/null || \
git remote set-url forge "https://git.hanzo.ai/${GITHUB_REPOSITORY}.git"
git push forge --follow-tags "HEAD:${GITHUB_REF}"
rm -f ~/.git-credentials
+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
+37 -9
View File
@@ -1,7 +1,7 @@
name: image
# THE builder for ghcr.io/hanzoai/iam from main, on Hanzo's own forge.
#
# git.hanzo.ai push (incl. mirror-sync from GitHub) → act_runner
# 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
@@ -114,13 +114,36 @@ jobs:
# 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*) tag="${GITHUB_REF#refs/tags/}"; push=true ;;
*) tag="ghcr.io/hanzoai/iam:unpublished"; push=false ;;
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 "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "image=$image" >> "$GITHUB_OUTPUT"
echo "push=$push" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
@@ -141,15 +164,15 @@ jobs:
platforms: linux/amd64
push: ${{ steps.meta.outputs.push }}
provenance: false
tags: ${{ startsWith(github.ref, 'refs/tags/v') && format('ghcr.io/hanzoai/iam:{0}', steps.meta.outputs.tag) || steps.meta.outputs.tag }}
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 SAME string used as the image tag: `iam sha-d2aa268` then states
# exactly which artifact is running, with no second identifier to drift.
# 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.tag }}
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.
@@ -159,10 +182,15 @@ jobs:
# 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="ghcr.io/hanzoai/iam:${{ steps.meta.outputs.tag }}"
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
+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
+1 -1
View File
@@ -1,4 +1,4 @@
# Hanzo IAM — proprietary identity service (zip + orm).
# 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.
+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 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.
+74 -6
View File
@@ -2,9 +2,28 @@
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. Proprietary,
clean-room rewrite on the Hanzo stack (`zip` over `hanzoai/orm`) — no the legacy surface,
Beego, or xorm. Retired the legacy surface fork = `hanzoai/iam-v1` (do not use).
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
@@ -55,7 +74,9 @@ 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/scim/v2/Users`. PKCE `S256` always; `client_id` = `<org>-<app>`.
`/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).
@@ -224,8 +245,15 @@ for redaction. The secret is revealed ONCE, by `create`. `capFor("keys")` =
manages.
`MintUserKey` writes a `schema.Key` ROW because that is the only thing the resolvers
read. Stamping it on `schema.User.AccessKey` authenticated nobody AND overwrote the
holder's working legacy `hk-`, locking them out with no path back through the UI.
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
@@ -321,6 +349,46 @@ issued to and a refresh token was being presented under a different id.
- `internal/{oidc,routes}` — OAuth2/OIDC surface; `internal/{scim,mfa,webauthn,providers,sessions,tokens,cred,authz,certs,keys}`.
- `internal/{users,organizations,applications,roles,permission,memberships}` — entities; `pkg/model`, `pkg/store`; `MIGRATION.md` (RFC surface + phases).
## 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
+2 -2
View File
@@ -2,7 +2,7 @@
**Identity & access for the Hanzo cloud — OpenID Connect / OAuth2 with PKCE, standards only.**
![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-proprietary-lightgrey)
![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,
@@ -114,7 +114,7 @@ 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
+5 -5
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// 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
@@ -14,10 +15,9 @@
// 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. The Guard is mounted on the prefixes
// IAM owns (internal/routes.guardedPrefixes), not on the app, so a module that
// registers outside them is unauthenticated — the one thing a Feature must get
// right on its own.
// 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 (
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package feature_test
import (
+26 -17
View File
@@ -19,8 +19,8 @@ retract [v1.0.0, v1.31.37] // Casdoor lineage; moved to github.com/hanzoai/iam-v
require (
github.com/hanzoai/orm v0.6.16
github.com/spf13/cobra v1.10.2
github.com/zap-proto/zip v1.18.22
golang.org/x/crypto v0.53.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 `iam compare`
@@ -35,9 +35,9 @@ 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.0
github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518
github.com/hanzoai/account v0.2.1
github.com/luxfi/crypto v1.20.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
@@ -48,6 +48,7 @@ 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
@@ -58,11 +59,12 @@ require (
github.com/gofiber/utils/v2 v2.0.4 // 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.0 // indirect
github.com/hanzoai/sqlite v0.3.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
@@ -71,24 +73,26 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/luxfi/accel v1.2.4 // indirect
github.com/luxfi/cache v1.2.1 // indirect
github.com/luxfi/codec v1.1.4 // indirect
github.com/luxfi/container v0.0.4 // indirect
github.com/luxfi/ids v1.2.10 // 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.4.1 // indirect
github.com/luxfi/math v1.5.1 // indirect
github.com/luxfi/math/big v0.1.0 // indirect
github.com/luxfi/metric v1.5.7 // 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-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.20220614013038-64ee5596c38a // 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/zap-proto/go v1.3.0 // indirect
@@ -96,11 +100,16 @@ require (
go.uber.org/atomic v1.11.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.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.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.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.51.0 // indirect
)
+86 -48
View File
@@ -12,6 +12,8 @@ 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=
@@ -70,10 +72,14 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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=
@@ -84,14 +90,16 @@ 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.0 h1:V9gKG3ZltN2ZCteDrOnXWfOeEe/YDhhUm9AorQEAuBo=
github.com/hanzoai/sqlcipher v0.1.0/go.mod h1:F0soUYM1i4sawOZUpRvVnWoUayPbeGVlGq01VXy9Aqg=
github.com/hanzoai/sqlite v0.3.2 h1:B/TRunlIDZECEypmr6rHeNyWf37YZXvPJHBDEFMKn/g=
github.com/hanzoai/sqlite v0.3.2/go.mod h1:a3llsefKbu2Iq/0rJ1mlWCaU2t2cXh+aze85x+oW72k=
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=
@@ -110,32 +118,39 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh
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.2.1 h1:kAzOS55/hmYeNKR+0HAKv4ma48Y6JjkI8UQeqdZ8bfI=
github.com/luxfi/cache v1.2.1/go.mod h1:co7JTxZZHpKT31Yh01LFp5aZOxmoUg157FhBLQdQHVU=
github.com/luxfi/codec v1.1.4 h1:Yl8ZalMNkqo7cD6R9AjczAajkLOmsjyZ9+DASVYHrvg=
github.com/luxfi/codec v1.1.4/go.mod h1:oGQ3j6E8c2P0pL0irYtWkrB1hmDUFIE0puXHK4gV5KI=
github.com/luxfi/container v0.0.4 h1:BXhF82WyfqVP5mjlNcr7tP0Fcnvl0Ap1rkiu+rq5XuM=
github.com/luxfi/container v0.0.4/go.mod h1:Z3SpmMF5d4t77MM0nHYXURpn+EMVaeu1fhbd/3BGaek=
github.com/luxfi/crypto v1.20.1 h1:d0/jW7vVVQZbeGJNVmtMKkrhjTM6BtqEOWH234iUghM=
github.com/luxfi/crypto v1.20.1/go.mod h1:bLCBuIV/KDjPytld7jSYe1WbfWknPQXcivq88Qo96QU=
github.com/luxfi/ids v1.2.10 h1:f1WILZE199ayMuqnEyB2WP1qfMZkmozOQXSVYtB3e5k=
github.com/luxfi/ids v1.2.10/go.mod h1:QBIwy3OHvrtskbUqKh1+OYRa6PsyR7f7oNX33sOfK7w=
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.4.1 h1:1t9bCCsEqnl9yIKrShlbs80DBKyYTWdnzkVfBqEeO7Q=
github.com/luxfi/math v1.4.1/go.mod h1:QvbRxauQyE1w4lvbcLSe6c8yeJz2Zj1Bq1rayGgs2tA=
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/metric v1.5.7 h1:LoSPEUpak2SLcynF+LT2cXjl9ECp4nY+Lia9zudmDv4=
github.com/luxfi/metric v1.5.7/go.mod h1:CMguEhyuLi4YUWyXimJ+UHply99BDFrL0pxedB7rBqM=
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-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=
@@ -158,11 +173,15 @@ 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/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=
@@ -179,8 +198,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
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.20220614013038-64ee5596c38a h1:1ur3QoCqvE5fl+nylMaIr9PVV1w343YRDtsy+Rwu7XI=
github.com/syndtr/goleveldb v1.0.1-0.20220614013038-64ee5596c38a/go.mod h1:RRCYJbIwD5jmqPI9XoAFR0OcDxqUctll6zUj/+B4S48=
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=
@@ -199,18 +218,12 @@ 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.3.1 h1:A2rCPWYCX866eAsdiWuns0dvWnBmViZtGm4pwX7jwlY=
github.com/zap-proto/http v0.3.1/go.mod h1:UYfGhDDCetgxs65XSev8Lpf65COg5vKQK+cWwZGh4zQ=
github.com/zap-proto/zip v1.18.16 h1:7ZWwS07NE4qUJlJcvwViOgI4Ol29gtv1jPPFyy5CreA=
github.com/zap-proto/zip v1.18.16/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
github.com/zap-proto/zip v1.18.18 h1:oeDshCFqECBYDeIp9yFSP4wybo4oI8tzUt1L240N3v0=
github.com/zap-proto/zip v1.18.18/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
github.com/zap-proto/zip v1.18.19 h1:MAVs2bK1IGG0+rxJznBK4JbnhPh4J/Ldpoy9b+Xdx38=
github.com/zap-proto/zip v1.18.19/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
github.com/zap-proto/zip v1.18.20 h1:sP4BsrrhCpw4OVFRlO6a6fs0mjIsBeQyeNL+pJkBOVY=
github.com/zap-proto/zip v1.18.20/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
github.com/zap-proto/zip v1.18.21 h1:h90f1DReRU2yAigoAPjSM6BFX0gRbata2TDVNmaeKbM=
github.com/zap-proto/zip v1.18.21/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
github.com/zap-proto/zip v1.18.22 h1:lTQeI1+uIiZpCCWkV2N3ti6mPTfO1CI2oTj2b4DFLtk=
github.com/zap-proto/zip v1.18.22/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
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=
@@ -223,10 +236,11 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
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.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
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=
@@ -235,6 +249,8 @@ 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=
@@ -251,12 +267,13 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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=
@@ -273,8 +290,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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=
@@ -287,10 +304,11 @@ 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.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
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=
@@ -325,11 +343,31 @@ 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/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
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=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
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.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/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA=
modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
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.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=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+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
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// 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
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package applications
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package applications
import (
+36 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// 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
@@ -147,6 +148,9 @@ func (h *Handler) Create(ctx context.Context, in *Input) (*schema.AuditLog, erro
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")
@@ -181,6 +185,15 @@ func (h *Handler) Update(ctx context.Context, in *Input) (*schema.AuditLog, erro
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())
@@ -198,12 +211,34 @@ func (h *Handler) Delete(ctx context.Context, in *Ref) (*DeleteOutput, error) {
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) {
+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)
}
}
}
+73 -19
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// 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
@@ -420,18 +421,27 @@ func legacyVerb(path string) bool {
return false
}
// Guard is the AUTHENTICATION middleware. Route it via app.Use AFTER the public
// group and BEFORE the authed routes: the public (pre-authentication) routes are
// registered first, so a matched public route terminates fiber's middleware walk
// and the Guard never runs on it — public vs gated is decided structurally, by
// which group a route is registered on, not by an allow-list. Every route the
// Guard does wrap — the typed CRUD handlers and the framework's /mcp and /openapi
// surfaces alike — 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.
// 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
@@ -468,10 +478,49 @@ func Guard(db orm.DB) zip.Handler {
}
}
// Authorize is the AUTHORIZATION hook, installed via app.Authorize so 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
// 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.
//
@@ -481,9 +530,14 @@ func Guard(db orm.DB) zip.Handler {
// arguments DO decode a target into In), is authorized against authorize().
//
// Every typed op is authed by construction — the public surface is raw handlers
// in the pre-Guard group, none of which is a typed op — so this hook needs no
// 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, before the op; over MCP, on the gated /mcp route).
// 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) {
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz_test
+8 -4
View File
@@ -1,10 +1,12 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// 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 after the public group, so gating is
// structural — the public routes registered before it are never reached by it).
// (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
@@ -142,7 +144,9 @@ func newHarness(t *testing.T) *harness {
// 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.
app.Prepare()
if err := app.Build(); err != nil {
t.Fatalf("build: %v", err)
}
return &harness{app: app, key: key, db: db}
}
+3 -2
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz
@@ -69,7 +70,7 @@ func TestSuperIsAdminOrgOnly(t *testing.T) {
// 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 before the Guard, everything else after it). The boundary is
// (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
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz_test
+3 -2
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz
@@ -58,7 +59,7 @@ var (
// 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 (hk-/sk-) to its owning
// 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
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz
import "testing"
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz_test
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz_test
import (
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz
import "testing"
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz_test
+274 -99
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// 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
@@ -13,13 +14,22 @@
// 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"
"errors"
"fmt"
"strings"
"time"
@@ -33,19 +43,122 @@ import (
"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).
func Route(r zip.Router, db orm.DB) {
r.Post("/v1/iam/admin/applications/upsert", upsertApplication(db))
r.Post("/v1/iam/admin/users/upsert", upsertUser(db))
//
// 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"))
}
func unauthorized(c *zip.Ctx) error {
return c.JSON(401, map[string]any{"status": "error", "msg": "a valid service token is required"})
// 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
}
// appUpsertReq is the operator's application upsert body (operator-core UpsertRequest).
type appUpsertReq struct {
// 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"`
@@ -88,6 +201,27 @@ type appUpsertReq struct {
// 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
@@ -97,91 +231,104 @@ type appUpsertReq struct {
// 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.Handler {
return func(c *zip.Ctx) error {
if !httpx.ServiceTokenAuth(c) {
return unauthorized(c)
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
}
ctx := c.Context()
var req appUpsertReq
if err := decode(c, &req); err != nil {
return c.JSON(400, errResp("invalid body: "+err.Error()))
if bad := in.check(); bad != nil {
return bad, nil
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
return c.JSON(400, errResp("name is required"))
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
return refuse(400, "name is required"), nil
}
existing, err := store.GetApplicationByName(ctx, db, "admin", req.Name)
existing, err := store.GetApplicationByName(ctx, db, "admin", in.Name)
if err != nil {
return c.JSON(500, errResp("server_error"))
return refuse(500, "server_error"), nil
}
var existingSecret string
if existing != nil {
existingSecret = existing.ClientSecret
}
req.ClientSecret = resolveSecret(req.Public, req.ClientSecret, existing != nil, existingSecret)
if req.ClientId == "" {
req.ClientId = req.Name // <org>-<app> convention: clientId == name
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 = req.ClientId
existing.ClientSecret = req.ClientSecret
existing.Organization = pick(req.Organization, existing.Organization)
if req.DisplayName != "" {
existing.DisplayName = req.DisplayName
existing.ClientId = in.ClientId
existing.ClientSecret = in.ClientSecret
existing.Organization = pick(in.Organization, existing.Organization)
if in.DisplayName != "" {
existing.DisplayName = in.DisplayName
}
if len(req.GrantTypes) > 0 {
existing.GrantTypes = req.GrantTypes
if len(in.GrantTypes) > 0 {
existing.GrantTypes = in.GrantTypes
}
if len(req.RedirectUris) > 0 {
existing.RedirectUris = req.RedirectUris
if len(in.RedirectUris) > 0 {
existing.RedirectUris = in.RedirectUris
}
if req.Cert != "" {
existing.Cert = req.Cert
if in.Cert != "" {
existing.Cert = in.Cert
}
if req.IsShared != nil {
existing.IsShared = *req.IsShared
if in.IsShared != nil {
existing.IsShared = *in.IsShared
}
existing.ExpireInHours = ttl(req.ExpireInHours, existing.ExpireInHours)
existing.RefreshExpireInHours = ttl(req.RefreshExpireInHours, existing.RefreshExpireInHours)
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 c.JSON(500, errResp("server_error"))
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", req.Name
a.ClientId, a.ClientSecret = req.ClientId, req.ClientSecret
a.Organization, a.DisplayName = req.Organization, pick(req.DisplayName, req.Name)
a.GrantTypes, a.RedirectUris, a.Cert = req.GrantTypes, req.RedirectUris, req.Cert
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(req.ExpireInHours, schema.DefaultExpireInHours)
a.RefreshExpireInHours = ttl(req.RefreshExpireInHours, 0)
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 = req.IsShared != nil && *req.IsShared
a.IsShared = in.IsShared != nil && *in.IsShared
a.Model = model
a.SetId("admin/" + req.Name)
a.SetId("admin/" + in.Name)
if err := a.CreateCtx(ctx); err != nil {
return c.JSON(500, errResp("server_error"))
return refuse(500, "server_error"), nil
}
}
return c.JSON(200, map[string]any{
"status": "ok", "action": action,
"data": map[string]any{
"name": req.Name, "organization": req.Organization,
"clientId": req.ClientId, "clientSecret": req.ClientSecret,
},
})
return done(action, &credential{
ClientId: in.ClientId, ClientSecret: in.ClientSecret,
Name: in.Name, Organization: in.Organization,
}), nil
}
}
// userUpsertReq is the operator's user upsert body.
type userUpsertReq struct {
// 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"`
@@ -190,6 +337,20 @@ type userUpsertReq struct {
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
@@ -197,47 +358,45 @@ type userUpsertReq struct {
//
// 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.Handler {
return func(c *zip.Ctx) error {
if !httpx.ServiceTokenAuth(c) {
return unauthorized(c)
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
}
ctx := c.Context()
var req userUpsertReq
if err := decode(c, &req); err != nil {
return c.JSON(400, errResp("invalid body: "+err.Error()))
if bad := in.check(); bad != nil {
return bad, nil
}
req.Owner, req.Name = strings.TrimSpace(req.Owner), strings.TrimSpace(req.Name)
if req.Owner == "" || req.Name == "" {
return c.JSON(400, errResp("owner and name are required"))
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 req.Password != "" {
h, err := cred.Hash(req.Password)
if in.Password != "" {
h, err := cred.Hash(in.Password)
if err != nil {
return c.JSON(500, errResp("server_error"))
return refuse(500, "server_error"), nil
}
hash = h
}
existing, err := store.GetUserByName(ctx, db, req.Owner, req.Name)
existing, err := store.GetUserByName(ctx, db, in.Owner, in.Name)
if err != nil {
return c.JSON(500, errResp("server_error"))
return refuse(500, "server_error"), nil
}
action := "created"
if existing != nil {
action = "updated"
existing.DisplayName = pick(req.DisplayName, existing.DisplayName)
existing.Email = pick(req.Email, existing.Email)
existing.Phone = pick(req.Phone, existing.Phone)
existing.IsAdmin = req.IsAdmin
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 c.JSON(500, errResp("server_error"))
return refuse(500, "server_error"), nil
}
} else {
// A new row obeys THE username rule; an existing one is found above and
@@ -245,29 +404,26 @@ func upsertUser(db orm.DB) zip.Handler {
// 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(req.Name)
name, err := schema.Username(in.Name)
if err != nil {
return c.JSON(400, errResp(err.Error()))
return refuse(400, err.Error()), nil
}
req.Name = name // the id and the response report what was STORED
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 = req.Owner, name
u.DisplayName, u.Email, u.Phone, u.IsAdmin = req.DisplayName, req.Email, req.Phone, req.IsAdmin
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(req.Owner + "/" + req.Name)
u.SetId(in.Owner + "/" + in.Name)
if err := u.CreateCtx(ctx); err != nil {
return c.JSON(500, errResp("server_error"))
return refuse(500, "server_error"), nil
}
}
return c.JSON(200, map[string]any{
"status": "ok", "action": action,
"data": map[string]any{"owner": req.Owner, "name": req.Name},
})
return done(action, &account{Name: in.Name, Owner: in.Owner}), nil
}
}
@@ -298,17 +454,36 @@ func resolveSecret(public bool, requested string, hasExisting bool, existing str
}
}
// decode reads the raw JSON body (content-type independent) into v.
func decode(c *zip.Ctx, v any) error {
body := c.Body()
if len(body) == 0 {
return errors.New("empty request body")
// 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
}
return json.Unmarshal(body, v)
if org = strings.TrimSpace(org); org != "" {
return "cert-" + org
}
return ""
}
func errResp(msg string) map[string]any { return map[string]any{"status": "error", "msg": msg} }
// 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
+5 -2
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package bootstrap_test
@@ -39,7 +40,9 @@ func boot(t *testing.T) (*zip.App, orm.DB) {
t.Cleanup(func() { _ = db.Close() })
app := zip.New(zip.Config{AppName: "bootstrap-test", DisableStartupMessage: true})
routes.Route(app, db)
app.Prepare()
if err := app.Build(); err != nil {
t.Fatalf("build: %v", err)
}
return app, db
}
+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)
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package bootstrap_test
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package bootstrap_test
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package bootstrap
+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)
}
}
+5
View File
@@ -9,6 +9,11 @@ import (
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.",
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// 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
+2 -1
View File
@@ -1,4 +1,5 @@
// 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 the legacy surface database and the v2 orm store and prints the
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//go:build migration
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//go:build !migration
+3 -2
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// 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
@@ -267,7 +268,7 @@ func userGetHandler(db orm.DB) zip.Handler {
// 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 (its hk- AccessKey) to a
// 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 {
+5 -2
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package compat_test
@@ -87,7 +88,9 @@ func newHarness(t *testing.T) *harness {
app := zip.New(zip.Config{AppName: "compat-test", DisableStartupMessage: true})
routes.Route(app, db)
app.Prepare()
if err := app.Build(); err != nil {
t.Fatalf("build: %v", err)
}
return &harness{app: app, key: key, db: db}
}
+50 -17
View File
@@ -1,14 +1,15 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// 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 (hk-/sk-) to {owner,name,email,isAdmin} to authenticate a keyed request. It is
// 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 (its hk- AccessKey) on an sk- resolution. A PUBLIC pk- is
// write-only and is REFUSED here (its org-only dual is /v1/iam/resolve-key).
// 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"
@@ -30,7 +31,10 @@ const (
otherApp = "hanzo-noresolve" // admin-owned app WITHOUT the capability
svcSecret = "resolver-secret"
keyUserHK = "hk-live-KEYUSERHK" // the user's own durable Cloud API key
// 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
@@ -65,9 +69,9 @@ func (h *harness) getBasic(t *testing.T, path, clientID, secret string) (int, st
return resp.StatusCode, string(b)
}
// keyFixtures seeds the two service apps, the target user (with an hk- key + secret
// sentinels), and a schema.Key (pk-/sk-) belonging to that user; then arms the
// CapKeyResolve allowlist with resolverApp only.
// 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)
@@ -76,7 +80,7 @@ func keyFixtures(t *testing.T, h *harness) {
u := orm.New[schema.User](h.db)
u.Owner, u.Name, u.Email = "hanzo", "keyuser", "keyuser@hanzo.ai"
u.IsAdmin = true
u.AccessKey = keyUserHK
u.AccessKey = userRowKey
u.AccessSecret = projSK // a secret half on the user row too — must never surface
u.AccessSecretHash = keyUserSecretHash
u.PasswordHash = secretUserHash
@@ -109,7 +113,7 @@ func seedClientApp(t *testing.T, db orm.DB, name, secret string) {
}
}
// A cap-holding service caller resolves each SECRET key shape to the right user, with
// 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.
@@ -118,7 +122,6 @@ func TestGetUserByAccessKey_ResolvesSecretsRefusesPublishable(t *testing.T) {
keyFixtures(t, h)
for _, tc := range []struct{ name, key string }{
{"hk on user row", keyUserHK},
{"sk confidential half", projSK},
} {
status, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+tc.key, resolverApp, svcSecret)
@@ -137,8 +140,8 @@ func TestGetUserByAccessKey_ResolvesSecretsRefusesPublishable(t *testing.T) {
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
// (its hk- key) when an sk- key was the one presented.
for _, secret := range []string{secretUserHash, keyUserSecretHash, keyUserHK} {
// (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)
}
@@ -158,6 +161,35 @@ func TestGetUserByAccessKey_ResolvesSecretsRefusesPublishable(t *testing.T) {
}
}
// 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.
@@ -195,7 +227,7 @@ func TestGetUserByAccessKey_NonCapDenied(t *testing.T) {
h := newHarness(t)
keyFixtures(t, h)
status, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+keyUserHK, otherApp, svcSecret)
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" {
@@ -246,10 +278,11 @@ func TestGetUserByAccessKey_RefusalCarriesItsReason(t *testing.T) {
keyFixtures(t, h)
for _, tc := range []struct{ name, key, wantCode string }{
{"revoked / never minted", "hk-live-NOSUCHKEY", "key_unknown"},
{"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_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
@@ -274,7 +307,7 @@ func TestGetUserByAccessKey_NonCapCallerLearnsNoReason(t *testing.T) {
h := newHarness(t)
keyFixtures(t, h)
_, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+keyUserHK, otherApp, svcSecret)
_, 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 != "" {
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package compat
+3 -2
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package compat_test
@@ -120,7 +121,7 @@ func TestResolveKey_RefusesNonPublishable(t *testing.T) {
for _, tc := range []struct{ name, key string }{
{"secret key's pk- half", secretKeyPK},
{"an sk- confidential half", secretKeySK},
{"an hk-", "hk-live-anything"},
{"a retired prefix", "hk-live-anything"},
{"unknown pk-", "pk-live-NOSUCH"},
{"empty", ""},
} {
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package compat
+28 -13
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package compat_test
@@ -144,28 +145,42 @@ func TestWriteAliases_requireAuth(t *testing.T) {
// 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). An anonymous caller gets the casibase
// {status:"error"} (200), never a 401 and never a leak.
// 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"},
{"GET", "/v1/iam/whoami"},
{"GET", "/v1/iam/linked-accounts"},
{"GET", "/v1/iam/get-account", 200},
{"GET", "/v1/iam/whoami", 200},
{"GET", "/v1/iam/linked-accounts", 400},
} {
status, body := h.get(t, tc.path, "")
if status != 200 {
t.Fatalf("%s %s without a bearer status=%d, want 200 (public); body=%s", tc.method, tc.path, status, body)
// 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 !strings.Contains(body, "\"error\"") {
t.Fatalf("anonymous %s must be the casibase error envelope; body=%s", tc.path, 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 — anonymous, no code → a 200 error, not a 401.
if status, body := h.post(t, "/v1/iam/signin", "", map[string]any{}); status != 200 || !strings.Contains(body, "\"error\"") {
t.Fatalf("anonymous signin status=%d body=%s, want 200 error (public)", status, 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)
}
}
+3 -3
View File
@@ -127,7 +127,7 @@ func init() {
"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 GenerateID 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.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.",
@@ -197,7 +197,7 @@ func init() {
"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 GenerateID 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.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.",
@@ -265,7 +265,7 @@ func init() {
"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 GenerateID 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.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.",
+307 -48
View File
@@ -1,7 +1,9 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// 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.
// 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.
@@ -9,23 +11,67 @@
// 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.
//
// THE ALLOWLIST IS DERIVED, NOT CONFIGURED. 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 CORS 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. A config-file
// allowlist is exactly how these two drift apart.
//
// Only the endpoints a browser legitimately calls cross-origin are opened.
// Credentials are NOT allowed: a PKCE exchange carries its proof in the body,
// not in a cookie, so echoing an origin can never authorize a cookie-bearing
// request.
//
// # 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"
@@ -33,23 +79,62 @@ import (
"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"
)
// browserPaths are the endpoints a browser-side OIDC client must reach
// cross-origin. Everything else stays same-origin only — an endpoint that no
// browser client calls has no reason to advertise itself to one.
var browserPaths = map[string]bool{
"/.well-known/openid-configuration": true,
"/v1/iam/.well-known/openid-configuration": true,
"/.well-known/oauth-authorization-server": true,
"/v1/iam/.well-known/oauth-authorization-server": true,
"/.well-known/jwks": true,
"/v1/iam/.well-known/jwks": true,
"/v1/iam/oauth/token": true,
"/v1/iam/oauth/userinfo": true,
"/v1/iam/oauth/revoke": true,
"/v1/iam/oauth/logout": true,
// 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
@@ -62,10 +147,15 @@ var browserPaths = map[string]bool{
// 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.
"/v1/iam/get-organizations": true,
"/v1/iam/get-organization": true,
"/v1/iam/get-users": true,
"/v1/iam/get-account": true,
//
// 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
@@ -73,8 +163,126 @@ var browserPaths = map[string]bool{
// 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": true,
"/v1/iam/invitations": true,
"/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,
@@ -152,32 +360,83 @@ func originOf(raw string) string {
// 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 {
origin := strings.TrimSpace(c.Header("Origin"))
if origin == "" {
return c.Next() // same-origin or a non-browser client
}
path := c.Path()
if !browserPaths[path] {
mode := browserPaths[c.Path()]
if mode == absent {
return c.Next()
}
// Vary on Origin whenever the response could depend on it, so a shared
// cache can never serve one origin's response to another.
c.SetHeader("Vary", "Origin")
if !reg.allowed(c.Context(), origin) {
return c.Next() // unregistered: no header, browser blocks it
// 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
}
c.SetHeader("Access-Control-Allow-Origin", origin)
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 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
}
return c.Next()
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") }
+42 -13
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package cors
@@ -46,8 +47,8 @@ func TestOriginOf_SkipsNonBrowserRedirects(t *testing.T) {
}
}
// Only endpoints a browser-side OIDC client actually calls are opened. Widening
// this set is a security decision, so the set is asserted rather than assumed.
// 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.
@@ -57,24 +58,50 @@ func TestBrowserPaths_ExactlyTheOIDCBrowserSurface(t *testing.T) {
"/v1/iam/.well-known/jwks",
"/v1/iam/oauth/userinfo",
} {
if !browserPaths[p] {
t.Errorf("%s must be reachable cross-origin", p)
if browserPaths[p] != bearer {
t.Errorf("%s must be reachable cross-origin, proving itself with a Bearer", p)
}
}
// These MUST NOT be: admin/bootstrap surfaces and the credential-login
// endpoint are not for cross-origin browser use.
// 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/login",
"/v1/iam/oauth/authorize", // a top-level redirect, not a fetch
} {
if browserPaths[p] {
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) {
@@ -111,9 +138,9 @@ func TestBrowserPaths_CoverTheConsoleOrgSurface(t *testing.T) {
"/v1/iam/get-users",
"/v1/iam/get-account",
} {
if !browserPaths[p] {
t.Errorf("%s must be reachable cross-origin: a console reads it to show "+
"which org the user is acting as", p)
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)
}
}
}
@@ -127,8 +154,10 @@ func TestBrowserPaths_StayClosedByDefault(t *testing.T) {
"/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] {
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)
}
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// 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.
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package cred
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package cred
+7 -4
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// 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
@@ -33,8 +34,8 @@ import (
ormdb "github.com/hanzoai/orm/db"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/oidc"
"github.com/hanzoai/iam/internal/routes"
"github.com/hanzoai/iam/pkg/pkce"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/internal/testhttp"
@@ -78,7 +79,9 @@ func boot(t *testing.T) *env {
app := zip.New(zip.Config{AppName: "iam-e2e", DisableStartupMessage: true})
routes.Route(app, db)
app.Prepare()
if err := app.Build(); err != nil {
t.Fatalf("build: %v", err)
}
return &env{app: app, key: key, db: db}
}
@@ -223,7 +226,7 @@ func (e *env) login(t *testing.T, verifier string) string {
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": oidc.ComputeS256Challenge(verifier), "codeChallengeMethod": "S256",
"codeChallenge": pkce.Challenge(verifier), "codeChallengeMethod": "S256",
})
st, resp := e.req(t, "POST", "/v1/iam/login", "", string(body), "application/json")
if st != 200 {
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// 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.
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package featurestore
+113 -19
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// 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
@@ -16,9 +17,17 @@ import (
"github.com/zap-proto/zip"
)
// Response is the the legacy surface-compatible envelope. status is "ok" or "error"; a
// non-ok status rides on a 200 (every SDK branches on status, not the HTTP
// code — preserving that contract keeps the clients unchanged at cutover).
// 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"`
@@ -49,43 +58,128 @@ func ServiceToken() string {
return ""
}
// ServiceTokenAuth reports whether the request carries the unified service token as
// a Bearer credential, compared in constant time. An unset expected token, or any
// 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.
func ServiceTokenAuth(c *zip.Ctx) bool {
//
// 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 := Bearer(c)
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 {
r := Response{Status: "ok", Data: data}
if len(more) > 0 {
r.Data2 = more[0]
}
return c.JSON(200, r)
return write(c, Good(data, more...))
}
// Err writes 200 { status:"error", msg } — the SDK contract (branch on status,
// not HTTP code).
// 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.
// ONE implementation writes the error envelope; Err is this with no reason to give.
func ErrCode(c *zip.Ctx, msg, code string) error {
return c.JSON(200, Response{Status: "error", Msg: msg, Code: code})
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 {
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 "
h := c.Header("Authorization")
if len(h) > len(p) && h[:len(p)] == p {
return h[len(p):]
}
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// 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
+4 -4
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// 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.
@@ -309,9 +310,8 @@ func NameFor(scope string) string {
//
// 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, and because it overwrote
// the user's working legacy hk- in the same field it locked the holder out with no
// way back through the UI. Writing the row the resolver actually reads is the fix.
// 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) {
+3 -4
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package keys
@@ -129,9 +130,7 @@ func TestKeys_PublishableForcesSecretEmpty(t *testing.T) {
//
// 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. Worse, the write
// landed in the same field as the user's working legacy hk-, so regenerating a key
// locked the holder out with no way back through the UI.
// the read never met, so every minted key authenticated nobody.
func TestMintUserKey_ResolvesBackToItsUser(t *testing.T) {
db := memDB(t)
ctx := context.Background()
+50 -23
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// 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
@@ -46,6 +47,8 @@ const (
// 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
@@ -53,15 +56,42 @@ const unauthorized = "auth:Unauthorized operation"
// 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) {
app.Get(Path, list(db))
zip.Get[lookup, httpx.Answer](app, Path, list(db),
zip.WithStatus(200, 400),
zip.WithTags("memberships"))
app.Post(Path, ensure(db))
app.Get(PathGet, list(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>"
@@ -77,30 +107,26 @@ type request struct {
// 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.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
user, org := c.Query("user"), c.Query("org")
if (user == "") == (org == "") {
return httpx.Err(c, "exactly one of user or org is required")
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 org != "" {
if !scoped(ctx, org) {
return httpx.Err(c, unauthorized)
if in.Org != "" {
if !scoped(ctx, in.Org) {
return httpx.Bad(400, unauthorized, ""), nil
}
rows, err := store.MembershipsByOrg(ctx, db, org)
return listed(c, rows, err)
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(user, "/")
home, _, found := strings.Cut(in.User, "/")
if !found || home == "" {
return httpx.Err(c, "user must be <owner>/<name>")
return httpx.Bad(400, "user must be <owner>/<name>", ""), nil
}
if !scoped(ctx, home) {
return httpx.Err(c, unauthorized)
return httpx.Bad(400, unauthorized, ""), nil
}
rows, err := store.MembershipsByUser(ctx, db, user)
return listed(c, rows, err)
return listed(store.MembershipsByUser(ctx, db, in.User))
}
}
@@ -196,10 +222,11 @@ func scoped(ctx context.Context, org string) bool {
return err == nil && got == org
}
// listed writes a membership listing, or the error envelope on failure.
func listed(c *zip.Ctx, rows []*schema.Membership, err error) error {
// 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.Err(c, err.Error())
return httpx.Bad(400, err.Error(), ""), nil
}
return c.JSON(200, httpx.Response{Status: "ok", Data: rows, Data2: len(rows)})
return httpx.Good(rows, len(rows)), nil
}
+116 -11
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package memberships_test
@@ -22,6 +23,7 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
@@ -70,7 +72,9 @@ func newHarness(t *testing.T) *harness {
app := zip.New(zip.Config{AppName: "memberships-test", DisableStartupMessage: true})
routes.Route(app, db)
app.Prepare()
if err := app.Build(); err != nil {
t.Fatalf("build: %v", err)
}
return &harness{app: app, key: key, db: db}
}
@@ -91,12 +95,8 @@ func (h *harness) token(t *testing.T, sub string) string {
func (h *harness) get(t *testing.T, path, bearer string) (int, env) {
t.Helper()
req := httptest.NewRequest("GET", path, nil)
req.Host = "hanzo.id"
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
return h.do(t, req)
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) {
@@ -127,6 +127,22 @@ func (h *harness) postBasic(t *testing.T, path string, body any, clientID, secre
// 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 {
@@ -134,9 +150,18 @@ func (h *harness) do(t *testing.T, req *http.Request) (int, env) {
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
var e env
_ = json.Unmarshal(body, &e)
return resp.StatusCode, e
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.
@@ -294,6 +319,86 @@ func TestEnsureMembership_reservedOrgRequiresSuper(t *testing.T) {
}
}
// ---- 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)
+10
View File
@@ -9,9 +9,19 @@ import (
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).",
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package factor
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// 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
+5 -2
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package mfa_test
@@ -69,7 +70,9 @@ func newHarness(t *testing.T) *harness {
app := zip.New(zip.Config{AppName: "mfa-test", DisableStartupMessage: true})
routes.Route(app, db)
app.Prepare()
if err := app.Build(); err != nil {
t.Fatalf("build: %v", err)
}
return &harness{app: app, key: key, db: db}
}
+108 -10
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
@@ -17,10 +18,22 @@ import (
// 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
// well-formed request is delegated to the hosted login UI (matching v1), which
// collects credentials and posts to /v1/iam/login; that endpoint mints the
// PKCE-bound code and the browser lands back on the registered redirect_uri.
// 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.
@@ -39,20 +52,39 @@ type authorizeRequest struct {
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.
//
// It shows the person the right way to sign in for the application they are
// signing in to, hands off to another identity provider if that is what they
// pick, and ends by returning them to the application with a one-time code.
// 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)
@@ -87,16 +119,49 @@ func authorizeHandler(db orm.DB) zip.Handler {
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)
}
// Delegate to the hosted login with a clean, re-encoded request. The login
// page posts credentials to /v1/iam/login, which mints the code.
// 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))
}
}
@@ -116,6 +181,7 @@ func authorizeParams(c *zip.Ctx) authorizeRequest {
resource: param(c, "resource"),
responseMode: param(c, "response_mode"),
provider: param(c, "provider"),
prompt: param(c, "prompt"),
}
}
@@ -145,15 +211,47 @@ func authorizeForwardQuery(q authorizeRequest, method string) string {
}
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 := "?"
+81 -2
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
@@ -7,6 +8,8 @@ import (
"net/url"
"strings"
"testing"
"github.com/hanzoai/iam/pkg/pkce"
)
const testRedirect = "https://app.example/callback"
@@ -85,7 +88,7 @@ func TestAuthorize_DelegatesValidRequest(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
challenge := ComputeS256Challenge("verifier-abcdefghijklmnopqrstuvwxyz-012345")
challenge := pkce.Challenge("verifier-abcdefghijklmnopqrstuvwxyz-012345")
q := url.Values{
"response_type": {"code"},
"client_id": {"pub"},
@@ -115,6 +118,82 @@ func TestAuthorize_DelegatesValidRequest(t *testing.T) {
}
}
// 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) {
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
+10 -8
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
@@ -7,6 +8,7 @@ import (
"testing"
"time"
"github.com/hanzoai/iam/pkg/pkce"
"github.com/hanzoai/iam/pkg/schema"
)
@@ -20,7 +22,7 @@ func testApp() *schema.Application {
func TestMintCode_BindsPKCEAndExpiry(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
verifier := "verifier-abc-000000000000000000000000000000000"
ch := ComputeS256Challenge(verifier)
ch := pkce.Challenge(verifier)
tok, err := MintCode(testApp(), "hanzo/alice", "openid profile", ch, "S256", "", now)
if err != nil {
t.Fatal(err)
@@ -49,7 +51,7 @@ func TestMintCode_RefusesPlain(t *testing.T) {
func TestRedeemCode_HappyPath(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
verifier := "verifier-happy-0000000000000000000000000000000"
tok, _ := MintCode(testApp(), "hanzo/alice", "openid", ComputeS256Challenge(verifier), "S256", "", now)
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)
}
@@ -58,7 +60,7 @@ func TestRedeemCode_HappyPath(t *testing.T) {
func TestRedeemCode_ReplayRejected(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
verifier := "verifier-replay-000000000000000000000000000000"
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge(verifier), "S256", "", now)
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)
@@ -75,7 +77,7 @@ func TestRedeemCode_ReplayRejected(t *testing.T) {
func TestRedeemCode_ExpiredRejected(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
verifier := "verifier-exp-00000000000000000000000000000000000"
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge(verifier), "S256", "", now)
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)
@@ -85,7 +87,7 @@ func TestRedeemCode_ExpiredRejected(t *testing.T) {
func TestRedeemCode_ClientMismatchRejected(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
verifier := "verifier-cli-00000000000000000000000000000000000"
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge(verifier), "S256", "", now)
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)
}
@@ -93,7 +95,7 @@ func TestRedeemCode_ClientMismatchRejected(t *testing.T) {
func TestRedeemCode_WrongVerifierRejected(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge("the-right-verifier-0000000000000000000000000"), "S256", "", now)
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)
}
@@ -102,7 +104,7 @@ func TestRedeemCode_WrongVerifierRejected(t *testing.T) {
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", ComputeS256Challenge("v-000000000000000000000000000000000000000000000"), "S256", "", now)
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)
}
+138 -55
View File
@@ -1,9 +1,13 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// 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"
@@ -14,48 +18,48 @@ import (
)
// GET/PUT /v1/iam/consent — the account-canonical data-sharing consent: the ONE
// place a user's choice lives. The hanzo.id signup asks it, the browser extension
// reads/writes it, and hanzo.ai edits it — all through here. It rides the SAME
// self-scoped preferences blob as update-preferences (preferencesKey → "consent"),
// so there is one store, one merge, one source of truth (no parallel table to drift).
// 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).
//
// SELF-SCOPED: the target is ALWAYS the caller (callerOf), never a body field.
// 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.
//
// Two switches, privacy-first defaults when unset:
// 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.
//
// insights default TRUE — anonymous product usage (no query/answer text).
// shareTraining default FALSE — OPT-IN to contribute the user's own data to
// train Hanzo's open models.
// 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"
// consentKey nests the consent object inside the preferences blob.
const consentKey = "consent"
type consentView struct {
Insights bool `json:"insights"`
ShareTraining bool `json:"shareTraining"`
}
// consentOf reads consent out of a preferences JSON blob, applying the defaults
// for a first-ever read (insights on, training off).
func consentOf(prefs string) consentView {
v := consentView{Insights: true, ShareTraining: false}
if prefs == "" {
return v
}
var m map[string]json.RawMessage
if json.Unmarshal([]byte(prefs), &m) != nil {
return v
}
if raw, ok := m[consentKey]; ok {
_ = json.Unmarshal(raw, &v)
}
return v
// 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.
// 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()
@@ -67,15 +71,19 @@ func getConsentHandler(db orm.DB) zip.Handler {
if err != nil || user == nil {
return httpx.Err(c, "server_error")
}
return httpx.Ok(c, consentOf(user.Properties[preferencesKey]))
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.
//
// It merges rather than replaces, so saving a consent screen never discards a
// preference some other screen set at the same moment.
// 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()
@@ -83,33 +91,108 @@ func putConsentHandler(db orm.DB) zip.Handler {
if !ok {
return httpx.Err(c, "please sign in first")
}
var in consentView
var in consentBody
if err := json.Unmarshal(c.Fiber().Body(), &in); err != nil {
return httpx.Err(c, "consent must be a JSON object")
}
if _, err := updateUser(ctx, db, owner, name, func(u *schema.User) error {
merged := map[string]json.RawMessage{}
if blob := u.Properties[preferencesKey]; blob != "" {
_ = json.Unmarshal([]byte(blob), &merged)
// 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")
}
cj, err := json.Marshal(in)
if err != nil {
}
// 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
}
merged[consentKey] = cj
out, err := json.Marshal(merged)
if err != nil {
return err
}
if u.Properties == nil {
u.Properties = map[string]string{}
}
u.Properties[preferencesKey] = string(out)
u.UpdatedTime = provisionNow()
return nil
// 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, in)
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])
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
+4 -2
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
@@ -12,6 +13,7 @@ import (
"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"
)
@@ -474,7 +476,7 @@ func TestDevice_AuthorizationCodeIsNotRedeemableAsDeviceCode(t *testing.T) {
code, _, _ := loginForCode(t, app, map[string]string{
"organization": "hanzo", "username": "alice", "password": "pw",
"clientId": "hanzo-app", "redirectUri": testRedirect, "scope": "openid",
"codeChallenge": ComputeS256Challenge(verifier), "codeChallengeMethod": "S256",
"codeChallenge": pkce.Challenge(verifier), "codeChallengeMethod": "S256",
})
if code == "" {
t.Fatal("setup: no authorization code minted")
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
+5 -4
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
@@ -381,7 +382,7 @@ func linkOrProvision(ctx context.Context, db orm.DB, app *schema.Application, pr
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(fresh *schema.User) error {
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
@@ -448,7 +449,7 @@ func federationProvider(app *schema.Application, name string) *schema.Provider {
if it == nil || it.Name != name || !it.CanSignIn || it.Provider == nil {
continue
}
if !isConfigured(it.Provider) {
if !offerable(it.Provider) {
continue
}
return it.Provider
@@ -479,7 +480,7 @@ func federationCallbackURL(c *zip.Ctx) string {
// 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 resolveIssuer(c.Host())
return resolveFederationOrigin(c.Host())
}
// federationOrgAllowed reports whether a federated (external) identity may be
+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)
}
}
}
+5 -3
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
@@ -24,6 +25,7 @@ import (
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/iam/pkg/pkce"
"github.com/hanzoai/iam/pkg/schema"
)
@@ -277,7 +279,7 @@ func oidcAuthorizeURL(cfg oidcConfig, p *schema.Provider, st *schema.FederationS
v.Set("scope", ensureOpenID(providerScopes(p, "openid email profile")))
v.Set("state", st.Name)
v.Set("nonce", st.IdpNonce)
v.Set("code_challenge", ComputeS256Challenge(st.IdpVerifier))
v.Set("code_challenge", pkce.Challenge(st.IdpVerifier))
v.Set("code_challenge_method", "S256")
return joinQuery(cfg.authURL, v)
}
@@ -377,7 +379,7 @@ func githubAuthorizeURL(p *schema.Provider, st *schema.FederationState, callback
v.Set("state", st.Name)
v.Set("allow_signup", "true")
if p.EnablePkce {
v.Set("code_challenge", ComputeS256Challenge(st.IdpVerifier))
v.Set("code_challenge", pkce.Challenge(st.IdpVerifier))
v.Set("code_challenge_method", "S256")
}
return joinQuery(firstNonEmpty(p.CustomAuthUrl, githubAuthorizeEndpoint), v)
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
+6 -4
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
@@ -20,6 +21,7 @@ import (
"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"
)
@@ -264,7 +266,7 @@ func beginAuthorize(t *testing.T, app *zip.App, clientID, provider string) (url.
"redirect_uri": {testRedirect},
"scope": {"openid email profile"},
"state": {fedAppState},
"code_challenge": {ComputeS256Challenge(fedVerifier)},
"code_challenge": {pkce.Challenge(fedVerifier)},
"code_challenge_method": {"S256"},
"provider": {provider},
}
@@ -700,7 +702,7 @@ func TestFederation_NonAllowlistedRedirectUriRefused(t *testing.T) {
q := url.Values{
"response_type": {"code"}, "client_id": {"webapp"},
"redirect_uri": {"https://evil.example/steal"},
"code_challenge": {ComputeS256Challenge(fedVerifier)},
"code_challenge": {pkce.Challenge(fedVerifier)},
"provider": {fedProvGoogle},
}
resp, _ := do(t, app, formReqNoBody("GET", PathAuthorize+"?"+q.Encode()))
@@ -775,7 +777,7 @@ func federationAuthorizeQuery(clientID string) url.Values {
"response_type": {"code"},
"client_id": {clientID},
"redirect_uri": {testRedirect},
"code_challenge": {ComputeS256Challenge(fedVerifier)},
"code_challenge": {pkce.Challenge(fedVerifier)},
"code_challenge_method": {"S256"},
"state": {fedAppState},
"provider": {fedProvGoogle},
+3 -2
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
@@ -89,7 +90,7 @@ func unlink(db orm.DB) zip.Handler {
return httpx.Err(c, "this provider can't be unlinked")
}
if _, err := updateUser(ctx, db, f.User.Owner, f.User.Name, func(fresh *schema.User) error {
if _, err := updateUser(ctx, db, f.User.Owner, f.User.Name, func(_ orm.DB, fresh *schema.User) error {
*b.ref(fresh) = ""
return nil
}); err != nil {
+3 -2
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
@@ -19,7 +20,7 @@ func newUnlinkServer(t *testing.T) (*zip.App, orm.DB) {
t.Helper()
db := openTestDB(t)
app := zip.New(zip.Config{AppName: "iam-unlink-test", DisableStartupMessage: true})
Route(app.Group(""), db) // public: authorize/login/token AND the self-authenticating unlink
Route(app.Group("").(*zip.App), db) // public: authorize/login/token AND the self-authenticating unlink
return app, db
}
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
+146 -54
View File
@@ -1,8 +1,10 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
"context"
"strings"
"github.com/hanzoai/orm"
@@ -25,9 +27,31 @@ const PathAuthMethods = "/v1/iam/auth/methods"
// caller itself (callerOf: session cookie first, then bearer) and SELF-SCOPES to
// that caller, so — like the rest of this group — they are reachable without a
// Guard-verified bearer yet never act on anyone but the resolved caller.
func routeFrontDoor(r zip.Router, db orm.DB) {
zip.Alias(r.Get, PathAuthApplication, LegacyPathAuthApplication, getAppLogin(db))
r.Get(PathAuthMethods, authMethods(db))
func routeFrontDoor(r *zip.App, db orm.DB) {
// The two login-screen descriptors are TYPED ops. Their whole input is a client
// id off the query string and their answer is this envelope, so nothing about
// them needed a raw handler — and a raw handler is what kept them out of the
// schema, the MCP tool list, the CLI and every generated SDK. Registered on the
// PUBLIC group, which carries no op-invoke authorizer, so typing them changes
// what they PUBLISH and nothing about who may call them.
//
// The older spelling of the first is the SAME op at its legacy address, so one
// function decides both answers and they cannot drift.
//
// It carries the same tag as its canonical twin rather than "compat", and that
// is a deliberate limit on the blast radius of a TYPING change. The compat tag
// is how an address is kept OUT of the published document, and this address is
// in it today — as an untyped route, which has no way to say compat. Tagging it
// now would delete a published path, and a published path that disappears is
// what cloud's per-product floor exists to refuse. Retiring the spelling is a
// surface decision that lowers that floor in the same commit; this is not that
// commit.
zip.Get[screen, httpx.Answer](r, PathAuthApplication, getAppLogin(db),
zip.WithStatus(200, 400), zip.WithTags("auth"))
zip.Get[screen, httpx.Answer](r, LegacyPathAuthApplication, getAppLogin(db),
zip.WithStatus(200, 400), zip.WithTags("auth"))
zip.Get[offer, httpx.Answer](r, PathAuthMethods, authMethods(db),
zip.WithStatus(200, 400), zip.WithTags("auth"))
// The account read is anonymous-safe (returns {status:"error"} unauthenticated)
// and a security contract — the gateway admin-guard reads its `owner`.
zip.Alias(r.Get, PathAccount, LegacyPathAccount, getAccount(db))
@@ -61,24 +85,23 @@ func routeFrontDoor(r zip.Router, db orm.DB) {
//
// The client secret is masked. Read before anyone has signed in, so it carries
// only what is safe for a browser to see.
func getAppLogin(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
if rt := c.Query("responseType"); rt != "" && rt != "code" {
return httpx.Err(c, "response_type is required (must be code)")
func getAppLogin(db orm.DB) zip.TypedHandler[screen, httpx.Answer] {
return func(ctx context.Context, in *screen) (*httpx.Answer, error) {
if in.ResponseType != "" && in.ResponseType != "code" {
return httpx.Bad(400, "response_type is required (must be code)", ""), nil
}
clientId := c.Query("clientId")
if clientId == "" {
return httpx.Err(c, "clientId is required")
if in.ClientId == "" {
return httpx.Bad(400, "clientId is required", ""), nil
}
app, err := store.GetApplicationByClientId(c.Context(), db, clientId)
app, err := store.GetApplicationByClientId(ctx, db, in.ClientId)
if err != nil {
return httpx.Err(c, err.Error())
return httpx.Bad(400, err.Error(), ""), nil
}
if app == nil {
return httpx.Err(c, "the application does not exist")
return httpx.Bad(400, "the application does not exist", ""), nil
}
store.EnrichProviders(c.Context(), db, app)
return httpx.Ok(c, maskApp(app))
store.EnrichProviders(ctx, db, app)
return httpx.Good(loginView(app)), nil
}
}
@@ -88,34 +111,29 @@ func getAppLogin(db orm.DB) zip.Handler {
//
// Public by design: it is read before anyone has signed in, and it exposes only
// which methods exist, never their credentials.
func authMethods(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
clientId := c.Query("clientId")
if clientId == "" {
return httpx.Err(c, "clientId is required")
func authMethods(db orm.DB) zip.TypedHandler[offer, httpx.Answer] {
return func(ctx context.Context, in *offer) (*httpx.Answer, error) {
if in.ClientId == "" {
return httpx.Bad(400, "clientId is required", ""), nil
}
app, err := store.GetApplicationByClientId(c.Context(), db, clientId)
app, err := store.GetApplicationByClientId(ctx, db, in.ClientId)
if err != nil {
return httpx.Err(c, err.Error())
return httpx.Bad(400, err.Error(), ""), nil
}
if app == nil {
return httpx.Err(c, "the application does not exist")
return httpx.Bad(400, "the application does not exist", ""), nil
}
store.EnrichProviders(c.Context(), db, app)
store.EnrichProviders(ctx, db, app)
oauth := []map[string]string{}
web3 := false
for _, it := range app.Providers {
if it == nil || it.Provider == nil || !it.CanSignIn {
continue
}
if !isConfigured(it.Provider) {
if !offerable(it.Provider) {
continue // hidden until real creds land — never a dead-end button
}
switch strings.ToLower(it.Provider.Category) {
case "web3":
web3 = true
case "oauth":
if strings.EqualFold(it.Provider.Category, "oauth") {
oauth = append(oauth, map[string]string{
"name": it.Name,
"type": it.Provider.Type,
@@ -123,21 +141,76 @@ func authMethods(db orm.DB) zip.Handler {
})
}
}
return httpx.Ok(c, map[string]any{
// Wallet sign-in is a capability of THIS BINARY, not of an application's
// provider list, so it is asked of the package that serves it. It used to
// be read off a linked provider of category "web3" — the seeded
// Web3Onboard row, whose clientId is the unexpanded literal
// `${IAM_WEB3_CLIENT_ID}` and which names a third-party library this build
// does not import. So every login screen reported web3:false while
// /v1/iam/web3/nonce answered on seven chain families, and the flag tracked
// a row that governed nothing.
//
// The chain list is the SAME one the nonce/verify endpoints gate on, so a
// screen cannot offer a chain the endpoint refuses.
names := schema.WalletChains()
return httpx.Good(map[string]any{
"password": app.EnablePassword,
"code": app.EnableCodeSignin,
// Offered only when a code can actually be delivered. The app switch says
// the org WANTS email/SMS codes; DeliveryConfigured says the server can
// send one. Both must hold, or the screen shows a method that ends in a
// person waiting for a message nobody sent.
"code": app.EnableCodeSignin && DeliveryConfigured(),
"webauthn": app.EnableWebAuthn,
"web3": web3,
"oauth": oauth,
"signup": app.EnableSignUp,
})
"web3": len(names) > 0,
// The families a wallet may sign in with, so a screen can render the
// right options instead of hardcoding a list that drifts from the
// verifier. Additive: `web3` stays the boolean every client reads.
"web3Chains": names,
"oauth": oauth,
"signup": app.EnableSignUp,
}), nil
}
}
// isConfigured reports whether a provider holds a real (non-placeholder)
// credential — the guard that keeps an unconfigured provider's button hidden so
// it never dead-ends the OAuth redirect.
func isConfigured(p *schema.Provider) bool {
// screen is what a login screen names when it asks how to draw itself: the
// application, and the OAuth response type it intends to use.
type screen struct {
// ClientId is the application's OAuth client id — the one field that selects
// which login screen this is.
ClientId string `json:"clientId"`
// ResponseType is the OAuth response type the screen will ask for. Only "code"
// is served; anything else is refused here rather than at the authorize leg,
// where the person has already typed a password.
ResponseType string `json:"responseType"`
}
// offer is the application whose enabled sign-in methods are being asked for.
type offer struct {
// ClientId is the application's OAuth client id.
ClientId string `json:"clientId"`
}
// offerable reports whether a provider can actually COMPLETE a sign-in, which is
// the only honest reason to draw a button for it. A method fails that in two
// independent ways, and checking only the first is what put dead buttons on the
// login screen:
//
// - NO REAL CREDENTIAL — a placeholder client id, never filled in.
// - NO DIALECT THAT CAN DRIVE IT. idpKind is the ONE authority for "can we
// federate this?", and it is what the authorize leg already consults. GitLab
// is the live example: a real-looking client id passes the credential check,
// so the button rendered, and then beginFederation refused it with "provider
// is not a supported federation type". The button existed only to fail.
//
// Both callers now ask THIS question — the login screen and the authorize leg —
// so what is offered and what is driveable can no longer disagree. Give GitLab an
// issuerUrl and it becomes a real OIDC provider here and its button returns, with
// nothing else to change.
//
// Web3 is exempt from the dialect check because it never reaches the federation
// broker at all: it is native challenge/response with no OAuth client.
func offerable(p *schema.Provider) bool {
if p == nil {
return false
}
@@ -145,6 +218,9 @@ func isConfigured(p *schema.Provider) bool {
if strings.EqualFold(p.Category, "Web3") {
return true
}
if idpKind(p) == "" {
return false
}
id := strings.ToLower(strings.TrimSpace(p.ClientId))
if id == "" {
return false
@@ -155,22 +231,38 @@ func isConfigured(p *schema.Provider) bool {
!strings.Contains(id, "change")
}
// maskApp returns a copy-safe view of the application with the client secret and
// every provider's secret removed — get-app-login is called by the browser, so
// no secret may cross it.
func maskApp(app *schema.Application) *schema.Application {
// loginView returns what a login screen may see of an application: no secrets,
// and no sign-in method that cannot complete.
//
// Both halves are here because this response IS the login screen's source of
// truth — the SDK calls it "the canonical source of truth for which methods
// exist" — so a provider present here is a button rendered. It previously
// answered with EVERY provider while /v1/iam/auth/methods answered with the
// offerable ones: two endpoints, two answers to one question, and the browser
// read the unfiltered one. Filtering here is what makes them agree.
func loginView(app *schema.Application) *schema.Application {
if app == nil {
return nil
}
masked := *app
masked.ClientSecret = ""
for _, it := range masked.Providers {
if it != nil && it.Provider != nil {
p := *it.Provider
p.ClientSecret = ""
p.ClientSecret2 = ""
it.Provider = &p
view := *app
view.ClientSecret = ""
// Same rule as the provider list below: this response IS the login screen's
// source of truth, so a switch left on for a method the server cannot perform
// would draw the button anyway. The org's stored setting is untouched — only
// what the browser is told.
view.EnableCodeSignin = view.EnableCodeSignin && DeliveryConfigured()
kept := make([]*schema.ProviderItem, 0, len(view.Providers))
for _, it := range view.Providers {
if it == nil || it.Provider == nil || !offerable(it.Provider) {
continue
}
p := *it.Provider
p.ClientSecret = ""
p.ClientSecret2 = ""
item := *it
item.Provider = &p
kept = append(kept, &item)
}
return &masked
view.Providers = kept
return &view
}
+4 -2
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
@@ -9,6 +10,7 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/sessions"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
)
@@ -62,7 +64,7 @@ func TestSignin_CodeExchangeSetsSessionAndReturnsAccount(t *testing.T) {
}
// It establishes the durable session get-account resolves from.
cookie := resp.Header.Get("Set-Cookie")
if !strings.HasPrefix(cookie, "hanzo_session=") {
if !strings.HasPrefix(cookie, sessions.CookieName+"=") {
t.Fatalf("signin did not set the session cookie: %q", cookie)
}
req := formReqNoBody("GET", PathAccount)
+125
View File
@@ -0,0 +1,125 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
"testing"
"github.com/hanzoai/iam/pkg/schema"
)
// A login screen may only offer what can finish. These cases are the live estate
// at the time this was written: of FIVE providers on hanzo-app, exactly two could
// ever complete a sign-in, and the other three rendered as buttons anyway.
func TestOfferableOffersOnlyWhatCanComplete(t *testing.T) {
for _, tc := range []struct {
name string
p schema.Provider
want bool
}{
{
// The two that work.
name: "google",
p: schema.Provider{Type: "Google", Category: "OAuth", ClientId: "113591532635-real.apps.googleusercontent.com"},
want: true,
},
{
name: "github",
p: schema.Provider{Type: "GitHub", Category: "OAuth", ClientId: "Iv23li3SYLoq40ExR6EN"},
want: true,
},
{
// THE BUG. A real-looking client id passed the credential check, so the
// button rendered — and the authorize leg then refused it with
// "provider is not a supported federation type", because nothing can
// drive a GitLab that declares no OIDC issuer.
name: "gitlab without an issuer is not driveable",
p: schema.Provider{Type: "GitLab", Category: "OAuth", ClientId: "5a68c0e6b690f4b3cc92f9a95a4ad52b"},
want: false,
},
{
// THE ESCAPE HATCH, and the reason this is a capability test rather
// than a deny-list of type names: GitLab IS an OIDC provider. Declare
// the issuer and it becomes driveable here with no code change — the
// button comes back on its own.
name: "gitlab with an issuer is driveable",
p: schema.Provider{Type: "GitLab", Category: "OAuth", ClientId: "5a68c0e6b690f4b3cc92f9a95a4ad52b",
IssuerUrl: "https://gitlab.com"},
want: true,
},
{
// Never configured — placeholder credentials, both of them.
name: "apple placeholder",
p: schema.Provider{Type: "Apple", Category: "OAuth", ClientId: "placeholder"},
want: false,
},
{
name: "web3onboard placeholder under the OAuth category",
p: schema.Provider{Type: "Web3Onboard", Category: "OAuth", ClientId: "placeholder"},
want: false,
},
{
// Web3 proper never reaches the federation broker, so requiring a
// dialect of it would hide a method that genuinely works.
name: "native web3 needs no dialect and no client",
p: schema.Provider{Type: "Web3Onboard", Category: "Web3"},
want: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
if got := offerable(&tc.p); got != tc.want {
t.Errorf("offerable(%s/%s issuer=%q) = %v, want %v",
tc.p.Type, tc.p.Category, tc.p.IssuerUrl, got, tc.want)
}
})
}
if offerable(nil) {
t.Error("offerable(nil) = true; a missing provider is never offerable")
}
}
// The login screen's source of truth must not carry a method it cannot complete —
// and must not carry a secret. get-app-login previously answered with EVERY
// provider while auth/methods answered with the offerable ones; the browser read
// the unfiltered one, which is why the dead buttons were visible at all.
func TestLoginViewDropsUnofferableAndSecrets(t *testing.T) {
app := &schema.Application{
ClientSecret: "app-secret-must-not-cross",
Providers: []*schema.ProviderItem{
{Name: "provider-google", CanSignIn: true, Provider: &schema.Provider{
Type: "Google", Category: "OAuth", ClientId: "real.apps.googleusercontent.com",
ClientSecret: "GOCSPX-must-not-cross", ClientSecret2: "also-must-not-cross"}},
{Name: "provider-gitlab", CanSignIn: true, Provider: &schema.Provider{
Type: "GitLab", Category: "OAuth", ClientId: "5a68c0e6b690f4b3cc92f9a95a4ad52b"}},
{Name: "provider-apple", CanSignIn: true, Provider: &schema.Provider{
Type: "Apple", Category: "OAuth", ClientId: "placeholder"}},
},
}
view := loginView(app)
if len(view.Providers) != 1 || view.Providers[0].Name != "provider-google" {
var got []string
for _, it := range view.Providers {
got = append(got, it.Name)
}
t.Fatalf("loginView kept %v, want only [provider-google]", got)
}
if view.ClientSecret != "" {
t.Error("loginView leaked the application client secret")
}
if p := view.Providers[0].Provider; p.ClientSecret != "" || p.ClientSecret2 != "" {
t.Error("loginView leaked a provider secret")
}
// The source must be untouched: this is a VIEW, and the caller's application
// is shared. Masking or filtering in place would strip the running config.
if app.ClientSecret == "" || len(app.Providers) != 3 {
t.Fatal("loginView mutated the application it was given")
}
if app.Providers[0].Provider.ClientSecret != "GOCSPX-must-not-cross" {
t.Error("loginView mutated the source provider's secret")
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (

Some files were not shown because too many files have changed in this diff Show More