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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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.
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>
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>
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>
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>
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.
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>
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>
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>
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>
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>
`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>
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>
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.
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.
`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>
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>
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>
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>
.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>
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.
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.
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>
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>
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.
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>
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>
The lookup was a GET with the user_code as a path segment. A user_code is the
one secret in the device flow, and a request line is copied into ingress and
proxy access logs where a body is not — the approval page even ships scrubUrl()
to keep the code out of the address bar, so a GET undid that server-side.
POST for a read is the same call RFC 7662 introspection beside it makes, for the
same reason. The code now rides the body at a fixed path.
Caught by the id client while wiring it up; v1.34.2 shipped the GET form because
this change was left in the working tree when that tag was cut. It never reached
production behaviour — the portal that calls it has not shipped — but a tagged
release did not contain what its own commit claimed, so: v1.34.3.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The handler destroyed nothing. Every line computed a redirect and it answered
{"status":"ok"} unconditionally — no session ended, no cookie cleared, no token
revoked. A redirect helper wearing a logout endpoint's name. Anyone who signed
out on a shared or borrowed machine was still signed in, and had been told
otherwise, which is worse than no logout at all.
sessions.Clear is the inverse of Set and does BOTH halves, because either alone
leaves a live session: the sid is dropped from the Session row so a cookie
captured before logout is dead server-side, and the cookie is expired so the
browser stops presenting it. Server-side revocation is the load-bearing half —
expiring the cookie is cosmetic against anyone holding a copy of its value, who
is precisely the threat.
The relying party's grant is revoked too, family-wide: a refresh chain rotates
into new rows, so deleting only the rows found by (user, app) can leave a
rotated descendant alive and mintable. Revocation state is the authority — a
JWT's exp still reads valid days out, so expiry is necessary and never
sufficient.
Revocation requires a live SESSION, not merely an id_token_hint. A hint is a
token, not proof of present possession; revoking on one alone would let anyone
replaying a captured id_token tear down that user's grant.
The open-redirect guard is untouched: a redirect still happens only when a
signature-verified hint identifies the application AND that application
registered the target. Content negotiation is layered on top — a browser
navigation lands on a signed-out page instead of a raw JSON blob on a blank
screen, while any caller that expresses no preference keeps the JSON envelope it
parses today.
Tests fail against the old handler with the exact sentence describing the
defect, including one that catches a logout reporting success on a still-live
session, and one asserting a bare hint revokes nothing.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The approval page rendered the PORTAL's own application name — a per-brand
constant — for every device code. A code minted by hanzo-cli was approved on a
screen naming hanzo-console. The one job of a device-approval screen is to tell
you WHICH application you are authorizing; one that names the wrong app defeats
the control and manufactures the confidence it should be earning.
The client is a property of the CODE, so serve it from the code's row:
GET /v1/iam/oauth/device/:userCode answers the pending authorization's own
application. Gated on the session and sharing approveDevice's ONE opaque refusal
and its tenant boundary — the user_code is 40 bits and is the only secret in the
flow, so an unauthenticated or case-distinguishing lookup would be an oracle for
hunting live codes. It reveals strictly less than the approval the same caller
could already attempt.
CodeLoginRequired is the stable reason a caller routes on, replacing prose that
several causes shared. It also completes e9553147, which referenced the constant
before it existed and left main unbuildable.
This must land BEFORE the approval page's self-attestation checkbox is dropped
(id 5d411ff): the checkbox was an anti-phishing control, and naming the correct
client carries that protection far better than a tickbox users clear reflexively
— but only once the name is true.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The approval page posts no credential — by design, the human is already signed in,
which is the whole point of approving on a phone. With no session cookie the
request fell through to the credential check and answered "organization, username
and password are required": three fields that page does not have and will never
show. The only reading available to the person reading it is that their credential
was rejected, when what was missing was a sign-in on that browser.
The session branch already knows this case — it says "please sign in first" one
line up when the session resolves to a dead user. Now the no-session case says the
same kind of thing, and names the next step rather than three fields that do not
exist here.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
A branch push published an immutable sha-<7> alongside the tag builds. It still
BUILDS on a branch — that is the check that main compiles and the image
assembles — but it now pushes nothing.
Traceable was never the bar. A registry that accumulates a tag per commit makes
'what is released' a question you answer by reading git instead of by reading the
registry, and it is how production came to run sha-ba43c54: a commit newer than
the last built release and older than two tagged ones, so the estate's IdP ran
code that no version named.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The RFC 8628 terminal leg could not complete. login.go's session branch was
gated to type=code — deliberately, to keep "a device approval a deliberate act"
— so the approval page's credential-less type=device post fell straight through
to the credential check and answered
{"status":"error","msg":"organization, username and password are required"}
with HTTP 200, so nothing read as broken. `hanzo login` printed a QR and hung at
"Waiting for approval…" forever, on every brand portal at once.
The deliberate act was never the password. It is the human opening the
verification URI and transcribing the user_code their own device shows;
approveDevice binds the approver's proven identity onto exactly that pending
code, and a code nobody typed approves nothing. What the restriction actually
demanded was a full re-authentication from someone already authenticated — which
no device flow asks for, and which this page never sends.
No test caught it because every device test approved with organization +
username + password (approveAs), a shape the product never sends: the page
exists precisely because the human is already signed in. Both new tests drive
the real shape — session cookie, no credentials — and the first is
negative-controlled: reverting the gate reproduces the live message exactly.
The second pins that anonymous approval is still refused and binds nobody, so
dropping the type restriction cannot let a device be taken over by a caller with
no identity to bind.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
99e8bdfe added `code` to the response envelope and wrote its documentation;
fa8afb44 and 8fec8654 then moved store and schema. None of the three ran
`go generate`, so six zipdoc_gen.go files describe a surface that no longer
matches their source.
make test runs the staleness check as its FIRST step, before a single test.
Every push since has failed there and skipped the build job, so no image has
been published for any of the three commits — including the refusal-reason
fix itself, which is the one that cannot reach production without this.
Pure `go generate ./...` output, no hand edits: the new Response.code
description, the rewritten resolve-key prose, and gofmt realignment of the
map literals the added key widened.
go test ./... -race -count=1 -> 27 packages ok, 0 failures.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
pkg/store returns *schema.User and *schema.Key, and schema was internal. An
outside caller could invoke the resolvers and read their fields — Go's internal
rule restricts imports, not inference — but could not NAME the types, so it could
not declare a variable, write a helper, or seed a row in a test.
That last one is what made it a real boundary rather than a curiosity. Moving
cloud's API-key resolver off HTTP and onto this store means its tests should
exercise the store instead of a stubbed HTTP envelope — which is better coverage,
since it tests the actual query rather than a fake reply. Those tests must create
a user with an access key, and creating one requires the type.
So schema moves out with store. The model a caller receives is part of the API
that returns it: a package cannot hand back a type and also forbid describing it.
28 files, 149 importers, no name collisions, nothing renamed. Full suite green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
There were two. pkg/store held the project lifecycle; internal/store held
applications, users, tokens, api keys and memberships — 56 exported functions
against the same orm.DB, doing the same job, split only by which one a caller
was allowed to import.
That split is why cloud talks to IAM over HTTP. cloud embeds IAM in-process
(zip.Graft composes iamserver.NewApp), so its API-key resolver sits in the same
binary as the code that resolves an access key — and cannot call it, because
PublishableKeyByAccessKey and UserByAccessKey live under internal/. So it dials
http://iam.hanzo.svc/v1/iam/resolve-key: a network round trip from a process to
itself, forced by a package boundary rather than by a design.
Everything downstream of that hop is scaffolding for a call that should never
have left the process — the Cloudflare 403 on server-side POSTs to the public
issuer, the CLOUD_KMS_IAM_TOKEN_URL → IAM_URL → public-issuer fallback chain in
cloud's KMS broker, and the init() that panicked when iam.hanzo.svc was down and
took api.hanzo.ai with it.
One package now, at pkg/store. No exported or unexported name collided, so
nothing was renamed to fit; the two files that were both called store.go are now
named for what they hold — project.go for the project lifecycle, store.go for
the rest. All 79 importers move with it. Full suite green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
"the entity does not exist" was one sentence for causes that call for
opposite actions. A holder whose key was revoked went looking for a
deleted org instead of minting a new key, and a tenant admin's forgery
attempt — the same-tenant pin firing — was indistinguishable from a typo.
The reason is now a value (store.KeyFailure) carried beside the error
rather than baked into its text. KeyError unwraps to orm.ErrNotFound, so
every existing errors.Is caller keeps working unchanged and unaware.
Both doors enumerate honestly: the secret door distinguishes unknown /
wrong-door / foreign-user / dangling-user, and the publishable door
distinguishes unknown / not-publishable / expired — the trio cloud's own
test annotated while having no way to tell them apart.
The human `msg` is byte-identical; the reason rides as `code`. Nothing
that reads the prose can tell the causes apart, and the caller that
reaches it has already passed CapKeyResolve — it can resolve any key to
a full principal, so the code discloses nothing it could not obtain.
A store fault yields no reason at all, so infrastructure trouble is never
reported to a holder as a bad credential.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
`jwks := jwksHandler(db)` then Alias(…, jwks) puts a VARIABLE where the handler
goes, and a variable has no doc comment. Inlining the call restores the one place
the sentence lives. And the comment on the handler opened by restating its own
route — "serves GET /v1/iam/.well-known/jwks" — which is the raw-route fallback
written by hand, in the one place a fallback cannot be deleted from.
193 of 193 IAM operations now carry a description, every one lifted from a Go doc
comment in this repo. cloud's `make -C apps/iam describe` goes green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Nine legacy-verb pairs published silence at BOTH addresses, and the cause was a
two-line helper: internal/httpx.Alias registered canonical and legacy inside
itself, where a pass reading `router.Get(path, handler)` cannot see either. The
legacy half is the one a pinned consumer is likeliest to be calling, so it was
the worse of the two to lose.
zip v1.18.22 owns the primitive now — a matcher can only recognise a function
whose identity it knows — so internal/httpx/alias.go is deleted and the ten call
sites read zip.Alias. Both addresses carry the handler's sentence, which is
correct: they are one handler and mean one thing.
JWKS joins them. It was `jwks := jwksHandler(db)` registered twice, which is an
alias written the long way and lost its prose for the same reason.
And the four with no doc comment at all — the four highest-traffic operations in
the product, which is how they came to be the last ones:
/v1/iam/login what a person's password actually does
/v1/iam/oauth/authorize where every sign-in begins
/v1/iam/oauth/token and what your application exchanges at the end
/.well-known/jwks the one URL a service needs to verify a token itself
Each now says what it does AND what it costs to get wrong: a refresh token is
retired on use, and re-presenting a retired one revokes the whole chain, so a
stolen token buys one use and costs the session. That is worth a customer's
minute, and it was written nowhere.
193 operations carry a description, up from 167, 93, and 0 as published.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The typed CRUD describes itself now. The other 74 operations did not, and they
are the ones a customer actually meets: sign-up, sign-in, whoami, the OAuth
token/authorize/userinfo/introspect/revoke endpoints, device login, wallet login,
SCIM provisioning, MFA enrolment, service accounts, memberships, the registry
token door, and the legacy read aliases. Every one published an address and
silence, and cloud's producer gate refuses a document like that — correctly, and
it has been refusing.
These routes stay untyped because the WIRE says so, not because nobody got to
them: an OIDC redirect, a JWKS document, a SCIM body RFC 7643 governs, a
multipart form, a Docker registry token. zip v1.18.21 gives them the same seam
the typed ops have — the doc comment on the handler — so this is prose written
where the handler lives, not a table of strings in a host that does not own them.
What is rewritten is the VOICE. These comments were written for us:
"Serves GET /Users — owner-scoped, filterable by `userName|emails eq \"x\"`"
"implements RFC 7662. Active iff the grant row still exists"
"reports the resolved caller's identity in the casibase envelope"
A person paying for the Hanzo Cloud is not reading about our grant rows. They now
read what the operation does for them, and what it costs them to get it wrong:
revoking a refresh token kills every token minted from it; a SCIM replace leaves
multi-factor enrolment alone so a routine directory sync cannot strip somebody's
second factor; a service account's secret is shown once and never again;
publishable key resolution names an organization and never a person, on purpose.
Where a comment carried something for MAINTAINERS — the confused-deputy incident
behind authz.Scope, why a wrong factor burns the federation challenge — it moved
into the function body. A comment in the doc position is not a note; it is what a
customer reads.
Measured: 167 operations carry a description, up from 93 (typed only) and 0 as
published. Every one traces to a Go doc comment in this repo.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
zipdoc keys prose by the handler it is attached to. The paragraph describing the
delete — 'anyone mid-sign-in through it is turned away' — was written above
Delete, the thin legacy-envelope wrapper, while every route registers
deleteApplication. So the emitted document carried an empty Doc{} for the delete
and the only operation in the kind with nothing to say was the destructive one.
Moved onto the handler the routes actually name. 0 undescribed operations in the
emitted document, down from 2.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
`/v1/iam/application` and `/v1/iam/applications` both answered, and which one a
reader wanted depended on the operation: the plural listed, the singular got,
created, updated and deleted. Every other kind in this service — users, certs,
roles, invitations, keys, projects, workspaces, permissions, providers, tokens,
sessions, organizations, audit-logs, webauthn-credentials — is addressed in the
plural with `/get`, `/update` and `/delete` under it. Fourteen against one is
not a matter of taste.
So applications moved to the house shape. The singular address stays reachable
on the SAME typed handlers, tagged `compat`, which is what keeps it out of the
published document and therefore out of every SDK, docs page and CLI command —
the same seam the nineteen legacy entity verbs already use. It is deleted when
the last pinned consumer moves.
make test green (-race -count=1).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
IAM published 179 operations on api.hanzo.ai and not one of them carried a
description. 94 carried a WithSummary("…") string; 85 carried nothing at all.
Everything downstream inherited that: the OpenAPI document, every generated
client, the MCP tool list an agent reads to decide whether to call something, and
`hanzo iam …`.
The prose was not missing. It was written, on the handlers, and no generator ever
read it — this repo has never run zipdoc. So:
* `//go:generate go run github.com/zap-proto/zip/cmd/zipdoc` in each of the 16
packages that register typed ops, the committed zipdoc_gen.go beside it, and
`make generate`. `make test` now runs `zipdoc -check` FIRST: a doc comment
edited without regenerating is a red build, not a quietly stale artifact,
because the failure mode here is silence and silence never rings.
* 75 `zip.WithSummary("…")` calls deleted. Each sat beside a doc comment saying
the same thing — two places to change and one to forget. The summary is now
the first sentence of the comment, so there is ONE source and it is the one a
reviewer already reads.
* Every one of the 93 rewritten for the person paying for the Hanzo Cloud
rather than for us. "Persists a new user, hashing the plaintext password"
became "Adds a person to your organization and, if you send a password, sets
the one they will sign in with. The password is hashed before it is stored
and is never returned." No in.Owner, no orm, no v1-parity notes, no
read-modify-writes. Where a sentence had to say something to MAINTAINERS —
the confused-deputy incident behind authz.Scope, the compat grouping — it
moved into the body or the Route doc, because a comment in the doc position
is not a note, it is what a customer reads.
This needs zip v1.18.19: 34 of these are registered as `listProviders(db)` and 11
as multi-line inline closures, and zipdoc could read neither until that release.
Measured, on the generated files: 93 of 93 typed ops carry a description, up from
50 of 84 when zipdoc first ran here and 0 of 179 as published.
Still open and stated rather than papered over: IAM's 85 UNTYPED fiber routes —
the OIDC surface, SCIM, MFA, service accounts, memberships and the legacy read
aliases — have no prose channel at all. zipdoc lifts from typed registrations
only, so those need either typing or a doc-comment channel for raw handlers.
Nothing here invents a sentence for them.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Nine addresses spelled the verb twice: send-verification-code, set-preferred-mfa,
get-account, get-app-login, update-preferences, issue-user-token, mint-user-keys,
revoke-user-keys, delete-mfa. That spelling arrived with the entity store this
service replaced, and it is not an internal detail — it is what a customer reads
in `hanzo iam --help`, what every generated SDK turns into a method name, and
what every docs page prints.
Each now answers at a canonical noun as well: account, auth/application,
preferences, verification-codes, tokens/issue, keys/mint, keys/revoke,
mfa/disable, mfa/preferred. httpx.Alias registers ONE handler value at BOTH
addresses — no second implementation to keep in step, and no forward that could
answer differently from the thing it forwards to. The legacy spellings stay
reachable, so the console BFF, the gateway admin-api and the hanzo.id portal keep
working; they are simply what nothing teaches. When the last pinned consumer
moves, the Legacy* half of a pair is deleted and nothing else changes.
naming_test.go is the gate, and it reads the WHOLE router rather than one
package's subtree — server.NewApp's route table, every address the binary
actually answers at. It found set-preferred-mfa, which I had not enumerated. The
frozen list only ever shrinks: a new verb-noun fails at the commit that
introduces it instead of surfacing years later as a command name in somebody's
terminal.
make test green (-race -count=1).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The one builder for ghcr.io/hanzoai/iam answered only to `push: branches:
[main]` and unconditionally tagged its output `sha-<7>`. So it could not
publish a semver image at all, by construction, and `git tag` published
nothing.
Measured: v1.33.32, .33, .34, .35, .36 and .37 are all real tags on main and
NONE of them has an image (404). The last release that does is v1.33.31.
Production ran `ghcr.io/hanzoai/iam:sha-ba43c54` — newer than the last BUILT
release and older than two tagged ones — so the estate's identity provider
was running code that no version names, and the five commits between it and
v1.33.37 include "ROPC requires a confidential client, so going public cannot
open it" and the fix behind "why no terminal could sign in".
Two changes, both minimal:
on.push.tags: ['v*'] a tag push now builds.
meta a tag push publishes the TAG; a branch push still
publishes an immutable sha-<7>.
Both outputs stay traceable and only the first is deployable — the estate's
guards require a semver tag (or a digest) in any manifest. A non-`v` tag
falls through to sha-, so an odd tag name cannot publish a bogus version.
This step also feeds `-X main.version`, so a release binary now reports the
same string as its image instead of every build calling itself a commit.
Owner directive: everything we publish carries real semver, no ad-hoc tags.
This is the repo that most needed it.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Five of the 19 legacy write aliases claimed the SAME operationId as the REST
twin they delegate to: addProvider, updateProvider, deleteProvider,
updateOrganization, deleteOrganization each named two distinct operations at
two distinct addresses. OpenAPI requires an operationId to be unique — one id,
one operation — so every generated client would bind whichever it read last,
and a document containing both is refused outright by any weave that checks.
It was invisible because the whole surface was published behind a wildcard.
Grafting iam into a host renders its ops into the host's document, and the
uniqueness check refused on the first of the five immediately.
The fix is one rule, applied to all 19 rather than patched on five: an alias
delegates to the canonical op, so the only thing that distinguishes the two IS
the address — let the address name it, via zip's path-derived default
(post_v1_iam_update_provider). Naming them by hand restated what the path
already said. The canonical REST op keeps its hand-picked SDK name.
No published SDK method changes: these 19 have never appeared in any generated
client, because the host published a wildcard where they were.
iam op names 89 -> 94 (five names that were one are now five).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
server.Handler was adaptor.FiberApp(NewApp(db).Fiber()) — the one place in
cloud's whole module graph where a zip App was erased into an http.Handler.
A host hung it on app.All("/v1/iam/*", zip.AdaptNetHTTP(h)), and iam's 94
typed ops died at that closure: cloud published five wildcard path keys and
35 placeholder operations where 78 real paths and 94 typed operations were,
with no schema, no MCP tool, no CLI command and no SDK method for any of them.
zip v1.18.16 adds Graft, which composes the App itself: the host's router
learns iam's patterns AND its registry while iam's router keeps iam's
behaviour — the Use(authz.Guard) seam, the error handler, the config. NewApp
is the graft target and needs no adaptation, so Handler is deleted rather
than kept as a second way to embed.
Liveness is not iam's. zip/ops.go states the rule: /healthz, /readyz and
/metrics are a SECOND listener the DEPLOYMENT brings up when it names
OPS_PORT, never the public one — a liveness probe must not queue behind
public traffic. routes.Route registered /healthz on the public group, which
was iam hand-rolling a path the framework owns on the wrong listener, and it
is what made iam un-composable: a host registers /healthz as the HOST's
because it must answer while every subsystem is still cold. Two claimants on
one liveness address is what once served {"binary":"iam2"} out of a shared
binary. One address, one owner, and this is not iam's.
The two authz tests that named /healthz now name only routes that exist —
a public-route assertion against a path with no route passes vacuously.
zip v1.17.7 -> v1.18.16 (the version cloud already resolves to).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
A CLI cannot hold a secret, so `hanzo-cli` has to register with none — that
absence is how IAM says "public", and it is what lets the device grant run at
all (a stored secret the client cannot present is `invalid_client` at
/v1/iam/oauth/device, which is why `hanzo login` was dead).
The same flip silently made ROPC reachable WITHOUT a credential. The password
grant let a public client through by design — a legacy-parity relaxation carried
so console/chat logins would not 401 during the clean-room cutover — and the
stored secret was the only thing that had ever gated it. Unlike the code and
device grants, ROPC has neither a PKCE challenge nor a human approval step to
authenticate the caller in the secret's place, so "public" there means anyone
who knows the client_id can post a username and password: a credential-stuffing
oracle against every user in the tenant, and a lockout lever against any named
one.
The relaxation was dormant — every live registration is confidential and takes
the secret path, measured against production — so requiring a confidential
client breaks nothing that works today and closes the surface for good. The rule
lives in the grant, not in a provision document, because registration shape must
not be able to open a credential surface.
Tests: a public client is refused with the CORRECT password (the refusal is the
client rule, not the credential); the lockout, org-boundary and forbidden-user
gates keep their own coverage, now reached by a fully authenticated caller.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The note claimed "every other app still carries refreshExpireInHours: 0". Wrong,
and wrong in the direction that hides the bigger win. Read off hanzo.id's store:
most first-party clients already carried the v1-era expireInHours 168 +
refreshExpireInHours 720, so for hanzo-cloud, hanzo-chat, hanzo-platform and
hanzo-world the lifetime was never the problem — they held a 30-day refresh token
they could not SPEND, because refresh demanded a secret their PKCE surface does
not have. One fix unblocks all of them, and each was driven after the change:
code->token 200 then refresh 200 with a new access token, no secret at either step.
hanzo-cli was the rare client with BOTH lifetimes at 0, which is why it was the
one that hurt. Still at 0: hanzo-git, hanzo-zrok, hanzo-admin, and every
auto-created per-signup app-<email> client. hanzo-mcp was too — same loopback
PKCE shape as the CLI, same dead refresh — and is now declared alongside it.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
`hanzo cli` reopened a browser login every hour. Two independent defects, either
of which alone is fatal to refresh_token, and both measured on hanzo-cli today.
CLIENT AUTH. authorizationCodeGrant carries a documented relaxation: a
registration that HOLDS a secret can still serve a public PKCE surface — hanzo-cli
and every @hanzo/iam SPA whose secret exists only for a backend path — so a code
exchange presenting no secret is authenticated by the PKCE binding instead.
refreshTokenGrant had no such relaxation and required the secret unconditionally.
The client completed the exchange without one, cannot acquire one, and was
refused the moment it tried to renew:
POST /v1/iam/oauth/token grant_type=refresh_token client_id=hanzo-cli
→ 401 {"error":"invalid_client","error_description":"client authentication failed"}
The value that decides this is a property of the GRANT, not of the registration,
and it was being computed at establishment and thrown away. schema.Token.
PublicGrant records it and refresh honours it. It is copied onto the successor
row, because a rotation that drops it makes only the FIRST refresh work and 401s
the second — a session that dies an hour late instead of on time. It never
widens: a grant established WITH the secret still needs it, a presented secret is
always verified, and a code with no PKCE challenge is untouched.
LIFETIME. refreshTTL falls back to appTTL when RefreshExpireInHours is unset, so
the refresh token expires at the same instant as the token it exists to renew —
the advertised grant is dead on arrival. Nothing could say otherwise: the upsert
body (the ONLY supported admin write path, and what `iam provision` converges
through) carried no lifetime field at all, and the create path hardcoded
ExpireInHours=1. expireInHours/refreshExpireInHours now travel document →
provision.App → upsert → model under ONE name. POINTERS on the wire so an omitted
lifetime PRESERVES: a plain float sends 0 on every steady-state reconcile and
resets the lifetime it just set, the same accident IsShared is a pointer for.
provision.checkLifetimes REFUSES a refresh lifetime that does not outlive the
access lifetime, so the state hanzo-cli shipped in cannot be declared again. The
rule is total — an unstated access lifetime is schema.DefaultExpireInHours, which
is now the ONE home for that default (oidc.appTTL and the upsert's create path
both read it; three copies of "1" is how a refresh token comes to be born
already expired).
NOT changed: the refreshTTL fallback itself. Session lifetime is POLICY and this
mechanism ships none — every other app fixes it with one line in its own org's
provision document.
Tests are the failure, not the fix: reverting the refresh relaxation reproduces
the live 401 verbatim, and reverting the rotation copy fails the second refresh.
Also covered — a secret-established grant still needs its secret, a wrong secret
is still refused, lifetimes round-trip and an omitted one preserves, and four
shapes of unusable refresh lifetime are rejected at Derive. 28 packages green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
claims_supported advertised `preferred_username` for a year while no token emitted
it, and that gap is exactly why consumers went on reading `name` as a display
name. It now lists `displayName` too, so the document says what a token actually
carries rather than what it might.
LLM.md gets the two rules a reader has to know before touching either surface: a
principal is owner + USERNAME on every surface, and what may be a username is
decided in one place.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Ten entry points reach a user row. Exactly ONE of them validated the name it
wrote — self-service signup, via a policy function with a single caller. SCIM
create, the legacy add-user verb, the operator's bootstrap upsert, the typed CRUD
create, the embedder seam and the wallet/service-account writes took whatever
bytes arrived. Anything a JSON string can hold could become a principal that way,
including a human's display name with a space in it — which is precisely the
account everyone had to go and check for after a token minted `name:
"Zach Kelling"`. The token was the defect; that it took an investigation to rule
out the other explanation is the defect underneath it.
So what may be a username is now a property of the entity, in schema.Username:
trim, lowercase, `^[a-z0-9][a-z0-9._-]{0,62}$`. Normalization settles case and
padding, which carry no meaning; everything else is REFUSED rather than rewritten,
because quietly turning what someone typed into a different principal is the
failure being avoided, not a convenience. Two deliberate differences from the
policy it replaces: a leading digit is allowed (nothing resolves a principal
numerically), and one character is allowed — the account this was written over is
named "z", which the old two-character minimum could not have created.
Every creation path calls it. users.Create is the choke point six of them already
share, so the rule lives at the write rather than at six doors; CreateInput's
AuthzTarget normalizes too, so the pair AUTHORIZED is the pair STORED and
authorization cannot sit one principal away from the write. The three paths that
write through orm directly — bootstrap's first-admin seed (which predates any
principal), the wallet identity, and the onboarding credential — state it
themselves. Service accounts stop re-enumerating the charset and keep only what is
actually theirs: `<org>-` binding, and segmentation (a handle that gets read back
apart must not name an empty segment).
Social signup derives from the ADDRESS, never the profile. schema.Handle takes the
email local part, and it refuses a string with no "@" and a local part with
whitespace — without both, "Zach Kelling" is just a local part whose space gets
dropped and the profile name silently becomes the username "zachkelling". The
display name reaches DisplayName and stops. Dedupe is a numeric suffix on the name
a person would have chosen (z, z2, z3), replacing a random 8-hex suffix on EVERY
name ("z-3f9ab21c") that made collisions impossible by making every username
unrecognisable.
Case stops making a second person. Lookups resolve exact, then folded, then over
the org for a legacy mixed-case row — and FAIL CLOSED when the fold is ambiguous,
the same rule GetUserById applies to a duplicated subject, so whoever registered
"ALICE" alongside "Alice" can never be resolved as the other. Stored names are NOT
rewritten: renaming moves real principals, so the resolution tolerates case
instead. users.lookup goes through store.GetUserByName rather than repeating the
query, which is how Create's uniqueness check had stayed case-sensitive while the
rule it guards is not — it would have admitted "Alice" next to "alice".
maxOrgSlug 60 → 55 so `<slug>-default`, the credential onboarding derives from a
slug, still fits the 63-character username bound by construction rather than by a
check that fails at the end of onboarding.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
`hanzo auth login` files its credential under `owner`/`name` read straight off
the minted token, so those two claims ARE the principal every downstream surface
believes it holds. Reproduced today on iam.hanzo.ai: the authorize/code exchange
for client_id=hanzo-cli returned an access token carrying
owner = hanzo name = "Zach Kelling" preferred_username = z
and the CLI filed `hanzo/Zach Kelling` — an account that does not exist, a human
label with a space in it. Every surface reading `name` then named the wrong
principal.
userClaims (token.go) computed `name = DisplayName, else Name`. That is OIDC's
display reading of `name`, inherited from the v1 Userinfo struct, and it has been
the behaviour since the in-tree server was written (73b7ef63e). It was not wrong
by accident — it was wrong by CONTRACT, because ours says owner=org, name=username
and nothing else addresses a principal. cloud's money path had already paid for
it: it addresses a wallet `<org>/<username>`, read `name`, addressed
`hanzo/Zach Kelling`, and 402'd every completion while the balance sat in
`hanzo/z`. 5c0ea823f answered that by ADDING preferred_username and deliberately
leaving `name` alone, which gave the username a home without evicting the display
name from the claim consumers actually read. The CLI then hit the same wall from
the other side.
So `name` is the username, always; preferred_username is the OIDC-standard
spelling of the SAME field (cloud reads it, discovery advertises it, and sourcing
both from one field is what stops them drifting); and the display name moves to
`displayName` — the spelling schema.User, SCIM and whoami already use — where
nothing resolves an account from it. UserInfo answers identically: it and the
token describe one principal, and a client holding either must not get two
different names for it.
Three mint paths — the code/refresh/password grant, the console's
issue-user-token, and the RFC 8693 exchange — had each SEPARATELY written the
`DisplayName, else Name` fallback, so fixing one would have left two. They now
share identityOf, the one user→claims resolution.
The values also stop travelling as six adjacent positional strings, two of them
human-readable and therefore swappable at the call site. They were swapped, on
all three paths, and it type-checked; the wallet harness had already lost a scope
into the username slot the same way. Identity gives each value a name, and
Signer.claims is the single place one becomes a claim set, so `name`,
`preferred_username` and `displayName` cannot be filled differently by one path
than another. A machine token's principal is the app, so its username is the app
name; a profile-less token omits the claims rather than emitting them empty.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The seam doc advertised hanzoiam/scim as one of the modules that plug in here.
There is no such plugin any more: SCIM 2.0 is served by internal/scim at
/v1/iam/scim/v2, wired in routes.Route, behind the Guard and org-scoped by
authz.Scope. hanzoiam/scim was a second implementation of the same bounded
context that nothing ever imported, and it has been retired. Pointing at it left
two documented ways to get SCIM when only one exists.
Names the criterion instead of leaving it to be guessed. What belongs OUT of the
core is provenance and only provenance: this tree is clean-room, so a
Casdoor-derived implementation stays in a hanzoiam/* module with its Apache-2.0
attribution. SAML and LDAP qualify; anything written fresh does not.
Corrects the reason the earlier draft gave for LDAP. It claimed goldap is GPL-2.0
with no linking exception and that a separate module isolates that copyleft. The
pinned goldap (v0.0.0-20240304151906) is MIT, as are hanzoai/ldapserver and its
upstream — and a separate module would not isolate copyleft anyway, since Go
links statically.
Records the two things the seam does not give a module, because both are load
bearing and neither was written down: the Guard is mounted on
routes.guardedPrefixes rather than on the app, so a module that registers outside
them is unauthenticated (that is exactly how the retired SCIM plugin, mounted at
/scim/*, would have shipped an open user-CRUD surface); and Route is really
"activate" — hanzoiam/ldap takes the app as `_` and binds TCP listeners. Rename
it Start when a composing binary first exists.
Doc-only. go test ./... -race green, unchanged from the v1.33.32 baseline.
Retraction is per-module. The root go.mod withdrew the Casdoor versions of
github.com/hanzoai/iam at v1.32.0, but it cannot reach the submodule path
github.com/hanzoai/iam/pkg/iam — the in-process Embed + Mount entry points,
which only ever existed on the Casdoor side. That left one Casdoor-lineage
module still resolvable under the canonical prefix: `go get` it today and you
get Beego, xorm and iam v1.31.17 from a path that looks like v2.
Deleting the pkg/iam/* tags would not fix it. proxy.golang.org has all seven
cached (@v/list returns them, @latest resolves v1.18.6) and module versions are
immutable there, so deletion only splits the world — proxied resolvers keep
serving Casdoor code while direct-VCS resolvers 404. Retraction reaches both.
The range is self-inclusive, so every version of the path is withdrawn and the
tombstone carries no package: `go get .../pkg/iam` now fails loudly instead of
silently handing back the old lineage. The code is preserved byte-identically
at github.com/hanzoai/iam-v1/pkg/iam (same SHAs, all 7 tags).
The lineage guard becomes table-driven rather than growing a second copy — a
new published module path is a new row, not a new test function.
.gitignore listed `iam` unanchored, which matches every path component named
iam at any depth and was silently hiding pkg/iam/ from git.
hanzoiam/scim was a SECOND SCIM 2.0 server over the same identity store —
Casdoor-derived, mounted at /scim/*, and by its own package doc carrying no
authorization ("the SCIM package itself carries no auth, so none is added
here"). Its GetAll read GetGlobalUsers unscoped and every item verb resolved by
id alone, so composing it would have added a cross-tenant user CRUD surface
beside this one. It had no consumers and no tags. This is the surviving way; the
other repo goes.
What it genuinely had that this did not, ported here with the scoping intact:
externalId now round-trips. It is the provisioning client's OWN key for the
record — schema.User declares the column and this surface accepted the
attribute and dropped it, so an IdP could not correlate what it wrote and
every sync looked like a new user.
profileUrl and addresses map to Homepage / Location+Region+CountryCode.
A single address is persisted, so a multi-valued write collapses to the
primary — the rule emails and phones already follow.
Discovery: /Schemas, /Schemas/{id}, /ResourceTypes, /ResourceTypes/{name}
(RFC 7644 §4), beside the /ServiceProviderConfig already served. A connector
reads these before it will configure.
userType is deliberately NOT writable, and that is the reason this port is not
a straight copy. schema.User.Type is the IDENTITY-CLASS discriminator, not a
profile label: Type == "service-account" is what serviceaccounts.is() tests
before handing out or rotating a pk-/sk- credential, and the class
oidc/provision.go mints a tenant's default credential as. Honouring a
client-supplied userType would let anyone who can provision a user reach an
identity class IAM otherwise hands out only through its own gated route. It is
advertised mutability:readOnly (RFC 7643 §7 — ignored on write) and projected on
read. TestRed_userType_cannotMintServiceAccount pins create, PUT and PATCH.
The tenant still comes from authz.Scope and nowhere else: the standard
enterprise extension's free-text `organization` (RFC 7643 §4.3) is not honoured
as an owner, which is what the deleted module did.
The schema document is not a hand-kept second copy of the wire shape —
TestSchema_matchesWireStruct reflects over scimUser and fails the gate if the
two diverge in either direction, top level and sub-attributes.
Gate: go test ./... -race -count=1 green, 0 failures, same as the baseline.
github.com/hanzoai/iam published two implementations. Every tag below v1.32.0
carried the Casdoor-derived tree (Beego/xorm, controllers/); v1.32.0 and above
carry this one (zip/orm, internal/). Same import path, no signal, so
`go get github.com/hanzoai/iam@v1.31.28` swapped lineage and still compiled.
Deleting those tags does not fix it. proxy.golang.org caches module versions
immutably and already serves 506 of them — measured: v1.31.28.zip is 5.8 MB of
controllers/ and iamserver/beego.go under this exact path. Deletion would only
change resolution for GOPRIVATE/direct resolvers (this fleet), while breaking
rebuilds of tags that still pin those versions, e.g. deployed visor v1.108.12.
retract reaches every resolver, proxied or direct. Measured against a file
proxy: the Casdoor versions vanish from `go list -m -versions`, a pinned one
reports "(retracted)", an explicit get warns with the rationale, and
`go get @latest` upgrades v1.31.28 => the current release.
The versions themselves stay reachable, byte-identical, at
github.com/hanzoai/iam-v1 (all 178 tags verified same SHAs).
Two build systems were pushing ghcr.io/hanzoai/iam. The Casdoor-lineage GitHub
Actions builders are being deleted from all 133 branches that carried one; this
is what the surviving builder needs so that deletion is a strict improvement
rather than a trade.
GATE. docker-deploy.yml declared `docker: needs: [go-tests, go-build,
frontend-build]` — its image push was gated on tests. This file had no gate at
all, so removing the other lineage would have left ONE builder that ships
whatever compiles. That is worse than the duplication being fixed. `build` now
needs `test`, which runs `make test` — the repo's single declared gate
(`go test ./... -race -count=1`), named rather than inlined so a human and CI
run the identical command. Private-module fetches use the same GH_PAT the image
build already mounts as GIT_AUTH_TOKEN: one credential, two consumers.
VERSION. The Dockerfile has carried `-X main.version=${VERSION}` all along, but
nothing ever overrode its `ARG VERSION=dev`, so `/iam version` on the live pod
printed `iam dev` and the running binary could not name its own lineage — the
one question worth asking while two lineages shared an image name. The build-arg
now carries the same sha-<7> string used as the image tag, so `iam sha-d2aa268`
names the exact artifact with no second identifier free to drift.
Verified: `make test` green on eb83277c before the change (unchanged by it);
`-X main.version` proven end to end — `iam dev` without the arg, `iam sha-eb83277`
with it.
This repo had no test target at all, so "run the tests" meant whatever each
person typed. `make test` is now the one command, and it is not a bare
`go test ./...`: that reuses cached PASS results, so a stale build reports green
for code you just changed, and it runs without the race detector, which is where
this repo's store and session defects surface. -count=1 defeats the cache.
Adding -race exposed the reason nobody had: 8 failures, all `i/o timeout`, all on
argon2id paths (signup, onboard, registry token, SCIM create), in a different
package each run. fiber's App.Test defaults to TestConfig{Timeout: time.Second}.
The detector costs roughly an order of magnitude, `go test ./...` runs packages
in parallel, and one second is simply not a valid budget for a deliberately
expensive KDF under that load. Each suite had inherited the default separately —
23 call sites, 19 files, none of them having chosen it.
That is the worst failure mode a gate can have. It is red without a defect, so it
teaches everyone to re-run until green, and a real regression hiding in the noise
gets re-run away with it.
internal/testhttp states the ceiling once — generous enough that only a genuine
hang trips it, bounded so a deadlock still fails rather than blocking forever —
and every suite drives the router through it. The KDF cost is untouched: it is
the security property, and lowering it for tests would be testing something else.
Verified: `make test` green end to end, and 0 remaining direct Fiber().Test calls
so no suite can drift back to its own timeout.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
authz.Scope discarded the owner it was given. For any non-super it returned
p.Org, whatever had been asked for. Reproduced twice against production with the
hanzo-console credential (home org hanzo):
GET /v1/iam/get-users?owner=hanzo 200 ok 262 rows owner=hanzo
GET /v1/iam/get-users?owner=lux 200 ok 262 rows owner=hanzo
GET /v1/iam/get-users?owner=zoo 200 ok 262 rows owner=hanzo
GET /v1/iam/get-users?owner=nonexistent-xyz 200 ok 262 rows owner=hanzo
Nothing in the status code, the `status` field, the msg or the count said the
filter had been dropped, so a fabricated org was indistinguishable from a real
one and from your own. No rows escaped IAM — the pin held — so this was not a
confidentiality breach here. It was MISATTRIBUTION, which is worse in one
specific way: you believe you hold tenant B while holding tenant A. An operator
asked for lux, received 262 hanzo accounts with every surface signal reading
success, and was one filter-and-delete from purging the wrong tenant.
Downstream it WAS a leak. cloud/iam_edge.go validates ?owner= against the
calling tenant and then forwards it under ONE confidential client, so every
tenant's team page asked for its own org and was served the edge credential's
org. A pin that lies composes into a breach; a refusal cannot.
THE RULE, in the one place it lives (authz.Scope, 17 call sites resolve here):
a SuperAdmin is bound to the owner it names; everyone else is bound to its own
org and may say so — its own org, or none, is honoured; any other org is
refused. An empty p.Org is refused too: "" resolved to "no filter", i.e. every
tenant's rows, the branch TestListRoutesNeverLeakAnotherTenant exists to shut.
NOT AN EXISTENCE ORACLE, by construction rather than by care: the decision is
taken from the verified principal alone and never touches the store, so lux
(real), built-in (reserved) and nonexistent-org-xyz (invented) are the same
comparison and the same bytes; the message names the CREDENTIAL's org, never the
requested one. Same collapse cloud's per-org KMS store makes — every spelling you
may not have routes to ONE existence-independent answer. It differs only in
which answer: KMS reads the org from the token, so absence is its only
observable and it answers 404; here the org is a stated parameter, so there is
an authorization decision to report, and reporting it is the point.
get-users and get-organization now agree on the only question carrying a secret:
for every principal without a cross-tenant grant both refuse a foreign org
existence-independently. For a CapOrgAdmin holder — the brand consoles, which
create customer orgs and read Organization.Founder to resume a partial one — org
existence is not a secret, and a grant HONOURS the org it names, returning that
org's row correctly attributed. What can no longer happen anywhere: being handed
org A's rows in answer to a request that named org B.
Four surfaces were silently rewriting, three of them worse than the reported one:
get-organization-projects/-workspaces ?organization=lux -> hanzo's projects,
reachable by any ordinary org admin, no client credential needed
SCIM GET /Users/lux/alice -> 200 carrying HANZO/alice, a different human under
the requested identity's URL
SCIM POST /Users naming owner=lux -> 201 Created inside hanzo, a provisioning
call that named one tenant landing the account in another
A REWRITE IS NOT A SAFE ANSWER, ONLY AN UNSAMPLED ONE. read_scope_test.go proved
foreign-exists and foreign-missing were both 404 and called the oracle closed. It
was — the re-pin turned /Users/orgb/bob into a lookup of hanzo/bob, absent. Seed
a hanzo/bob, a name every tenant has, and the same request returns 200 carrying
hanzo's bob under orgb's URL; PATCH active:false then deactivates a hanzo
employee. That case is now pinned, and the old 404 message ("User hanzo/bob not
found") disclosed the redirect in its own text.
Refusals leave through authz.Deny — the same refuse() shaping the Guard uses, so
one refusal looks the same whether raised before the handler or inside it.
httpx.Err would have sent HTTP 200 carrying {"status":"error"}, which is how a
refusal gets logged as a success.
BREAKING for a caller that passes a foreign owner today and silently receives its
own data. Audited across the fleet; each is wrong today and now fails loudly
rather than quietly: cloud's iam_edge (mounted only when IAM is not in-process),
the casdoor-derived SDKs that bind ?owner= to a config org rather than the
credential, and console's get-organization (already failing).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Hanzo IAM is original work. The disclaimer listed a vendor name among the
licenses this source does not contain — naming a codebase that was never
part of it. The retired fork was iam-v1, not this repo. The clean-room
statement itself is unchanged.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Every remaining reference in this repo renamed. The vendor name was describing
OUR behaviour in 64 files — "Casdoor-derived", "the Casdoor shape", "Casdoor verb
spelling", "Casdoor error shape" — and that framing is what sends the next reader
to upstream documentation to answer a question about this codebase. It cost me
hours today debugging a 403 against the wrong authorization model.
The compat surface is still described accurately, just natively: "legacy verb
aliases", "the legacy envelope", "legacy v1 database". Those aliases are being
removed, and until they are, they need to be documented as what they are rather
than as who wrote them.
ONE reference is deliberately kept, in LICENSE:
This is a clean-room implementation. It contains no Casdoor, Apache-2.0, or
other third-party licensed source code.
That is a legal provenance assertion, and its whole function is to name what this
code does NOT contain. Removing the name would weaken the claim to "contains no
third-party code", which is both vaguer and less defensible. A clean-room
statement has to name the thing it is clean of.
Build green, 25 packages pass.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
zip v1.10.0 -> v1.17.7. v1.17.1 taught a typed op to read the whole URL
("a typed op reads the whole URL, not half of it"): until then a typed GET bound
NOTHING — no query, no path params — so every typed GET handler ran on a zero
input. That is the seam behind the tenant leaks fixed in the previous two
commits, and it is why seven legacy verbs had no working native replacement:
GET /v1/iam/applications?owner=x answered 400 "field Owner is required" because
the owner never arrived.
Bumping it exposed a second, older split. entityNoun pluralised a path segment
ONLY after stripping a legacy verb prefix, so:
/v1/iam/get-application -> "applications" matches the app self-read clause
/v1/iam/application -> "application" matches nothing -> reserved-owner -> 403
A relying party could read its own application row over the legacy verb and was
REFUSED over the native route — one policy giving two answers, decided by
spelling, which is precisely what kept cloud on the compat surface. Every clause
is written in the plural (applications, certs, projects, users, organizations,
keys), so the fold now applies to every segment; the only segments it newly
changes are the singular natives, and each folds onto the entity it already is.
Two test expectations updated because they encoded the bugs, not the contract:
- selfread_compat asserted 400 for the noun-surface LIST, with the comment
"reaches the handler = authorized". It reads 403 now, and 403 is right:
ApplicationQuery carries only Owner, so ?name= is ignored and the request asks
to enumerate every application under the reserved admin org. The 400 was a
shape complaint landing before authz could refuse. The case that actually
mirrors the compat verb — the SINGLE application by natural key — is now
asserted at 200, and fails at 403 if the fold is reverted.
- entityOf("/v1/iam/application") asserted "application". That singular WAS the
split.
27 packages pass.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Same confused deputy as the previous commit, found by extending its test rather
than by reading more code — which is the point of writing the test first. Both
listers filtered on in.Owner, and a typed GET binds nothing, so both took the
"empty owner lists everything" branch on every REST call:
GET /v1/iam/tokens?owner=hanzo -> orgb's tokens
GET /v1/iam/webauthn-credentials?owner=hanzo -> orgb's credentials
These are the most sensitive rows on the surface, so they are worth being precise
about: the leak is row METADATA (owner, name, timestamps, the credential
inventory) — not token secrets or credential material, which the schema does not
put on the list path. Still a full cross-tenant inventory of who holds what.
Both now resolve the owner through authz.Scope, matching certs and the six fixed
previously. The audit is complete for this class: every lister with the
"empty owner => no filter" shape is either principal-scoped or fails closed
("owner is required" — keys, permissions), and applications/users carry
validate:"required" so they refuse rather than widen.
organizations keeps the shape deliberately and is now covered by the test as a
REFUSAL case: it is the tenant registry, the one documented exception to the
reserved-owner gate, and the route is SuperAdmin-only. If a change ever opens it
to tenants, that case fails instead of quietly listing.
Falsified per route: revert either handler and the test names the leaked row.
25 packages pass.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
A confused deputy, invisible from either half alone. The Guard authorizes on the
query string — asking for a foreign org is correctly refused — and then the
handler filtered on `in.Owner` rather than on the principal. A zip typed GET
binds NOTHING from the request (a body is read only for non-GET), so `in.Owner`
arrived EMPTY on every REST call, took the "empty owner lists everything" branch,
and returned every tenant's rows.
The shape, as hanzo/boss (org admin of hanzo only):
GET /v1/iam/roles?owner=orgb -> 403 the guard works
GET /v1/iam/roles?owner=hanzo -> 200 contains orgb's roles
Name someone else's org and you are refused; name your OWN and the whole table
opens. Status codes look correct throughout, which is why this survived: only the
body shows it.
Six handlers: roles, projects, workspaces, invitations, audit-logs, providers.
certs was already correct — it resolved the owner through authz.Scope, and its
comment says exactly why ("a query parameter can never widen a listing beyond the
bearer's authority"). All six now do the same, so the owner comes from the
authenticated principal and a SuperAdmin still reads the owner it asks for.
Scope of exposure: cross-tenant METADATA — role membership, project and workspace
inventory, invitation rows, another tenant's audit trail. Not credentials;
masking held throughout (privateKey empty, masterPassword "***", clientSecret
empty on every path checked).
TestListRoutesNeverLeakAnotherTenant pins it through the real router with two
seeded tenants, asserting on the BODY because the status code never moved. Revert
any one handler and it fails naming the leaked row. A companion test asserts the
refusal did not simply migrate into a silently-empty listing.
Found while mapping the removal of the legacy compat surface: the native list
routes were proposed as its replacement, and they were the leaking ones.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Wallet login was the one public account-creation front door that never
consulted store.IsReservedOrg. signup, onboarding, federated provisioning
and token exchange all do; verify.go did not, so the predicate that is
documented as "never reached by a public signup or an external login" was
reachable by exactly that.
The org is not caller-chosen here (provision takes in.App.Organization),
so this is not a cross-tenant hole — it is an escalation one. authz
derives Super from owner == "admin", so an application row owned by a
reserved org with EnableSignUp set turned an unauthenticated,
wallet-signed POST into a SuperAdmin mint.
Folded into the existing EnableSignUp case rather than added as a new
one, so the refusal stays byte-identical to "sign up is disabled" and a
prober cannot tell which condition fired.
TestReservedOrgNeverProvisions covers all three reserved orgs and fails
without the guard; TestOrdinaryOrgStillProvisions pins that an ordinary
tenant still signs up, so the hole cannot be "closed" by breaking wallet
sign-up outright.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
orgChoiceMode was read as "the caller may choose their organization", and the
tenant gate spells that as `app.OrgChoiceMode == ""` — so any non-empty mode
satisfied it. Past that gate the org lookup has two arms and only one of them was
guarded: a name NOBODY holds falls into the self-serve branch and is checked
(reserved names, '/', an email, the length and leading-digit rules), while a name
that ALREADY EXISTS falls straight through to the create-the-user tail with no
check at all. The whole of orgChoiceMode's meaning for existing orgs was therefore
"any org in the registry", and the registry is one multi-brand store.
Reproduced against production before writing this. An unauthenticated POST to
hanzo.id/v1/iam/signup naming application "hanzo-console" — a hanzo app, the one
app that is both orgChoiceMode=create and EnableSignUp — and organization "lux"
was answered {"status":"ok"} and created a user with owner "lux" and
registerSource "lux/conf". Nothing about hanzo-console is supposed to reach Lux's
tenant, and the same request shape reached every brand and every customer org.
The missing arm is the else: an org that already exists must be the app's own, or
the app must be isShared, which is the declaration that it is multi-tenant on
purpose. Creation is untouched, so a founder still mints their own org, and a
stranger still lands in the app's own tenant — the two destinations org choice was
actually for. The refusal reuses the sentence the wrong-tenant and reserved-org
refusals already use, so it adds no authority oracle to distinguish them.
What this does concede: a name nobody holds still succeeds, so signup can still be
asked whether an org exists by trying to mint it. That is inherent to self-serve
creation rather than introduced here, it leaves an org row behind every time it is
asked, and it is a far smaller thing than membership in a tenant that exists.
The test reproduces the production request and fails without the guard, with the
same owner "lux" the live instance returned; three sibling cases pin the paths
that must keep working, including a shared app still admitting an existing tenant.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This workflow polled github.com every 10 minutes and fast-forwarded the forge
from it. It was written when which side was canonical was still open; it is not
open now, so a cron that reconciles two mains is a second answer to a settled
question. Removing it leaves exactly one way for code to move.
It was already inert by its own admission — the header notes it does nothing
while the GitHub repo is a pull mirror, because the forge overwrites main on its
own timer and rejects the push.
I shipped this claim minting the bare org name. account.Parse Cuts on ":" and
returns a ZERO Account when there is no kind prefix, and Payer then ignores the
claim and falls back to its shape rule — so the value did nothing at all, and
did it silently. The claim was minted, signed, stamped into
X-Billing-Account-Id by cloud's sanitizer, carried through principal.Payer into
the ai spend-gate, and dropped at the very last step. An admin kept paying from
their personal wallet while the org pool sat unused, and nothing anywhere
errored.
Every other layer was already correct: IAM signs it, cloud parses and stamps it
(auth_identity.idClaims.mintedBillingAccount), principal.BillingAccount reads
the header, ai's JWT path passes it as Credential.Account. Only the VALUE was
wrong, and it was wrong in the one place a wrong value cannot announce itself.
Built with account.Org(owner).String() rather than concatenating "org:" so it
stays the exact inverse of Parse if that encoding ever changes. Verified against
the real package: Org("hanzo").String() == "org:hanzo", Parse round-trips it to
subject "hanzo", and Parse("hanzo") is Zero.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The entity must be spelled plural on every route or authz.entityOf names two
entities for one thing and a capability keyed on it dies on half the surface;
and a publishable key resolves at a different door than a secret one, with a
narrower capability, because it discloses an org and never a principal.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
A publishable key was a model with no producer. IAM owned the whole
apparatus — schema.KeyScopePublish, store.PublishableKeyByAccessKey,
compat resolve-key — and NO endpoint anywhere minted one, so every
surface configured its own ingest credential and errors stayed on a
separate DSN. The type of a key is a FIELD on the one mint, not a
second endpoint: `?type=publishable` yields the pk-, `?type=secret`
(the default) yields the sk-, and an unknown type is refused rather
than quietly handed a session-equivalent secret.
Two rows, one per scope, so a user holds both at once: rotating the key
in a browser bundle must not sign the holder out of their own API.
NameFor(scope) is the one mapping from access class to row.
The key LIST disclosed every sk- in the org verbatim — there was no
Key.Mask. That made read AUTHORIZATION stand in for redaction, which
is why the only key list in the system was reachable by SuperAdmin
alone: the surface a user calls to see their own keys had no truthful
read at all and reported "no key" immediately after a successful mint.
Mask blanks the confidential half and keeps the publishable one (the
holder needs it; it authenticates nobody), and with the read safe on
its own, capFor("keys") grants it to the authority that already mints,
rotates and revokes the same credential.
One noun for the entity. keys served its list at /v1/iam/keys and every
other op at /v1/iam/key, and authz.entityOf reads the first segment as
the entity — so the list authorized on "keys" and every write on "key",
and any capability keyed on it was dead on one of the two halves. Same
defect entityNoun was written to fix for the verb spellings, reached
from the other side. Plural everywhere now, matching users.Route.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Cloud's platform resolves a tenant's projects from a second, embedded copy of
this store — the split-brain where a project created at /v1/iam is invisible
to the PaaS and vice versa. Resolving them HERE instead needs a machine read,
and a confidential client's authority is its capability allowlist, which maps
no projects entity at all.
The grant mirrors the self-read blocks above it — narrow by construction,
four ways at once: only a READ, only projects, only the caller's OWN org, and
only the identity the "<org>-platform-kms" contract names (the same string
cloud's SanitizeIdentity recognises in order to DENY that principal
SuperAdmin). The contract is the grant, stated once; no env allowlist to
drift. Every wall carries its own negative in the test.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
hanzo-console signs up with orgChoiceMode=create and the form defaults the org
field to the address the person just typed, so every signup mints an org: 56 of
the 124 organizations on the live instance are email-shaped
(qa_probe_x@hanzo-qa.dev, …). Nearly half the tenant registry is people, and "is
this a company?" has no answer.
It is also the wrong money shape. account.Payer resolves a member of the SIGNUP
org to a PERSONAL wallet — Person(hanzo, name) — precisely so an individual has a
balance without their own tenant. Minting them an org sends them down the Org()
pool branch instead, which makes that special-case dead code and costs an org row
plus a membership row on every signup, at signup rate.
This refuses the SHAPE, not the intent: a real company name still creates a real
org, and someone who typed their address lands in the signup org with a personal
wallet, which is where they belonged. Three tiers stay clean — employees in
admin, individuals in the signup org on personal wallets, tenant members paying
their org's pool.
Existing email-shaped orgs are untouched: drift to reap separately, and the few
real ones need their users moved to hanzo/<name> first because that changes the
wallet key.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Found by standing up three real customer orgs through the real front door
(signup -> login -> onboard) and then attacking the boundary. Before the onboard
step — the state EVERY new account passes through — a brand-new anonymous
account listed Hanzo's 121 private git repos and its CRM contacts, and created
then deleted a project inside org `hanzo`.
Nothing here is forged: the `orgs` claim is signed and cloud reads it correctly.
The claim itself is wrong, because MemberOrgRefs synthesizes the HOME membership
from user.Owner, and signup sets Owner to the APPLICATION's org. Storage
placement is being read as tenancy.
Recorded rather than patched: this repo already refuses exactly this on the
onboard path, so the fix is to make signup obey the same rule — but every
version of it either stops customer signup or changes the meaning of the `orgs`
claim for existing users, and Seed being new-only means the config route does not
even take effect on a live app row. That is an owner's call, not a 6am guess.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
An organization's PasswordOptions was the WHOLE password policy, and an empty
option set meant "any non-empty password". store.CreateOrganization mints a
self-serve org with no options, so that empty set was reachable by an
unauthenticated caller: POST /v1/iam/signup into a self-serve org was accepted
with the single byte "a", and the resulting account then logged in.
The floor (min length, and a refusal of one repeated rune, which is the length
rule's only trivial evasion) now applies in passwordPolicyError before any org
option is consulted. Options remain ADDITIVE strictness on top of an invariant
rather than being the invariant, so there is one source of truth and an org can
only ever make the policy stricter. The value is the same AtLeast8 every seeded
organization already carries, lifted out of configuration into code so it cannot
be configured away.
Length is now counted in runes once, for the floor and for AtLeast6/AtLeast8
alike — a byte count let a few multi-byte characters satisfy an "8 characters"
rule, and let the floor and AtLeast8 disagree about what "8 characters" means.
No effect on the funnel: every seeded org already declares AtLeast8, so a
legitimate signup is unchanged. Tests cover the exact production attack
(self-serve org + password "a"), the 8-char evasion "aaaaaaaa", and that a
strong password into an optionless org still succeeds.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Neither module is imported by any Go source in this module; they are only
reachable transitively. Declaring them direct overstated the dependency
surface. go mod tidy moves both to the indirect block with no go.sum churn
and no version changes.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
go.mod declares `go 1.26.5` while the builder pinned golang:1.26.4. The golang
images set GOTOOLCHAIN=local, so the base can never fetch a newer toolchain to
satisfy the directive and the build dies immediately:
go: go.mod requires go >= 1.26.5 (running go 1.26.4; GOTOOLCHAIN=local)
A release build that fails in under a second did not fail to compile — it
failed to acquire a toolchain.
Found by sweeping every hanzoai repo with a go.mod against its Dockerfile base:
25 declare go >= 1.26.5, and 16 pin an explicit 1.26.4 that cannot satisfy it.
Repos on a floating `1.26` / `1.26-alpine` are already fine — both now resolve
to 1.26.5 (verified by digest: golang:1.26-alpine and golang:1.26.5-alpine are
the same sha256:0178a641).
Patch bump only, image variant preserved, and the target tag verified to exist
before the change.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
A native client binds an ephemeral port and cannot know its redirect_uri at
registration time, so the provisioner registers the portless
http://127.0.0.1/callback for a `cli` app. IsRedirectUriValid compared by exact
string equality, so the http://127.0.0.1:51234/callback the CLI actually sends
never matched:
GET /v1/iam/oauth/authorize?client_id=hanzo-cli
&redirect_uri=http%3A%2F%2F127.0.0.1%3A51234%2Fcallback&...
400 authorization error: invalid redirect_uri
The browser therefore never came back and the CLI blocked on accept() forever —
the "loopback login hangs" symptom was this one comparison. The registration
half already assumed RFC 8252 semantics; the validation half never implemented
them.
Match now ignores the port for loopback IP LITERALS only (127.0.0.1, ::1) and
still compares scheme, host, path, query and fragment exactly. localhost is
deliberately excluded — it resolves through DNS, so it is not provably local
(RFC 8252 §8.3). Nothing off-loopback is widened.
Redemption is untouched: token.go still binds the code to the exact redirect_uri
it was minted with, so port-agnostic REGISTRATION never becomes port-agnostic
REDEMPTION. PKCE is still required for public clients.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Every IAM image build has failed since 144db2add with
stat /src/cmd/migrate-v1: directory not found
That commit ("iam: one IAM — drop v1 and the iam2 name") deleted
cmd/migrate-v1. The Dockerfile still built it and still COPY'd it into the
runtime stage, and it was the only consumer left referencing it — so the deletion
was complete everywhere except the one place that would notice.
The failure is silent from the outside: the run goes red, but nothing pins the
deployed tag to a green build, so the fleet simply kept serving the last image
that happened to succeed while every commit after it built nothing.
One binary now, and the comment says why the second is gone rather than
describing a thing that is not there.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(Also carries the in-flight "test: give Sign its scope back" fix, which the
rebase folded in — that commit corrected a harness call where "openid" sat in the
username slot and silently became a username. Its comment is kept, updated for
the parameter this change adds.)
An org admin was billed against their own empty personal wallet while the
company's credit sat in the org pool, unreachable.
account.Payer honours a signed `billing_account` above every other signal, and
absent one falls back to a shape rule that makes the SIGNUP ORG special: a member
of any OTHER org spends the org pool, but a member of "hanzo" gets a PERSONAL
wallet. That asymmetry is deliberate and load-bearing — every self-signup lands
in hanzo, and keying them on the pool once let a brand-new $0 account read
Hanzo's balance and sail through the gate.
The cost was that a real admin is indistinguishable from a random signup. IAM
never minted the claim at all, so the branch that exists to resolve exactly this
never fired for anyone.
Now owners and admins carry billing_account = their org and spend the pool; a
plain member carries nothing and still falls through to a personal wallet, so the
free-rider hole stays shut. Only the caller's HOME org is considered — a token
minted for one tenant must never name another's ledger, however privileged its
holder is elsewhere. Eight cases pinned, including admin-of-another-org.
Empty is meaningful rather than missing: it means "no explicit entitlement", and
every consumer keeps the behaviour it already had.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The seam handed out one id and looked up another, so the id AddUser
assigns never resolved: a feature module (hanzoiam/scim) reads a user's
Id back off the row, returns it as the SCIM resource id, and the client
sends it on the next request — where GetUserByID went to orm.Get, which
keys on the orm STORAGE id. Different value, every time:
AddUser -> users.Create mints u.Id = uuid.NewString()
GetUser -> row.Id = "51d22ea6-dd9b-…" (the OIDC `sub`)
row storage id = "17851797297058435770001" (a surrogate)
GetUserByID(row.Id) -> orm: entity not found
So every SCIM GET/PATCH/DELETE by id 404s — and an unknown id came back
as a raw orm.ErrNotFound rather than (nil, nil), which a caller surfaces
as a 500 instead of a 404.
Route it through store.GetUserById, the ONE subject resolver, like the
other eight methods here already route through store/users. That is also
the only shape that can work: the storage id is "owner/name" for a
migrated row — mutable, and its slash cannot survive a /Users/{id} path
segment — and a surrogate decimal for a v2-native one.
The seam interface now states the contract so an implementor cannot
guess wrong, and internal/featurestore gets its first test: the exact
add -> re-read -> resolve sequence a module performs, the id surviving
an update (a body-supplied Id is ignored), and the (nil, nil) miss. All
three FAIL on the previous implementation.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
A product announcement needs to know WHO the customers are, and the answer
already exists: every customer is an IAM user in its org. Export that as an
in-process read beside the project store so an embedder (cloud marketing)
resolves an audience through IAM instead of keeping a contact list of its own.
Read-only by construction — a sender must never mutate an identity — and rows
come back through schema.User.Mask(), so no digest, seed, or bearer material
crosses the embed seam. An empty org is refused rather than treated as the
unscoped admin view: on a mailing path that is a cross-tenant send, not a
listing. The reachability predicates run in Go because the flags are omitempty
and a false value is absent from the stored document; only the owner filter,
the tenancy key, is pushed into the query.
Superseded: the Hanzo GitHub App pushes a webhook, so the forge tracks GitHub
without a per-repo workflow. This file called git.hanzo.ai/api/v1/.../mirror-sync
— a Gitea API for a system we no longer drive — and would sit inert in every repo.
One mechanism, in one place, instead of ~350 copies of a cron.
zip v1.10.0 carries zap-proto/http v0.3.0, where wire headers are length-prefixed
pairs instead of JSON. That breaks against v1.9.x, so every ZAP service moves
together. orm v0.6.14 called zaphttp.NewTransport, which v0.3.0 removed, so it
moves in the same commit.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
There is one identity service. The clean-room rewrite already replaced the Casdoor
fork in this repo — the tree has no object/, controllers/ or web/, LICENSE is
Hanzo proprietary, and no source file carries a Casdoor copyright — but the name
never followed. 65 files still said "iam2", the binary was /iam2, and the store was
iam2.db, which reads as though a second service exists somewhere.
Renamed throughout: command, app name, DB path, prose. Zero "iam2" left.
cmd/migrate-v1 is deleted with the v1 service it read from. It existed to copy a
legacy Casdoor SQLite store into the clean-room one; with v1 dropped there is no
source to migrate. MIGRATION.md goes with it — it described the migration as
upcoming work, and it was the last file carrying Casdoor's copyright line.
internal/cred stays. It resolves the password algorithm from the stored row and
verifies argon2id as well as bcrypt; every row written by the old service is
argon2id, and dropping that would lock those accounts out. That is credential
support, not v1 code.
The server doc comment claimed iam runs "ALONGSIDE the live Casdoor /v1/iam/*"
under a shadow prefix. That was the cutover plan and is no longer true; it now
describes the prefix as the caller's choice, normally canonical.
Builds clean, 26 test packages pass, gofmt clean.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
zip v1.8.4 renamed zaphttp.NewTransport(addr) to zaphttp.Dial(network, addr) and
turned RegisterTransport's second argument into a Transport{Serve,Dial} struct.
iam registers no transport of its own, but orm v0.6.8 does: db/zap.go called
zaphttp.NewTransport, so bumping zip alone left the whole module unbuildable —
"undefined: zaphttp.NewTransport" out of github.com/hanzoai/orm/db.
orm v0.6.15 is the version that made that move (zaphttp.Dial("tcp", cfg.Addr),
requiring http v0.2.2), so the alignment is the pair, not the one. dbx follows
orm to v1.17.2.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Three write-sites let the CALLER choose a credential. hk- resolves a user by exact
match on AccessKey and sk- resolves its owning key by exact match on AccessSecret,
so a request body carrying either plants a secret the sender already knows and can
then present AS that principal. Rotation belongs to mint; these were profile edits
that happened to be able to forge.
users.Create / users.Update — AccessKey, AccessSecret and AccessSecretHash are now
cleared on create and carried from the stored row on update, exactly as the password
digest already was. Update is a full-row write, so without this a user-admin could
overwrite any reachable user's credential with a chosen one. It is also what makes
the legacy hk- population MONOTONE: while a body could re-introduce the prefix at
will, no census of it could ever be a proof, and the retirement had no finish line.
keys.apply() — AccessKey and AccessSecret are no longer copied from the caller.
Harmless-looking today because the secret is stored verbatim, and a forgery the
moment it is stored as a digest: a chosen digest is not a chosen password, it is
someone else's identity.
keys.apply() also stops carrying Scope, which is the key's ACCESS CLASS and belongs
to mint. An update could flip a secret key to publish scope, which blanks its secret
and makes its pk- half org-resolvable at the ingest door — a privilege change wearing
the clothes of a rename. Scope is now settable at create and fixed thereafter.
Each is covered by a test that plants a credential and asserts it did not stick,
while a legitimately mutable field on the same request still applies — so the guard
cannot be satisfied by simply refusing the write.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
mint-user-keys stamped the new sk- onto schema.User.AccessKey. Nothing resolves
that: store.UserByAccessKey's sk- branch reads schema.Key.AccessSecret via
userOwningKey. The write and the read never met, so every key minted this way
authenticated nobody — and because it overwrote the SAME field that holds a
user's working legacy hk-, regenerating a key silently revoked the holder with
no way back through the UI. It is latent only because nobody has pressed the
button since newAccessKey() switched to sk-: 33 hk- and ZERO sk- exist
fleet-wide, and there are zero schema.Key rows.
Mint now writes the row the resolver actually reads, through ONE exported
minter so the shape of a key is decided in one place. Keyed deterministically at
(owner, "cloud-api") so a re-mint ROTATES in place rather than leaving a second
live secret behind — a user holds one key, and revoking it revokes them. Revoke
clears both homes, so a holder still carrying an hk- is fully revoked by one
call rather than "revoked" being a lie for exactly the unmigrated population.
Fixed greenfield on purpose: with zero Key rows and zero live sk-, this costs
nothing today and costs a re-mint per holder for every day it waits.
The three tests that covered this asserted the WRITE LOCATION — that the key
landed on the user row — which is why they stayed green while the feature was
broken. They now assert the ROUND TRIP: mint, then resolve, and get the same
user back. Verified the new test fails against the old behaviour with exactly
"no schema.Key row resolves the minted secret", so it can catch the regression
it was written for.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
A brand application — hanzo-id, hanzo-chat, a brand console — serves every customer
of that brand, and self-service onboarding moves each founder OUT of the brand org
into the org they founded. So `user.Owner != app.Organization` is the STEADY STATE
for those apps, and the honest description of them is isShared: they really do serve
every organization. Nothing could say so until now; the flag existed on the schema
and no write path could set it.
That matters because the tenant gate is about to start reading it. The gate's premise
— an application belongs to one tenant — is simply false for a brand app, and turning
it on before the flags are declared refuses every self-service customer. Flags first,
enforcement after.
The upsert is the door because it does not destroy the credential. update-application
is a full REPLACE over a read that MASKS the client secret, so the natural admin
round-trip posts ClientSecret:"" and silently turns a confidential client public
(fixed in the preceding commit, but not yet in the running build). This endpoint
merges field by field and resolveSecret already preserves what is stored.
isShared is a *bool so that OMISSION PRESERVES. This is the operator's steady-state
reconcile and almost no caller mentions sharing; a plain bool would read as false on
every one of them and silently un-share the fleet — the same shape of accident as the
de-secret, and it would surface as a recurring lockout of every self-service customer.
Nil leaves it alone; only an explicit true or false moves it. A newly created app is
single-tenant unless it says otherwise.
Tests: omission preserves (red before the pointer — "an omitted isShared UN-SHARED
the app"), an explicit false still un-shares, a new app defaults closed, and flipping
the flag leaves the client secret byte-identical. Full suite 27/27.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Signer.Sign grew a `username` parameter between the id_token's preferred_username
work and this caller; the wallet harness still passed the old eight, so "openid"
bound to `username` and the arity was one short. The package has not built since —
`go test ./...` fails on main today, which means the wallet suite has been silently
absent from every run rather than passing.
Pass the empty username explicitly and let "openid" land on `scope` where it was
always meant to go. Test-only; no production path changes. Full suite: 27/27.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
update-application is a full REPLACE, and every read of an application MASKS the
client secret. So the only shape an admin UI or an operator can have — read the
record, change one field, write it back — posted ClientSecret:"" and silently
de-secreted the app. The token endpoint reads a stored empty secret as "public
client, demand no client auth", so one console save on an application page could
turn a confidential client public and weaken every flow it serves.
Measured on live IAM, not inferred: the SuperAdmin read of hanzo-console,
hanzo-app, hanzo-id and hanzo-cloud all return clientSecret "" — while a
token-endpoint probe (nonexistent username, so client auth is decided before any
user lookup) answers invalid_client for all four, proving every one of them DOES
hold a secret. Read says empty, reality says otherwise; the round-trip is a trap.
I hit it trying to flip enableSignUp on two apps and stopped rather than take the
outage.
An omitted secret now preserves what is stored. Same rule the operator upsert
already settled in resolveSecret ("existing app -> preserve what it has"), stated
here because this is the other door onto the same row. Rotation stays possible,
it just has to be DELIBERATE: send the new secret. Clearing one on purpose
(confidential -> public) is no longer expressible as an accident — it goes
through the upsert's explicit `public: true`, the one place that decision is named.
Tests: the masked round-trip preserves the credential (red before this change —
"the admin round-trip DE-SECRETED the app"), a deliberate rotation still lands,
and a genuinely public client is never handed a secret it never had.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
git.hanzo.ai mirrors this repo by PULL on a ~10-minute interval, and arcd runs
CI/CD there — so every push waited out that interval before anything built.
This asks Gitea to pull HEAD immediately.
Latency only: the repo already mirrors via the App webhook, so a missing
HANZO_GIT_TOKEN or a failed curl is non-fatal and never fails the push.
Idempotent (mirror-sync just pulls HEAD) and concurrency-coalesced.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
UserByAccessKey documented hk- as "minted by issue-user-token OR by a service
account", and revokeUserKeysHandler called the thing it clears an hk- key. Neither
is true: newAccessKey() returns keys.Mint("sk", ""), so every user key minted
since the key seam was unified is sk-, stamped on the same User.AccessKey field.
That matters for the retirement. Reading the code today suggests hk- is a live
shape still being issued, so removing it looks like a breaking change to an active
credential; in fact the population is FIXED and can only shrink, and what remains
is a re-key of stored values, not a code cutover. The branch stays until those are
re-keyed — dropping it earlier rejects every credential still carrying the prefix.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Same drift as cloud: git.hanzo.ai held 156 commits GitHub did not, so the
GitHub-to-forge sync could never fast-forward. Merging the forge in from this side
needs no forge credentials and makes that push a fast-forward again.
The merge changes no files at all — every one of those commits is content GitHub
already carries under different SHAs, from the two sides being reconciled by hand.
This records the shared history so the sync stops being rejected.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Discovery has advertised preferred_username in claims_supported since it was
written, and no token ever emitted it. The only user-ish claims on the wire were
sub (a UUID) and `name`, and userClaims fills `name` from User.DisplayName — so a
real token read
sub = e7d7fda0-… name = "Zach Kelling" email = z@hanzo.ai
A resource server that needs the IAM USERNAME (the `<name>` half of
`<owner>/<name>`) had nothing to read and fell back to `name`. cloud's money path
addresses a wallet exactly that way, so it addressed `hanzo/Zach Kelling` — a
wallet no funding path can name, a human label with a space in it — while the
balance sat in `hanzo/z`. Every signed-in completion 402'd against a funded
account, which is what took hanzo.chat dark.
userClaims already had the value and discarded it: it computes DisplayName for
`name` and drops u.Name. It now returns both, and Sign/SignUserToken/SignID take
the username and emit it as preferred_username. omitempty keeps a machine token
(no user, no username) omitting the claim rather than emitting it empty, so one
struct still serves both token shapes.
DisplayName is unchanged and still carried in `name` — this adds the missing
claim, it does not repurpose an existing one.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Root cause of the .224/.225 401s. app.Use(authz.Guard) is not "guard my routes",
it is "guard every route this *zip.App will ever serve". Coherent while IAM owns
the app; false the moment it is one of 59 subsystems. Embedded, IAM mounts at
position 9 and `ai` registers /v1/models at 106, so IAM's Guard gated ai's routes
97 positions later — then resolved the bearer against the EMBEDDED iam2.db, which
has never seen a token minted by the external hanzo.id, and failed closed on every
valid request.
The tell was the body. {"status":401,"error":"authentication required"} is
internal/authz/authz.go verbatim, not ai's OpenAI-shaped error — same image, same
route, different handler, selected purely by whether iam was mounted.
Fixed by mounting on the prefixes this subsystem declares: /v1/iam, /login/oauth,
and the framework's own side doors onto IAM's typed ops (/mcp and
/.well-known/openapi.json — named, not assumed, because a host binary owns those
paths when IAM is embedded). NOT by reordering mounts, which would work today and
break on the next reorder: a position in a slice is not a security boundary, a
path prefix is.
A SCOPING change, not a relaxation. The public group is still registered first and
still terminates the walk; every path IAM serves is gated exactly as before, and
the existing side-door suite (POST /mcp and the OpenAPI doc, both 401 without a
bearer) is unchanged and still green.
The test mounts a SIBLING subsystem on the same app, which is what no previous
test did — with only IAM mounted there is no neighbour to swallow, so the suite
could not see this. Red against app.Use with the production body verbatim.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Fourth instance of one pattern in a day: verified with the bare id, the client
sends the org-qualified one. ai/internal/iam/cert.go:35 builds
"<IAM_ORG>/<name>" -> hanzo/cert-hanzo, while GetApplication hardcodes admin/ —
same clause, opposite directions, and only the spelling I happened to test worked.
SETTLED THE TRAP BEFORE GRANTING. admin/cert-hanzo and hanzo/cert-hanzo are two
DISTINCT rows, so granting blind risked cloud validating every bearer against the
wrong public key: green boot, total silent auth failure, no stack trace — strictly
worse than the crashloop. Measured instead: a real hanzo-cloud token carries
kid=cert-hanzo, and both rows carry the IDENTICAL 4096-bit modulus
(zE8fZcoJ_u4Uq…), which is the single key /v1/iam/.well-known/jwks publishes for
that kid. So the owner half selects between duplicates of ONE keypair, not between
keys, and the trap cannot fire.
They were seeded 3ms apart (…19.416479 and …19.419119) by the same run — seed
drift, a duplicate signing identity, reported separately. Nothing here depends on
which row wins, which is the point: name == p.AppCert remains the whole gate, so
an app reaches the one cert its own application row names and no other, whichever
owner it spells. Read-only, and Cert.Mask blanks PrivateKey, so this discloses the
PUBLIC key already published at the JWKS endpoint.
The probe manifest is now a committed artifact (test/probe/) with the rule written
down: derive the test id from what the client actually sends, by reading the
client. A green suite has now failed to predict production three times running;
the probe caught all three in seconds.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The reconcile added in the previous commit merged the WHOLE declared application
object onto the live row. Measured against production before enabling it, that
would have been destructive: live applications legitimately carry redirect URIs
and grants init_data.json does not list — hanzo-console alone had 4 extra
redirects and 2 extra grants, hanzo-id and lux-kms extra grants, zoo-cloud an
extra redirect. Declared-wins over the whole object would have DELETED them and
broken the very logins the flag change exists to fix.
The file's authority is narrowed to identity POLICY — who may sign up, whether
password/code sign-in is on, how an org is chosen, which org owns the app.
Registration (redirects, grants, client credentials) drifts legitimately and is
owned by the provision document; keeping the two apart is what lets a bootstrap
file converge a flag without being able to take a surface offline.
appPolicyKeys is deliberately short: every key on it is one this file can silently
revert on the next boot, so a field belongs there only if declared should always
win over live.
Test pins it: a row carrying undeclared redirects, grants and a generated
clientSecret keeps all three across a reconcile that flips enableSignUp.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
A pk- is the publishable half of a key — safe to ship in client JS. It resolved
through UserByAccessKey to a full principal (owner, name, email, isAdmin), so a
value meant to be public was also a read grant.
pk- is now write-only: UserByAccessKey recognizes hk- and sk- only, and a pk-
falls through to not-exist alongside every unknown value. Resolving a pk- to its
tenant is a separate door, GET /v1/iam/resolve-key, behind its own
CapPublishableResolve capability, and it answers the org and scope and nothing
else. store.PublishableKeyByAccessKey is fail-closed on prefix, scope and expiry,
and an unresolvable key returns the same envelope as a missing one.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The probe caught this one line past the fix. get-application as hanzo-cloud went
403 -> 200, and the body said "the entity does not exist": authorized, and still
unable to read itself. A 200 that is functionally the 403 it replaced.
Scope pins a non-SuperAdmin to p.Org, which for an APP principal is the tenant it
SERVES (hanzo), not the org that OWNS its row (admin). So the Guard admitted
admin/hanzo-cloud and the handler then queried hanzo/hanzo-cloud.
Scope itself is not loosened — its pinning IS the tenant gate on every
handler-authorized path (SCIM, service-accounts, memberships), and widening it for
app principals would let one list admin-owned rows there. Instead ScopeFor asks the
ONE self-read clause again, through the same authorize() that defines it: if
authorize would admit this exact read, the owner it admitted is the owner queried;
otherwise the pin stands. No second copy of the rule.
The test now asserts the BODY, not the status. Asserting 200 was exactly what let
the first version look correct while returning nothing.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Two defects, one symptom: nobody could create an account.
1. "the application does not allow to sign up new account"
enableSignUp was false on the console app, and init_data.json COULD NOT change
it. Seeding is new-only — upsert calls orm.GetOrCreate and skips an existing
row — which is right for identity DATA (a bootstrap file must never stomp a
live user) but wrong for application POLICY. init_data.json is how the platform
declares whether an app allows sign-up; with the flag unreachable, production
read false while the declared state said otherwise, and the only way to move it
was an out-of-band admin call.
Declared application policy now converges on boot. The reconcile merges the RAW
declared object onto the loaded row rather than saving a decoded struct, which
is the crux: json.Unmarshal sets only the keys PRESENT in the JSON, so a field
the file does not mention keeps its stored value. That is what protects
clientSecret — generated at first seed, never written back to the file — from
being blanked, the same de-secret hazard update-application had to fix. It also
needs the raw object because a decoded struct cannot distinguish "declared
false" from "absent".
2. "the organization: X does not exist"
Signup required the org to already exist, so "a new account with a new
organization" was impossible without an operator creating the org by hand. The
old comment said this needed an org-create helper that iam2 did not have; that
helper is now store.CreateOrganization (idempotent, so two founders racing a
name join rather than collide).
Self-serve creation is OPT-IN per application via orgChoiceMode == "create", so
an app that merely lets users CHOOSE among orgs still cannot mint one, and an
app naming a single tenant is unaffected.
Safe by construction, resting on checks that already ran rather than new ones:
IsReservedOrg refuses admin/built-in/app BEFORE this point, and the founder is
created under their OWN org — authority is a property of the user row, and authz
derives Super from user.Owner == "admin", so self-serve signup can never mint a
SuperAdmin. The org name is validated because it becomes the OWNER half of every
(owner, name) key; an unvalidated name would be key injection, not cosmetics. The
"does not exist" refusal is unchanged when the opt-in is off, so signup does not
become an org-existence oracle.
Tests: org is created and owned by admin while the founder is owned by the new
org; refused without the opt-in (both "" and a non-create mode); reserved orgs
still refused WITH the opt-in set; name policy. Plus seed convergence and the
undeclared-field guard proving clientSecret survives a reconcile. Full suite green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
v1.33.17's self-read grant was inert in production. It matched entity
"applications"; entityOf resolved the alias cloud actually calls,
/v1/iam/get-application, to the literal string "get-application". No clause
matched, the reserved-owner gate denied, 403. The native noun route matched and
the verb route did not — the same two-spellings-of-one-concept bug as
OrgChoiceMode "None" vs "", in a different place.
I proved that grant with a unit test against authorize() and shipped it without
exercising the path the caller uses. That is the third time today a fix was
"unit-tested only" and did not fire. The tests here go through the real router
over the compat verb with client_secret_basic — the shape hanzo-cloud sends.
entityOf now folds the verb spelling onto the entity noun in the ONE place a path
becomes an entity. This is wider than the grant: EVERY capability keyed on an
entity was dead on the compat surface, because capFor("add-organization") is not
capFor("organizations") — the allowlists that exist precisely so the brand
consoles can manage orgs during onboarding were consulted with a key that could
never match. Both surfaces now resolve the same policy.
CERTS, the second entity on the same critical path. InitAuthConfig reads the
application, then reads application.Cert, then InitConfig(cert.Certificate) —
so granting only applications fixes one line and panics identically on the next.
An app may read the ONE cert its own row names: Principal carries AppCert from
that row, and the clause requires name == p.AppCert, so an app cannot walk to
another brand's signing cert. Read-only, and Cert.Mask already blanks PrivateKey
and AccessSecret, so what crosses is the PUBLIC certificate this client must
already trust to verify our tokens.
ReadTarget also resolves a BARE `?id=cert-hanzo` to its name half. It previously
yielded NO target — owner "" and name "" — so the authorizer was handed nothing
and fail-closed denied even the caller reading its own. Knowing the name cannot
widen anything: an empty owner still fails the tenant rule and IsReservedOrg(""),
so only the self-read clause, which pins that name to the principal, can act on it.
Refusals pinned over the same verb surface: a sibling app, the same name under a
tenant owner, a cert it does not reference (both id spellings), the application
list, the cert list, user rows, and a wrong client secret.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The upsert REPLACES redirectUris and grantTypes — bootstrap.go writes
`if len(req.RedirectUris) > 0 { existing.RedirectUris = req.RedirectUris }`.
So a document that cannot express a live URI does not merely fail to add it:
converging DELETES it, reports success, and takes the login down.
Read off the production store, not inferred. hanzo-app holds 24 redirect URIs
— the desktop deep link hanzo://oauth/hanzo, the Tauri loopback ports,
https://cowork.hanzo.ai/auth/callback — where `desktop` derives exactly one,
hanzo://oauth/app, a value nothing uses. hanzo-cloud holds grants
[authorization_code refresh_token device_code client_credentials]: one
client_id serving a browser PKCE surface AND a backend machine identity, where
`spa` derives two, so a converge silently revokes the machine half. Hanzo's
provision document has been BLOCKED on precisely this since it was written; it
could not be applied without breaking working logins.
Redirects and Grants are additive, never substitutive: hosts+type stay the
default so a line stays one line, and the field carries only the exceptions.
Both go through one union() — order-stable, duplicate-free, blanks dropped — so
two runs over one document still produce byte-identical bodies, which is what
makes --dry-run reviewable and a re-run a no-op. A literal redirect that is a
path rather than an absolute URI is a Derive error, because that registration
converges silently and fails later as redirect_uri_mismatch.
Merging in the server was the alternative and is worse: it makes the document
permanently non-authoritative, drift becomes unremovable, and every stale URI
lives forever. Replace stays; the document gets a vocabulary big enough to be
true.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Two refusals a client could not act on, both at the same seam.
SELF-READ. hanzo-cloud calling get-application on itself got 403. Reading its own
registration is the ordinary bootstrap of an OIDC relying party — how a client
discovers its cert, redirect URIs and enabled methods — and it discloses nothing
the holder of that client's credential does not already have. The owner-pin that
closed "every client credential is a global admin" is right and stays; it was
missing this one case, and applications are not in capFor(), so a confidential
client could not read even itself.
Granted narrowly, four ways at once: only an app principal, only a READ, only the
applications entity, and only the exact (AppOwner, App) pair the request
authenticated as. Both halves of the key must match, which is what makes it
self-read rather than "apps may read applications" — a sibling under the same
owner differs in name, and admin/<app> vs <tenant>/<app> differs in owner, so
NEITHER direction of that name collision is admitted. Read-only is load-bearing:
a self-write would let a client widen its own redirect URIs or grant types.
ENVELOPE. The Casdoor verbs are a contract — every client branches on a STRING
status of "ok"/"error" and reads msg — and the handlers honour it. But the Guard
short-circuits before any handler runs, and zip's error shape is
{"status":401,"error":"…"}: status an int where the client expects a string, the
text under error where the client reads msg. So one endpoint spoke two languages
depending on how far the request got, and a client written against the documented
shape saw neither an ok nor a recognizable error. Fixed at the source rather than
teaching every client to tolerate both.
Scoped to the compat surface only. Those paths are verb-shaped (get-/add-/update-/
delete-) while the native surface is noun-shaped, so the prefix distinguishes the
two contracts with no second list to keep in sync; REST/OIDC/SCIM keep their own
error shapes. HTTP status codes are unchanged.
Tests: the self-read truth table pins both collision directions, both write verbs,
the wrong entity, an unpinned app and an empty owner as refusals, and that a human
is unaffected; the envelope test pins verb-vs-noun selection. Red before.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Every app sent a SIGNED-IN person to a credential form. The portal's launcher
tiles are the visible symptom: click one, the app starts an OIDC hop, and the
browser lands back on hanzo.id looking at a password box — which reads as "the
link is dead", because from the outside nothing happened except a bounce.
The client half of silent SSO was already written and correct (@hanzo/id-auth
silentLogin posts a credential-less type=code login with the cookie attached, and
its comment describes exactly the branch it expects). IAM v2 never had that
branch: a post with no username and no password fell straight through to
"organization, username and password are required", so the fallback to the
interactive form was the ONLY outcome — always.
A code request carrying no credential but a live session THIS IdP issued is a
user who is already signed in asking for a grant to the next app. Re-typing the
password proves nothing new: the cookie is HMAC'd over the platform signing key,
carries its own expiry, and its sid is checked against the Session row on every
resolve, so it is revocable — and it only ever exists downstream of the full
gate, second factor included, because loginGrant is what sets it.
It grants nothing extra. The mint runs through loginGrant, the ONE minting tail,
so the reserved-org gate, the app-org tenant gate, the exact redirect_uri match
and the public-client PKCE requirement are the same checks in the same order as a
password post; only the proof of identity differs. Restricted to type=code, and
the user row is re-read so an account forbidden or deleted since sign-in is
refused rather than riding its old session. Not a CSRF mint either:
/v1/iam/login is not a CORS browser path and this IdP never allows credentialed
cross-origin reads, so only a first-party page can both send the cookie and read
the code.
Tests: a live session mints a redeemable code bound to the right user and
redirect; no session still demands a credential; an unregistered redirect_uri is
refused even with a session; a forbidden user cannot ride an old session.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Self-service org creation already had ONE door — POST /v1/iam/onboard, which
resolves the caller from its own session or bearer and provisions org + admin +
metered key under the Founder stamp. Two things kept the org it produced from
being a real tenant.
The org was born with an empty ROSTER. provision() moved the caller in as the
org's admin, and MemberOrgRefs derives a HOME entry from the user row, so the
`orgs` claim looked right — but nothing ever wrote the (User x Org) relation an
org's membership is actually read from. MembershipsByOrg returned nothing, so as
far as invitations, team management and every roster read were concerned the org
had no owner. It now ensures the founder's row at RoleOwner: EnsureMembership
never downgrades, so a later home-org backfill (which writes RoleAdmin) can no
longer quietly demote the person who founded it.
And the move SIGNED THE FOUNDER OUT. An IAM identity is (owner, name), so moving
a user re-keys it — which strands the session cookie, whose (Owner, Name,
Application) triple keys the Session row. The next request read as anonymous: a
person was logged out by their own signup. sessions.Rekey carries the live
session across to the new key and revokes the old sid, so the browser holds
exactly one session throughout and the superseded cookie cannot be replayed. It
is a no-op on the bearer path, whose subject is a stable UUID the re-key does not
touch.
The same re-key stranded the caller's PREVIOUS home membership on an id that no
longer exists, leaving a ghost on the old org's roster forever; the converge that
re-keys the user now drops it.
No gate is widened. Authority is FOUNDERSHIP — the caller is resolved from its
own credential and may only found an org for itself — so IAM_ORG_ADMIN_APPS,
which would let one app administer every tenant's orgs, stays exactly as it is.
Tests (red before, green after): a founder's org carries Founder + an owner
roster row + the orgs claim and keeps them signed in; a second identity can
neither complete nor join that org; anonymous and forged-bearer callers are
refused.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
git.hanzo.ai is CANONICAL; GitHub is a mirror. Everything that BUILDS,
PUBLISHES or DEPLOYS is native — .hanzo/workflows/image.yml on the forge for
images, hanzo-cd for rollout. GitHub Actions must never build, never publish an
image, and never touch a cluster. Its single job is to get commits onto the
forge so the native pipeline can see them.
Direction is push (GitHub -> forge). The forge also runs a pull job
(.hanzo/workflows/sync-from-github.yml); the two compose rather than fight
because whichever arrives second sees LOCAL == REMOTE and exits a no-op.
fetch-depth: 0 — a shallow push silently drops commits.
The token goes in a credential helper, never a remote URL: a token baked into
.git/config leaks on any `git remote -v`, which is a live finding in this very
working copy.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The image could not be built at all. On-cluster BuildKit, from the v1.33.12 tag:
go: github.com/hanzoai/orm@v0.6.8-0.20260726065619-7b3c62da906d:
invalid pseudo-version: revision 7b3c62da906d is not a descendent of
preceding tag (v0.6.7)
7b3c62da906d is on no branch and in no tag that github.com/hanzoai/orm serves,
so only a machine whose module cache already held it could resolve that pin —
which is why this built here and nowhere else. v0.6.8 is the released tag
carrying the same work (the tenant registry), so the dependency is stated as a
version instead of a commit that happens to be lying around.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Every @hanzo/iam browser login died at the token exchange:
Token exchange failed (401): {"error":"invalid_client",
"error_description":"client authentication failed"}
A visitor signed in at hanzo.id, came back to /auth/callback, and got no session —
on hanzo.chat and cloud.hanzo.ai both. Cause: authorizationCodeGrant required the
registered secret whenever the app HAS one, and registration here is not per-flow.
`hanzo-chat` (secret len 48) and `hanzo-cloud` (len 64) keep a secret for their
BACKEND paths — chat's passport OpenID strategy, cloud's `IAM_CLIENT_ID=hanzo-cloud`
client_credentials machine auth — while their SPA is a public PKCE client that
cannot hold one. Deleting the secrets is NOT the fix: clientCredentialsGrant
requires a registered secret, so that would break cloud.
So authenticate the browser the way OAuth 2.1 does — by the code's PKCE binding:
require the secret only when one is PRESENTED, or when the code carries no
challenge. Same bounded relaxation this file already documents for passwordGrant
(Casdoor allowed exactly this; the clean-room rewrite tightened it and broke login).
Untouched: a presented-but-wrong secret is still 401; a code with no PKCE still
needs the secret (client auth cannot be skipped by omitting the challenge);
RedeemCode still verifies verifier↔challenge, single-use and redirect binding;
client_credentials still demands a registered secret.
Tests (4) cover all four corners and the first one reproduces the live 401 with
this change reverted.
GET/PUT /v1/iam/consent, self-scoped to the caller (callerOf). Stores the two
switches (anonymous insights default-on; opt-in training-contribution default-off)
inside the SAME preferences blob as update-preferences, so there is one source of
truth and no parallel table to drift. The hanzo.id signup asks it, the browser
extension (v1.9.36) reads/writes it, and hanzo.ai edits it — one value, one way.
219fc64e took the PKCE challenge off the query string. The same request carries
`scope`, `nonce`, `redirectUri` and `clientId` in exactly the same place, and
those were still being read from the body alone — so they were dropped, and the
damage surfaced two hops away at the relying party.
insights.hanzo.ai signed in and landed on "Something went wrong". Captured from
the pod:
Internal Server Error: /complete/oidc/
File ".../social_core/backends/open_id_connect.py", line 357
response["id_token"], response["access_token"]
KeyError: 'id_token'
Nothing was wrong with that client. The code it redeemed had `Scope=""`, so
issueTokens' `hasScope(row.Scope, "openid")` was false and /token answered 200
with an access_token and no id_token at all. Behind that sat two more failures
the first one hid: `Nonce=""` fails the id_token claim check of every strict
OIDC consumer, and `RedirectUri=""` meant the token endpoint skipped the RFC
6749 §4.1.3 redirect binding entirely — a code minted for one client's callback
could be redeemed against another.
The login form is posted to the URL the authorize step handed the page and the
OAuth request rides that query, written in two spellings: this server's own
authorize redirect emits RFC snake_case (authorizeForwardQuery), the @hanzo/iam
SDK emits camelCase. adoptQueryPKCE becomes adoptQuery and fills the whole
passthrough from either spelling, so no parameter can be forgotten on its own
again. Body still wins when both are present; an adopted value runs every check
the body path runs, so an unregistered redirect_uri is refused as before.
Every login test in this package posted these parameters in the BODY — a
contract no real client uses, which is why CI stayed green through the outage.
login_query_test.go posts what the wire actually carries: credential in the
body, request on the query. Before this change four of its five cases fail,
the first with the production symptom (`no id_token in the token response`).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The mount/wire sweep matched on word boundaries, so the one occurrence
where "wire" was glued to the next word (wireContract) survived. Same
law, same substitution the file's own doc comment already uses ("These
tests pin the HTTP contract"). go test ./internal/schema/ ok.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Create-org and invite-member are performed by the console on the user's OWN
behalf. Listed as the native REST paths (/v1/iam/organizations,
/v1/iam/invitations), not the Casdoor add-* verbs — those exist so existing
backends keep working, and a new browser client should not learn them.
Guard-authorized as before: opening the ORIGIN does not open the data, so the
browser can only do what that principal could already do.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The fork moved its API off /api to /v1, so every call built against
${{ github.server_url }}/api/v1/... now 404s. Verified live with a control:
/v1/version 200, /api/v1/version 404, a nonsense path 404.
This is the build-dispatch in sync-from-github, so a fast-forward from GitHub
was landing commits and then silently failing to trigger the build.
A preflight carries no credentials — the browser strips them — so the Guard
could only ever answer 401, and a 401 preflight is indistinguishable to the page
from "your origin is not allowed". A registered console asking its own IdP
"which orgs am I in?" got a failed preflight and rendered an empty org switcher,
with nothing in the network log but an OPTIONS 401. That is the whole reason
consumers grew server-side IAM proxies: the browser path looked impossible.
Whether a path is open to a browser is CORS's question, and it is already
answered upstream in internal/cors: if it opened the path it terminated the walk
with 204 and the Guard never runs; if it did not, falling through emits no
allow-origin header and the browser blocks the real request anyway. Either way
a preflight is not a request to authorize.
Also opens the org surface a console reads about ITSELF (get-organizations,
get-organization, get-users, get-account). Same shape as userinfo, which was
already open: a Bearer-protected read where CORS decides which ORIGIN may see
the answer and the Guard still decides WHO. Opening the path does not open the
data — the Guard authorizes the exact (owner, name) addressed, so a caller sees
only what its principal could already see.
Tests: TestGuard_NeverAuthenticatesAPreflight drives the real router and pairs
each assertion — OPTIONS must not 401, and the same GET without a bearer must
still 401, so opening the preflight can never quietly open the read. Verified by
reverting the fix: 3 failures, one per path. Plus browserPaths coverage both
ways (the console surface is open; certs/providers/writes/typed-CRUD stay shut).
Note: TestSuperAdminWritesAdminCertAndCrossOrg and TestOrgAdminManagesOwnOrgOnly
are FLAKY on main independently of this change — 0, 1, and 2 failures across
three identical baseline runs. Not touched here.
"Mount" and "wire" are banned. Route registration in a zip app is Group +
the Route seam this repo already standardized on (routes.Route,
oidc.Route, mfa.Route, scim.Route, registry.Route) — server.Mount and
feature.Mount were the last two holdouts, so the repo had TWO names for
one concept. One and only one way:
server.Mount -> server.Route
feature.Feature.Mount -> feature.Feature.Route
feature.MountAll -> feature.RouteAll
registry.mount (unexp.) -> registry.route
Prose follows the same law: "mounts/mounted/mounting" -> registers/
registered/registering, "wire contract" -> HTTP contract, "wire
request" -> HTTP request, "wires/wired/wiring" -> binds/bound/binding.
These read as values (what the thing IS) rather than places (where it
got stuck).
No external repo imports hanzoai/iam/{feature,server} — verified by
grepping every go.mod under ~/work/{hanzo,lux,zoo} for hanzoai/iam and
then grepping those trees for the import paths; the only cross-repo
import is hanzoai/iam/pkg/model (hanzo/cloud), untouched here. So this
breaks no consumer.
Mechanical only: no behaviour change, no route path change.
GOWORK=off go build ./... clean; go test ./... 26/26 ok, same set as
the pre-change baseline.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Two gaps that made the sanctioned convergence path unable to express a real
client, so the client got hand-made instead — which is how it drifted.
cert. issueTokens resolves app.Cert to build the signer, and the id_token is
minted from it. A client registered with no cert therefore cannot produce one,
and an OIDC consumer that identifies the user FROM the id_token fails after a
perfectly successful code exchange — goth reports "cannot get user information
without id_token", which reads like a scope or secret problem and is neither.
Every client this package registered until now sent no cert at all.
Omitted from the JSON when empty, not sent as "". The upsert assigns a cert only
when non-empty, so omission is what actually means "leave it alone" — and
nothing here rotates on a re-run, same contract as clientSecret.
callback. Redirects stay derived per host — hand-written URI lists are how
registrations drift from the app calling them. But the path is not always ours:
Hanzo Git serves /user/oauth2/<source>/callback. One optional path override per
app keeps the host set as the single source of the URI list while letting a
foreign server's shape be stated. Validated at Derive (must be a path, never
/api/) because a malformed redirect converges silently and only shows up as
redirect_uri_mismatch on the next login.
Four tests, each proven to fail without its half: the override lands
(and an app that does not ask still gets /auth/callback), the cert is carried,
an absent cert is absent from the body, and a bad callback is rejected.
provision + bootstrap suites pass.
Restores the guard dropped by a force-push. Without these rules the JWT
signing keypair (object/token_jwt_key.key/.pem), the iamd binary, web/build,
and web/node_modules are untracked-but-stageable — a plain 'git add -A' commits
the keypair. Keys live in KMS; they never belong in the tree.
Every @hanzo/iam browser login died at the token exchange:
Token exchange failed (401): {"error":"invalid_client",
"error_description":"client authentication failed"}
A visitor signed in at hanzo.id, came back to /auth/callback, and got no session —
on hanzo.chat and cloud.hanzo.ai both. Cause: authorizationCodeGrant required the
registered secret whenever the app HAS one, and registration here is not per-flow.
`hanzo-chat` (secret len 48) and `hanzo-cloud` (len 64) keep a secret for their
BACKEND paths — chat's passport OpenID strategy, cloud's `IAM_CLIENT_ID=hanzo-cloud`
client_credentials machine auth — while their SPA is a public PKCE client that
cannot hold one. Deleting the secrets is NOT the fix: clientCredentialsGrant
requires a registered secret, so that would break cloud.
So authenticate the browser the way OAuth 2.1 does — by the code's PKCE binding:
require the secret only when one is PRESENTED, or when the code carries no
challenge. Same bounded relaxation this file already documents for passwordGrant
(Casdoor allowed exactly this; the clean-room rewrite tightened it and broke login).
Untouched: a presented-but-wrong secret is still 401; a code with no PKCE still
needs the secret (client auth cannot be skipped by omitting the challenge);
RedeemCode still verifies verifier↔challenge, single-use and redirect binding;
client_credentials still demands a registered secret.
Tests (4) cover all four corners and the first one reproduces the live 401 with
this change reverted.
The tree had no rule for the iamd binary, web/build, web/node_modules, or
object/token_jwt_key.*, so a routine 'commit local changes' swept 57k files,
a 240MB binary, and the JWT signing keypair into a commit. Keys live in KMS;
they never belong in the tree.
GET/PUT /v1/iam/consent, self-scoped to the caller (callerOf). Stores the two
switches (anonymous insights default-on; opt-in training-contribution default-off)
inside the SAME preferences blob as update-preferences, so there is one source of
truth and no parallel table to drift. The hanzo.id signup asks it, the browser
extension (v1.9.36) reads/writes it, and hanzo.ai edits it — one value, one way.
A public client's login was rejected with "PKCE is required for public clients"
while the browser was sending a challenge the whole time — we only looked in
one place. Captured live:
POST /v1/iam/login?clientId=lux-cloud&…&code_challenge=JCCAo2ey…&code_challenge_method=S256
body: {"type","username","password","application","signinMethod","autoSignin","organization"}
-> {"status":"error","msg":"PKCE is required for public clients"}
The login form is posted to the URL the authorize step handed the page, so the
OAuth parameters ride the QUERY. The body binds `codeChallenge` (camelCase);
the query spells it `code_challenge` (RFC 7636). Take the query value when the
body has none — body still wins when both are present, so this can only ever
supply a challenge, never replace one.
A public client's login was rejected with "PKCE is required for public clients"
while the browser was sending a challenge the whole time — we only looked in
one place. Captured live:
POST /v1/iam/login?clientId=lux-cloud&…&code_challenge=JCCAo2ey…&code_challenge_method=S256
body: {"type","username","password","application","signinMethod","autoSignin","organization"}
-> {"status":"error","msg":"PKCE is required for public clients"}
The login form is posted to the URL the authorize step handed the page, so the
OAuth parameters ride the QUERY. The body binds `codeChallenge` (camelCase);
the query spells it `code_challenge` (RFC 7636). Take the query value when the
body has none — body still wins when both are present, so this can only ever
supply a challenge, never replace one.
Found by probing the live store: after the provisioner correctly registered
lux-cloud as public, ANY later upsert that merely omitted `public` — an
operator reconcile, a read-back, any caller that only sets a name — minted a
fresh secret and silently turned it confidential again. Every browser login
then failed `invalid_client` with nothing in the provision document changed to
explain it. Reproduced end to end:
upsert {"name":"lux-cloud"} -> clientSecret dc49409a…
upsert {"name":"lux-cloud","public":true} -> clientSecret "" (correct)
upsert {"name":"lux-cloud"} -> clientSecret Ug5KTDx… (!!)
The old branch asked "is the stored secret empty?" and treated empty as
"nothing to preserve". But for a public client empty IS the value, and it is
load-bearing: the token endpoint reads a stored secret as "demand client auth".
Settle it in one testable place, resolveSecret: public -> none; explicit ->
honour; existing -> preserve WHATEVER it has, empty included; new -> mint. The
table test pins all four, so the confidential no-rotation path stays covered
too.
Found by probing the live store: after the provisioner correctly registered
lux-cloud as public, ANY later upsert that merely omitted `public` — an
operator reconcile, a read-back, any caller that only sets a name — minted a
fresh secret and silently turned it confidential again. Every browser login
then failed `invalid_client` with nothing in the provision document changed to
explain it. Reproduced end to end:
upsert {"name":"lux-cloud"} -> clientSecret dc49409a…
upsert {"name":"lux-cloud","public":true} -> clientSecret "" (correct)
upsert {"name":"lux-cloud"} -> clientSecret Ug5KTDx… (!!)
The old branch asked "is the stored secret empty?" and treated empty as
"nothing to preserve". But for a public client empty IS the value, and it is
load-bearing: the token endpoint reads a stored secret as "demand client auth".
Settle it in one testable place, resolveSecret: public -> none; explicit ->
honour; existing -> preserve WHATEVER it has, empty included; new -> mint. The
table test pins all four, so the confidential no-rotation path stays covered
too.
upsertApplication ALWAYS minted a clientSecret when the request omitted one.
The token endpoint treats a stored secret as "this client must authenticate"
(token.go: the secret is verified only when one exists), so every client the
operator or provisioner registered was implicitly confidential — and a browser
SPA, which cannot hold a secret, had no way to comply. Its code->token exchange
died on `invalid_client` at the callback, holding a perfectly valid code.
Captured on lux.cloud, one layer at a time:
POST /v1/iam/login 200 password fine
POST /v1/iam/oauth/token (CORS fix) reachable no longer net::ERR_FAILED
POST /v1/iam/oauth/token 401 invalid_client <- this
Add `public` to the upsert. A public client stores NO secret — that absence is
exactly what the token endpoint reads as "PKCE, do not demand client auth" — and
setting it CLEARS a secret left by an earlier confidential registration, so a
mis-typed app can be corrected by re-converging instead of by hand.
provision derives it from the app's type, which is what `type` was always for:
spa/cli/desktop ship to the user and are public; confidential and service can
be trusted with a credential. The steady-state no-rotation path is untouched:
a confidential client that omits the secret still keeps the one it has.
upsertApplication ALWAYS minted a clientSecret when the request omitted one.
The token endpoint treats a stored secret as "this client must authenticate"
(token.go: the secret is verified only when one exists), so every client the
operator or provisioner registered was implicitly confidential — and a browser
SPA, which cannot hold a secret, had no way to comply. Its code->token exchange
died on `invalid_client` at the callback, holding a perfectly valid code.
Captured on lux.cloud, one layer at a time:
POST /v1/iam/login 200 password fine
POST /v1/iam/oauth/token (CORS fix) reachable no longer net::ERR_FAILED
POST /v1/iam/oauth/token 401 invalid_client <- this
Add `public` to the upsert. A public client stores NO secret — that absence is
exactly what the token endpoint reads as "PKCE, do not demand client auth" — and
setting it CLEARS a secret left by an earlier confidential registration, so a
mis-typed app can be corrected by re-converging instead of by hand.
provision derives it from the app's type, which is what `type` was always for:
spa/cli/desktop ship to the user and are public; confidential and service can
be trusted with a credential. The steady-state no-rotation path is untouched:
a confidential client that omits the secret still keeps the one it has.
A public PKCE client runs code->token in the BROWSER: the page at
https://lux.cloud fetches https://lux.id/v1/iam/oauth/token directly. IAM sent
no Access-Control-Allow-Origin, so the browser blocked it and every login
dead-ended on the callback with "Failed to fetch" — user authenticated, code
issued and valid, impossible to spend. Captured live:
POST https://lux.id/v1/iam/login -> 200 (password fine)
GET https://lux.id/.well-known/openid-configuration -> net::ERR_FAILED
POST https://lux.id/v1/iam/oauth/token -> net::ERR_FAILED
"blocked by CORS policy: No 'Access-Control-Allow-Origin' header"
THE ALLOWLIST IS DERIVED, NOT CONFIGURED. An origin is permitted iff some
registered application already declares a redirect_uri on it — the same set
OAuth trusts to receive a code. So CORS can never be looser than the redirect
allowlist, and there is no second list to drift: provision a host and login
works from it, which composes exactly with `iam2 provision`.
Scoped deliberately. Only the endpoints a browser-side client actually calls
are opened (discovery, JWKS, token, userinfo, revoke, logout); admin/bootstrap
upsert, credential login and /oauth/authorize (a top-level redirect, not a
fetch) stay closed, and a test asserts both halves of that set. Credentials are
NOT allowed — a PKCE exchange proves itself in the body, never via a cookie —
so echoing an origin cannot authorize a cookie-bearing request.
The origin set is cached for 60s; a storage error keeps the last good set
rather than failing open to every origin or closed to all of them.
A public PKCE client runs code->token in the BROWSER: the page at
https://lux.cloud fetches https://lux.id/v1/iam/oauth/token directly. IAM sent
no Access-Control-Allow-Origin, so the browser blocked it and every login
dead-ended on the callback with "Failed to fetch" — user authenticated, code
issued and valid, impossible to spend. Captured live:
POST https://lux.id/v1/iam/login -> 200 (password fine)
GET https://lux.id/.well-known/openid-configuration -> net::ERR_FAILED
POST https://lux.id/v1/iam/oauth/token -> net::ERR_FAILED
"blocked by CORS policy: No 'Access-Control-Allow-Origin' header"
THE ALLOWLIST IS DERIVED, NOT CONFIGURED. An origin is permitted iff some
registered application already declares a redirect_uri on it — the same set
OAuth trusts to receive a code. So CORS can never be looser than the redirect
allowlist, and there is no second list to drift: provision a host and login
works from it, which composes exactly with `iam2 provision`.
Scoped deliberately. Only the endpoints a browser-side client actually calls
are opened (discovery, JWKS, token, userinfo, revoke, logout); admin/bootstrap
upsert, credential login and /oauth/authorize (a top-level redirect, not a
fetch) stay closed, and a test asserts both halves of that set. Credentials are
NOT allowed — a PKCE exchange proves itself in the body, never via a cookie —
so echoing an origin cannot authorize a cookie-bearing request.
The origin set is cached for 60s; a storage error keeps the last good set
rather than failing open to every origin or closed to all of them.
The provision documents in each org's universe repo described a mechanism
that did not exist: IAM_PROVISION_CONFIG / IAM_PROVISION_ON_BOOT are set on
the deployment and a ConfigMap is present, but nothing in this repo has ever
read either, the ConfigMap is not mounted, and it declares only one org. So
no org's app graph was ever reconciled, and registrations drifted from the
apps that call them — which presents as invalid_client / redirect_uri_mismatch
long after the change that caused it.
Build the missing driver. The convergence primitive already existed —
POST /v1/iam/admin/applications/upsert is idempotent by natural key and
PRESERVES an existing clientSecret when the request omits one. This package
is the half that was missing: read the document, derive every client, apply.
Mechanism here, policy in each org's repo. This ships ZERO brands.
Everything is DERIVED from an app's name and type, so a document line stays
one line and cannot drift:
clientId ALWAYS <org>-<app> (HIP-0111)
redirect https://<host>/auth/callback per host, for browser types
Both derivations are verified against production, not assumed: lux.cloud,
zoo.cloud and platform.hanzo.ai all drive client_id=<org>-<app> with
redirect_uri=https://<host>/auth/callback. Note it is NOT /api/... — the
/v1-only rule holds and the browser callback is unversioned.
Re-running is a no-op by construction, and the test suite pins the contract
that makes that true: the request must never carry clientSecret, or every
converge would silently rotate a live credential.
iam2 provision --config <doc> --url https://lux.id [--dry-run]
The provision documents in each org's universe repo described a mechanism
that did not exist: IAM_PROVISION_CONFIG / IAM_PROVISION_ON_BOOT are set on
the deployment and a ConfigMap is present, but nothing in this repo has ever
read either, the ConfigMap is not mounted, and it declares only one org. So
no org's app graph was ever reconciled, and registrations drifted from the
apps that call them — which presents as invalid_client / redirect_uri_mismatch
long after the change that caused it.
Build the missing driver. The convergence primitive already existed —
POST /v1/iam/admin/applications/upsert is idempotent by natural key and
PRESERVES an existing clientSecret when the request omits one. This package
is the half that was missing: read the document, derive every client, apply.
Mechanism here, policy in each org's repo. This ships ZERO brands.
Everything is DERIVED from an app's name and type, so a document line stays
one line and cannot drift:
clientId ALWAYS <org>-<app> (HIP-0111)
redirect https://<host>/auth/callback per host, for browser types
Both derivations are verified against production, not assumed: lux.cloud,
zoo.cloud and platform.hanzo.ai all drive client_id=<org>-<app> with
redirect_uri=https://<host>/auth/callback. Note it is NOT /api/... — the
/v1-only rule holds and the browser callback is unversioned.
Re-running is a no-op by construction, and the test suite pins the contract
that makes that true: the request must never carry clientSecret, or every
converge would silently rotate a live credential.
iam2 provision --config <doc> --url https://lux.id [--dry-run]
The push-mirror carries native -> GitHub. This is the return leg, so the two
forges converge from either side.
They compose rather than fight: a native commit reaches GitHub via the mirror,
so this job then sees LOCAL == REMOTE and exits "in sync"; a GitHub commit
fast-forwards native here and the resulting mirror push is a no-op. No echo,
no loop.
Fast-forward ONLY -- a divergence fails loudly instead of force-pushing either
side and destroying whichever history lost the race. Same shape hanzoai/app has
run green 299 times.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The push-mirror carries native -> GitHub. This is the return leg, so the two
forges converge from either side.
They compose rather than fight: a native commit reaches GitHub via the mirror,
so this job then sees LOCAL == REMOTE and exits "in sync"; a GitHub commit
fast-forwards native here and the resulting mirror push is a no-op. No echo,
no loop.
Fast-forward ONLY -- a divergence fails loudly instead of force-pushing either
side and destroying whichever history lost the race. Same shape hanzoai/app has
run green 299 times.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v9.22.0 renames the package identifier from redis to kv. Nothing here imports
it — it is an indirect dependency — so this is a graph bump only, keeping the
version aligned with the repos that do import it.
v9.22.0 renames the package identifier from redis to kv. Nothing here imports
it — it is an indirect dependency — so this is a graph bump only, keeping the
version aligned with the repos that do import it.
The KV client moved orgs (hanzoai/kv-go -> hanzokv/go) and this module reached
it transitively via hanzoai/orm, which has already migrated. Bumping orm lets
tidy drop the old path — no source here imported either client directly, so
there was nothing to edit, only a dependency to advance.
The KV client moved orgs (hanzoai/kv-go -> hanzokv/go) and this module reached
it transitively via hanzoai/orm, which has already migrated. Bumping orm lets
tidy drop the old path — no source here imported either client directly, so
there was nothing to edit, only a dependency to advance.
.hanzo/workflows is already canonical here; the forge takes the FIRST existing
dir in WORKFLOW_DIRS (.hanzo before .gitea) and ignores the rest, so this file
never ran. Dead cruft — delete it. One native-CI dir, .hanzo.
.hanzo/workflows is already canonical here; the forge takes the FIRST existing
dir in WORKFLOW_DIRS (.hanzo before .gitea) and ignores the rest, so this file
never ran. Dead cruft — delete it. One native-CI dir, .hanzo.
Every credential IAM mints is one of exactly two things, and the schema already
said so: AccessKey (pk-*) is the publishable half — frontend-safe, the hot lookup
index — and AccessSecret (sk-*) is the confidential half. `hk-` was a third
prefix meaning whichever of those two you happened to be holding, so every
consumer had to know all three: the spelling 'hk-/pk-/sk-' appears verbatim in
the gateway auth filter, the cloud audit redactor, the registry resolver, and
CapKeyResolve's own doc comment. Three names, two concepts.
Two mint sites carried it, and the second was worse than the first:
oidc.newAccessKey hand-rolled 'hk-' + newOpaqueToken, duplicating the
keys.Mint it should have called -> keys.Mint("sk").
A durable full-access bearer credential IS the
confidential half. Its (string, error) signature also
lied — it could not fail — so the dead branch is gone.
provision.mintCredential
serviceaccounts.mint minted BOTH halves as Mint("hk") — the public lookup
handle and the argon2id-digested secret, identical
prefixes — while the comment two lines down says 'the
access key is a public lookup handle, not a secret'.
An exposed secret was indistinguishable from a harmless
handle at a glance, in a log or a config file. Now
pk- for the handle, sk- for the secret.
Forward-only and non-breaking: resolution is an exact-value lookup
(store.UserByAccessKey), never a prefix match, and the gateway filter already
accepts pk-/sk-. Keys already issued keep authenticating; every NEW key is
minted correctly. The prefix carries no authority — it is a readable label on an
opaque random token.
Tests: 24 packages ok, 0 fail. The three suites that asserted 'hk-' now assert
the half they actually receive (sk- for the user key, pk- for the service-account
handle) — they were encoding the bug.
Every credential IAM mints is one of exactly two things, and the schema already
said so: AccessKey (pk-*) is the publishable half — frontend-safe, the hot lookup
index — and AccessSecret (sk-*) is the confidential half. `hk-` was a third
prefix meaning whichever of those two you happened to be holding, so every
consumer had to know all three: the spelling 'hk-/pk-/sk-' appears verbatim in
the gateway auth filter, the cloud audit redactor, the registry resolver, and
CapKeyResolve's own doc comment. Three names, two concepts.
Two mint sites carried it, and the second was worse than the first:
oidc.newAccessKey hand-rolled 'hk-' + newOpaqueToken, duplicating the
keys.Mint it should have called -> keys.Mint("sk").
A durable full-access bearer credential IS the
confidential half. Its (string, error) signature also
lied — it could not fail — so the dead branch is gone.
provision.mintCredential
serviceaccounts.mint minted BOTH halves as Mint("hk") — the public lookup
handle and the argon2id-digested secret, identical
prefixes — while the comment two lines down says 'the
access key is a public lookup handle, not a secret'.
An exposed secret was indistinguishable from a harmless
handle at a glance, in a log or a config file. Now
pk- for the handle, sk- for the secret.
Forward-only and non-breaking: resolution is an exact-value lookup
(store.UserByAccessKey), never a prefix match, and the gateway filter already
accepts pk-/sk-. Keys already issued keep authenticating; every NEW key is
minted correctly. The prefix carries no authority — it is a readable label on an
opaque random token.
Tests: 24 packages ok, 0 fail. The three suites that asserted 'hk-' now assert
the half they actually receive (sk- for the user key, pk- for the service-account
handle) — they were encoding the bug.
Enabling Actions on the mirror armed more than main. Mirror-synced TAGS fire
a push event (services/mirror/mirror_pull.go:357 — a new ref calls
SyncPushCommits with the tag's RefFullName), and the v1.33.x release line is
diverged from main with no .hanzo/workflows to shadow it, so a v* tag there
makes Gitea collect .github/workflows/build.yml. That file logs in as
hanzo-dev with GH_PAT, which EXISTS as a git.hanzo.ai org secret, so it would
succeed — racing GitHub Actions to push the same immutable
ghcr.io/hanzoai/iam:v<X.Y.Z> from the same commit. Two digests behind one
name, on the identity control plane.
Gitea's disable list is keyed on the workflow filename
(services/actions/notifier_helper.go: cfg.IsWorkflowDisabled(wf.EntryName)),
so `build.yml` is now disabled on this repo. This file takes a distinct name
so that block cannot silence it too — and `image` is the honest name anyway:
it builds an image, it does not deploy.
Reversible: delete the release line's builder or unify the two lines, then
re-enable build.yml.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Enabling Actions on the mirror armed more than main. Mirror-synced TAGS fire
a push event (services/mirror/mirror_pull.go:357 — a new ref calls
SyncPushCommits with the tag's RefFullName), and the v1.33.x release line is
diverged from main with no .hanzo/workflows to shadow it, so a v* tag there
makes Gitea collect .github/workflows/build.yml. That file logs in as
hanzo-dev with GH_PAT, which EXISTS as a git.hanzo.ai org secret, so it would
succeed — racing GitHub Actions to push the same immutable
ghcr.io/hanzoai/iam:v<X.Y.Z> from the same commit. Two digests behind one
name, on the identity control plane.
Gitea's disable list is keyed on the workflow filename
(services/actions/notifier_helper.go: cfg.IsWorkflowDisabled(wf.EntryName)),
so `build.yml` is now disabled on this repo. This file takes a distinct name
so that block cannot silence it too — and `image` is the honest name anyway:
it builds an image, it does not deploy.
Reversible: delete the release line's builder or unify the two lines, then
re-enable build.yml.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deleted only after the replacement was verified, not before. Evidence:
git.hanzo.ai run 394, .hanzo/workflows/build.yml, event push (mirror-sync
of 6a91a53), job `build` on git-runner-2, conclusion success — which
published ghcr.io/hanzoai/iam:sha-6a91a53, digest sha256:4d0f1ff087bc, at
2026-07-25T23:09:34Z, confirmed pullable by an independent registry
manifest fetch (HTTP 200, OCI manifest, 4 layers). First image ever built
from main since the 07-24 neutralization, and this mirror's first run ever.
The file being removed built nothing: it was `on: workflow_dispatch` with a
single echo. It also pointed callers at .hanzo/workflows/deploy.yml, a path
that no longer exists, so leaving it in place would misdirect. GitHub now
holds zero CI for this repo; the mirror pulls on its 10m interval and the
sync push fires the native build.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Deleted only after the replacement was verified, not before. Evidence:
git.hanzo.ai run 394, .hanzo/workflows/build.yml, event push (mirror-sync
of e71ce0f), job `build` on git-runner-2, conclusion success — which
published ghcr.io/hanzoai/iam:sha-e71ce0f, digest sha256:4d0f1ff087bc, at
2026-07-25T23:09:34Z, confirmed pullable by an independent registry
manifest fetch (HTTP 200, OCI manifest, 4 layers). First image ever built
from main since the 07-24 neutralization, and this mirror's first run ever.
The file being removed built nothing: it was `on: workflow_dispatch` with a
single echo. It also pointed callers at .hanzo/workflows/deploy.yml, a path
that no longer exists, so leaving it in place would misdirect. GitHub now
holds zero CI for this repo; the mirror pulls on its 10m interval and the
sync push fires the native build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.github/workflows/build.yml was neutralized to a dispatch-only echo on
2026-07-24 (3ab19a5b4), handing the build to .hanzo/workflows/deploy.yml.
That file could never run, so main has shipped no image since. Measured:
main is diverged from the v1.33.x line that ships (136 ahead / 142 behind
v1.33.8); v1.33.8 came from a tag push on that other line; git.hanzo.ai had
Actions disabled on this mirror, so the native file had zero runs, ever.
Replace it with a builder that matches the forge as measured:
runs-on hanzo-build-linux-amd64 the only label the four online act_runners
advertise; the old hanzo-linux-amd64 matches nothing and queues forever
buildx + build-push-action buildctl-daemonless.sh is absent from
catthehacker/ubuntu:act-24.04, the image this pool serves
GHCR_USER/GHCR_TOKEN, GH_PAT org-level secrets that exist; the old
GIT_CLONE_TOKEN is on neither repo nor org, and the Dockerfile needs a
token to fetch the private hanzoai modules
no kubectl patch the App CR is ArgoCD-managed with selfHeal;
rollout stays a reviewed tag pin in hanzoai/universe
Immutable sha- tag only: a re-pushed semver leaves two digests behind one
name, which is how platform's v4.4.5 came to mean two builds on 2026-07-25.
Renamed deploy.yml -> build.yml because it builds and does not deploy.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
.github/workflows/build.yml was neutralized to a dispatch-only echo on
2026-07-24 (f267b4ae8), handing the build to .hanzo/workflows/deploy.yml.
That file could never run, so main has shipped no image since. Measured:
main is diverged from the v1.33.x line that ships (136 ahead / 142 behind
v1.33.8); v1.33.8 came from a tag push on that other line; git.hanzo.ai had
Actions disabled on this mirror, so the native file had zero runs, ever.
Replace it with a builder that matches the forge as measured:
runs-on hanzo-build-linux-amd64 the only label the four online act_runners
advertise; the old hanzo-linux-amd64 matches nothing and queues forever
buildx + build-push-action buildctl-daemonless.sh is absent from
catthehacker/ubuntu:act-24.04, the image this pool serves
GHCR_USER/GHCR_TOKEN, GH_PAT org-level secrets that exist; the old
GIT_CLONE_TOKEN is on neither repo nor org, and the Dockerfile needs a
token to fetch the private hanzoai modules
no kubectl patch the App CR is ArgoCD-managed with selfHeal;
rollout stays a reviewed tag pin in hanzoai/universe
Immutable sha- tag only: a re-pushed semver leaves two digests behind one
name, which is how platform's v4.4.5 came to mean two builds on 2026-07-25.
Renamed deploy.yml -> build.yml because it builds and does not deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The clean rewrite already enforces all 3 iam-v1 hardening invariants by construction
(no ?userId override; app principals never admin/super; SCIM re-pins to caller org
before read). These mutation-proven tests PIN them so a future refactor can't
silently reopen the escalation / cross-org existence-oracle classes. Test-only.
The clean rewrite already enforces all 3 iam-v1 hardening invariants by construction
(no ?userId override; app principals never admin/super; SCIM re-pins to caller org
before read). These mutation-proven tests PIN them so a future refactor can't
silently reopen the escalation / cross-org existence-oracle classes. Test-only.
Ports the security INVARIANTS of three iam-v1 (Casdoor fork) fixes into the clean
iam2 rewrite. The rewrite's architecture already ENFORCES all three by construction
(no ?userId override exists; an app principal is never Admin/Super; SCIM re-pins the
owner to the caller's org before any store read) — so this adds the missing
regression guards that pin each invariant to the surface that now enforces it, not
new enforcement code.
- impersonation (iam-v1 c904dc0a + 0e5485a5): the ?userId override is GONE
(userinfo/whoami/get-account take the subject from the verified JWT sub). The
analogue "act as an arbitrary named user" surface is the confidential-client mint
(issue-user-token / mint-user-keys, ?id=<owner>/<name>); its escalation block is
mintTarget's reserved-org gate. TestImpersonation_* proves a general minter cannot
reach an admin-org (SuperAdmin) target, and that the admin-mint capability is the
sole boundary that can. Mutation-verified: deleting the gate mints a token with
sub=admin/z (the exact iam-v1 super spoof) and the test fails.
- SCIM read-scope + existence oracle (iam-v1 da0732a1): scopedTarget re-pins a
non-super's owner to its own org on every verb, so list/count are org-scoped and a
foreign row is never addressed. TestRed_scim* proves a foreign-existing id, a
foreign-missing id, and an own-missing id are the identical 404 (no 404-vs-403
cross-org existence oracle) and that cross-org DELETE/PATCH never reach the row.
Mutation-verified: skipping the re-scope makes a foreign row distinguishable and
the test fails.
Test-only; no production code changed. Build + full suite green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Ports the security INVARIANTS of three iam-v1 (Casdoor fork) fixes into the clean
iam2 rewrite. The rewrite's architecture already ENFORCES all three by construction
(no ?userId override exists; an app principal is never Admin/Super; SCIM re-pins the
owner to the caller's org before any store read) — so this adds the missing
regression guards that pin each invariant to the surface that now enforces it, not
new enforcement code.
- impersonation (iam-v1 c904dc0a + 0e5485a5): the ?userId override is GONE
(userinfo/whoami/get-account take the subject from the verified JWT sub). The
analogue "act as an arbitrary named user" surface is the confidential-client mint
(issue-user-token / mint-user-keys, ?id=<owner>/<name>); its escalation block is
mintTarget's reserved-org gate. TestImpersonation_* proves a general minter cannot
reach an admin-org (SuperAdmin) target, and that the admin-mint capability is the
sole boundary that can. Mutation-verified: deleting the gate mints a token with
sub=admin/z (the exact iam-v1 super spoof) and the test fails.
- SCIM read-scope + existence oracle (iam-v1 da0732a1): scopedTarget re-pins a
non-super's owner to its own org on every verb, so list/count are org-scoped and a
foreign row is never addressed. TestRed_scim* proves a foreign-existing id, a
foreign-missing id, and an own-missing id are the identical 404 (no 404-vs-403
cross-org existence oracle) and that cross-org DELETE/PATCH never reach the row.
Mutation-verified: skipping the re-scope makes a foreign row distinguishable and
the test fails.
Test-only; no production code changed. Build + full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A publishable pk- is public (it ships in client JS) and must authenticate no
read. Today store.UserByAccessKey resolves a pk- to its owning user, so
get-user?accessKey and the registry token path turn a public key into a read
principal. Remove the pk- branch: only the secret hk-/sk- shapes resolve to a
user; a pk- falls through to ErrNotFound on every principal path (get-user AND
registry), so a public key authenticates no read anywhere.
Add the write-only publishable key as a Scope on schema.Key. KeyScopePublish
mints a pk- half only (no sk-, forced empty on create and update), and its ONLY
resolution is org-only: store.PublishableKeyByAccessKey backs a new
GET /v1/iam/resolve-key, gated by CapPublishableResolve (its own least-privilege
capability, narrower than CapKeyResolve which discloses principals). It is the
dual of get-user?accessKey, co-located in compat.
Resolve contract cloud consumes:
GET /v1/iam/resolve-key?accessKey=<pk-> (client_secret_basic, CapPublishableResolve)
-> 200 {status:ok, data:{org:<owner>, scope:"publish"}} live publishable key
-> 200 {status:error, msg:"the entity does not exist"} anything else
Tests prove a pk- never becomes a principal via UserByAccessKey,
get-user?accessKey (even for a CapKeyResolve holder), the registry, or as a
bearer; resolve-key returns org-only and refuses a secret key's pk- half, an
sk-, an expired/unknown key, a non-cap app, and a human; a publishable key mints
a pk- only.
Self-service onboarding provisions a tenant atomically through the ONE
provision() primitive: personal org (== billing account) + founding user as
its own admin + ONE org-scoped, HASHED, metered service-account credential.
Backend-portable resume (Founder stamp) converges an interrupted signup on
any backend; provision-not-promote (store.IsReservedOrg guard) blocks the
reserved admin org; first-run gate + caller re-resolution.
Exposed as the self-service POST /v1/iam/onboard and the service-token
POST /v1/iam/admin/provision (the ONE atomic op the cloud orchestrator calls
instead of a create-org + move-user pair). The tenant credential is a
service account (hk- key + argon2id secret digest, shown once) — no plaintext
at rest. The unified service-token validator lives in httpx.ServiceTokenAuth
(shared with bootstrap).
Pre-pay model: NO trial credit. A provisioned org starts at a ZERO balance;
the metering gate refuses metered requests until it pre-pays, then debits.
Integrated onto origin/main; adopts main's store.IsReservedOrg (ONE reserved
set) and schema.User.Id (surrogate via user.Model.Id() for the Founder stamp).
Squash of the reviewed pre-pay series f35d3d889..be36295a6 (red verdict SHIP),
replayed clean off a local junk base (a 239MB iamd binary + node_modules that
must never reach origin).
Tests: provision happy-path/idempotent/atomic-resume/founder-fence/tenant-
isolation/reserved-refusal + the service-token endpoint, all green.
Self-service onboarding provisions a tenant atomically through the ONE
provision() primitive: personal org (== billing account) + founding user as
its own admin + ONE org-scoped, HASHED, metered service-account credential.
Backend-portable resume (Founder stamp) converges an interrupted signup on
any backend; provision-not-promote (store.IsReservedOrg guard) blocks the
reserved admin org; first-run gate + caller re-resolution.
Exposed as the self-service POST /v1/iam/onboard and the service-token
POST /v1/iam/admin/provision (the ONE atomic op the cloud orchestrator calls
instead of a create-org + move-user pair). The tenant credential is a
service account (hk- key + argon2id secret digest, shown once) — no plaintext
at rest. The unified service-token validator lives in httpx.ServiceTokenAuth
(shared with bootstrap).
Pre-pay model: NO trial credit. A provisioned org starts at a ZERO balance;
the metering gate refuses metered requests until it pre-pays, then debits.
Integrated onto origin/main; adopts main's store.IsReservedOrg (ONE reserved
set) and schema.User.Id (surrogate via user.Model.Id() for the Founder stamp).
Squash of the reviewed pre-pay series f35d3d889..be36295a6 (red verdict SHIP),
replayed clean off a local junk base (a 239MB iamd binary + node_modules that
must never reach origin).
Tests: provision happy-path/idempotent/atomic-resume/founder-fence/tenant-
isolation/reserved-refusal + the service-token endpoint, all green.
SaveFederationState burned the single-use federation transaction via a non-atomic
Get -> set Used -> Update (federationCallbackHandler): two concurrent callbacks on one
state could both observe Used=false and both complete the same federated login — the
same lost-update/TOCTOU class fixed for TakeChallenge in ITEM 4. Exploitability is low
(browser-cookie-bound, same-user, short TTL; a double-burn just re-completes the same
login, no identity crossing), but it is the exact twin and must not be left non-atomic.
Replace SaveFederationState with store.BurnFederationState: resolve the row's real key
via the (owner,name) query path, then find-and-burn inside a GetForUpdate transaction
(mirrors the wallet challenge burn / TakeChallenge). Refusals (gone/used/expired/race)
roll back with the opaque ErrFederationConsumed; a store fault returns the raw error so
the caller keeps its distinct 'internal error' message. The bind-cookie (CSRF) check
still runs on the prior read BEFORE the burn, so a CSRF-failed replay never burns a
victim's pending state. SaveFederationState deleted (no other callers).
Test TestBurnFederationState_concurrentBurn_exactlyOneWinner: 16 parallel burns on one
state yield EXACTLY one winner. FAILS before (3-7 winners), PASSES after (-race).
SaveFederationState burned the single-use federation transaction via a non-atomic
Get -> set Used -> Update (federationCallbackHandler): two concurrent callbacks on one
state could both observe Used=false and both complete the same federated login — the
same lost-update/TOCTOU class fixed for TakeChallenge in ITEM 4. Exploitability is low
(browser-cookie-bound, same-user, short TTL; a double-burn just re-completes the same
login, no identity crossing), but it is the exact twin and must not be left non-atomic.
Replace SaveFederationState with store.BurnFederationState: resolve the row's real key
via the (owner,name) query path, then find-and-burn inside a GetForUpdate transaction
(mirrors the wallet challenge burn / TakeChallenge). Refusals (gone/used/expired/race)
roll back with the opaque ErrFederationConsumed; a store fault returns the raw error so
the caller keeps its distinct 'internal error' message. The bind-cookie (CSRF) check
still runs on the prior read BEFORE the burn, so a CSRF-failed replay never burns a
victim's pending state. SaveFederationState deleted (no other callers).
Test TestBurnFederationState_concurrentBurn_exactlyOneWinner: 16 parallel burns on one
state yield EXACTLY one winner. FAILS before (3-7 winners), PASSES after (-race).
users.API.Update (POST /v1/iam/update-user, the canonical admin profile edit) is a
full-row write that preserved Id/CreatedTime/password from the stored row but NOT
SigninWrongTimes/LastSigninWrongTime. A client body omitting signinWrongTimes (or
sending 0) therefore overwrote a LOCKED account's counter to 0 — an org-admin's routine
profile edit silently unlocked a user mid-attack. Authz is org-admin/SuperAdmin (not
anonymous), so LOW, but it is a real hole in the ITEM-2 invariant: recordAttempt must be
the ONLY writer of the lockout counters.
Fix: carry both lockout fields from the stored row and ignore the body value, symmetric
with the server-owned Id/CreatedTime block two lines up.
Parity-suite check: no test asserts a client-settable signinWrongTimes — every
SigninWrongTimes assertion verifies the lockout MECHANISM (increment/reset/threshold),
all preserved. Nothing to remove.
Test TestUpdate_preservesLockoutCounter: lock the account, then update-user with a
zero-value counter body; counter must stay >= threshold. FAILS before (0), PASSES after.
users.API.Update (POST /v1/iam/update-user, the canonical admin profile edit) is a
full-row write that preserved Id/CreatedTime/password from the stored row but NOT
SigninWrongTimes/LastSigninWrongTime. A client body omitting signinWrongTimes (or
sending 0) therefore overwrote a LOCKED account's counter to 0 — an org-admin's routine
profile edit silently unlocked a user mid-attack. Authz is org-admin/SuperAdmin (not
anonymous), so LOW, but it is a real hole in the ITEM-2 invariant: recordAttempt must be
the ONLY writer of the lockout counters.
Fix: carry both lockout fields from the stored row and ignore the body value, symmetric
with the server-owned Id/CreatedTime block two lines up.
Parity-suite check: no test asserts a client-settable signinWrongTimes — every
SigninWrongTimes assertion verifies the lockout MECHANISM (increment/reset/threshold),
all preserved. Nothing to remove.
Test TestUpdate_preservesLockoutCounter: lock the account, then update-user with a
zero-value counter body; counter must stay >= threshold. FAILS before (0), PASSES after.
TakeChallenge did Get -> set Used=true -> UpdateCtx, a non-atomic read-modify-write:
two concurrent finishMfa calls on ONE captured passcode could both observe Used=false
and both win, double-spending the challenge (the F-D1 lost-update/TOCTOU class). Move
the find-and-burn inside a GetForUpdate transaction — the row lock is held from the
read through the Used=true write, so exactly one caller wins — mirroring the wallet
challenge burn. Every refusal still collapses to the one opaque ErrChallenge.
Correct the mfa_gate deferral note: it credited 'the atomic password lockout' for
throttling the second factor, but the MFA threat model assumes the password is KNOWN
and a CORRECT password RESETS the lockout (never trips it), so the password lockout
provides NO throttle on minting fresh challenges. The real friction is the argon2id
cost per fresh challenge + the 30s TOTP window (a moving 6-digit target). The note now
states that truthfully and records that the burn is now atomic.
Test: TestTakeChallenge_concurrentBurn_exactlyOneWinner — 16 parallel TakeChallenge on
one id yield EXACTLY one winner. FAILS before (8-11 winners), PASSES after (-race).
TakeChallenge did Get -> set Used=true -> UpdateCtx, a non-atomic read-modify-write:
two concurrent finishMfa calls on ONE captured passcode could both observe Used=false
and both win, double-spending the challenge (the F-D1 lost-update/TOCTOU class). Move
the find-and-burn inside a GetForUpdate transaction — the row lock is held from the
read through the Used=true write, so exactly one caller wins — mirroring the wallet
challenge burn. Every refusal still collapses to the one opaque ErrChallenge.
Correct the mfa_gate deferral note: it credited 'the atomic password lockout' for
throttling the second factor, but the MFA threat model assumes the password is KNOWN
and a CORRECT password RESETS the lockout (never trips it), so the password lockout
provides NO throttle on minting fresh challenges. The real friction is the argon2id
cost per fresh challenge + the 30s TOTP window (a moving 6-digit target). The note now
states that truthfully and records that the burn is now atomic.
Test: TestTakeChallenge_concurrentBurn_exactlyOneWinner — 16 parallel TakeChallenge on
one id yield EXACTLY one winner. FAILS before (8-11 winners), PASSES after (-race).
Two comments mis-stated the key shape, so a reviewer reasoning about a locked write
would assume the wrong key:
- internal/users/lockout.go said the users.Create'd key is 'an auto-int64';
- internal/schema/user.go said the orm storage id IS '(Owner,Name)' / the OIDC sub.
The actual Create-shape storage key is a store-assigned surrogate id — a GenerateID
DECIMAL STRING (e.g. "17847909129933610000001") — because Create allocates rather than
pinning a key; only MIGRATED casdoor rows are keyed 'owner/name' (SetId). (Owner,Name)
is the natural/QUERY key, not necessarily the storage key. Both comments now state the
real shapes and point at the one correct resolution (Key().Encode(), see updateUser).
No behavior change.
Two comments mis-stated the key shape, so a reviewer reasoning about a locked write
would assume the wrong key:
- internal/users/lockout.go said the users.Create'd key is 'an auto-int64';
- internal/schema/user.go said the orm storage id IS '(Owner,Name)' / the OIDC sub.
The actual Create-shape storage key is a store-assigned surrogate id — a GenerateID
DECIMAL STRING (e.g. "17847909129933610000001") — because Create allocates rather than
pinning a key; only MIGRATED casdoor rows are keyed 'owner/name' (SetId). (Owner,Name)
is the natural/QUERY key, not necessarily the storage key. Both comments now state the
real shapes and point at the one correct resolution (Key().Encode(), see updateUser).
No behavior change.
onboard and update-preferences wrote the whole user row via user.UpdateCtx on the
snapshot loaded at handler entry. If a wrong-password lockout increment
(users.recordAttempt) landed between that load and the write, the stale full-row write
erased it — Red PoC4 dropped SigninWrongTimes 5->0, unlocking the account. All such
writers are authenticated (own-row self-service, or a privileged minter), so this is
LOW, but the durable fix is to keep the counter off the full-row-write path.
Route both through updateUser (introduced for ITEM 1): the row is read FRESH under a
GetForUpdate row lock and written back, so the lockout counter is carried from the
current committed value, never a stale snapshot — the same row lock recordAttempt
takes, so the two serialize. preferences additionally now merges against the FRESH
stored blob under the lock, so a concurrent product/device setting a DIFFERENT key is
preserved too (the shallow-merge promise is now actually delivered under concurrency).
Narrow recordAttempt's 'collateral clobber removed' note to the truth: its own write is
lost-update-free within its lock window; cross-writer safety comes from routing every
other user-row writer through the SAME lock (updateUser).
Tests (internal/oidc/user_test.go):
- TestUpdateUser_preservesConcurrentLockoutCount: deterministic differential — a CONTROL
branch reproduces the pre-fix stale full-row write RESETTING the counter to 0, the FIX
branch shows updateUser PRESERVING it (fail-before/pass-after in one permanent test).
- TestUpdateUser_concurrentWithLockIncrement_exactCount: -race, N updateUser writes
racing N atomic increments leave the counter at EXACTLY N (no lost update).
onboard and update-preferences wrote the whole user row via user.UpdateCtx on the
snapshot loaded at handler entry. If a wrong-password lockout increment
(users.recordAttempt) landed between that load and the write, the stale full-row write
erased it — Red PoC4 dropped SigninWrongTimes 5->0, unlocking the account. All such
writers are authenticated (own-row self-service, or a privileged minter), so this is
LOW, but the durable fix is to keep the counter off the full-row-write path.
Route both through updateUser (introduced for ITEM 1): the row is read FRESH under a
GetForUpdate row lock and written back, so the lockout counter is carried from the
current committed value, never a stale snapshot — the same row lock recordAttempt
takes, so the two serialize. preferences additionally now merges against the FRESH
stored blob under the lock, so a concurrent product/device setting a DIFFERENT key is
preserved too (the shallow-merge promise is now actually delivered under concurrency).
Narrow recordAttempt's 'collateral clobber removed' note to the truth: its own write is
lost-update-free within its lock window; cross-writer safety comes from routing every
other user-row writer through the SAME lock (updateUser).
Tests (internal/oidc/user_test.go):
- TestUpdateUser_preservesConcurrentLockoutCount: deterministic differential — a CONTROL
branch reproduces the pre-fix stale full-row write RESETTING the counter to 0, the FIX
branch shows updateUser PRESERVING it (fail-before/pass-after in one permanent test).
- TestUpdateUser_concurrentWithLockIncrement_exactCount: -race, N updateUser writes
racing N atomic increments leave the counter at EXACTLY N (no lost update).
saveUser resolved a user row by orm.Get(owner+"/"+name), which only matches a
MIGRATED casdoor row (owner/name storage key). A v2-native users.Create'd user gets
a store-assigned surrogate key (GenerateID decimal string) + a UUID sub, so the
owner/name lookup MISSED it — every hk- key mint/revoke for an account created after
the cutover errored (orm.ErrNotFound -> 500). Migrated users worked; new signups did not.
Introduce updateUser: resolve the row's REAL storage key via the (owner,name) query
path (store.GetUserByName(...).Key().Encode(), both shapes), then read-modify-write it
inside a GetForUpdate transaction (mirrors recordAttempt / the wallet challenge burn).
mutate edits the FRESH locked row in place; the whole fresh row is written back, so
fields the caller does not touch — notably the lockout counters — are carried from the
current committed value, not a stale snapshot (this also removes the counter clobber for
these paths, closed fully for onboard/preferences in the ITEM 2 commit).
Route all four saveUser callers through it (mint, revoke, federated link, federated
unlink) and delete saveUser — one and only one way to write back a user row.
Test: TestMintRevokeUserKeys_createPathUser_persists drives mint AND revoke against a
canonical users.Create'd user; FAILS before (mint 500), PASSES after.
saveUser resolved a user row by orm.Get(owner+"/"+name), which only matches a
MIGRATED casdoor row (owner/name storage key). A v2-native users.Create'd user gets
a store-assigned surrogate key (GenerateID decimal string) + a UUID sub, so the
owner/name lookup MISSED it — every hk- key mint/revoke for an account created after
the cutover errored (orm.ErrNotFound -> 500). Migrated users worked; new signups did not.
Introduce updateUser: resolve the row's REAL storage key via the (owner,name) query
path (store.GetUserByName(...).Key().Encode(), both shapes), then read-modify-write it
inside a GetForUpdate transaction (mirrors recordAttempt / the wallet challenge burn).
mutate edits the FRESH locked row in place; the whole fresh row is written back, so
fields the caller does not touch — notably the lockout counters — are carried from the
current committed value, not a stale snapshot (this also removes the counter clobber for
these paths, closed fully for onboard/preferences in the ITEM 2 commit).
Route all four saveUser callers through it (mint, revoke, federated link, federated
unlink) and delete saveUser — one and only one way to write back a user row.
Test: TestMintRevokeUserKeys_createPathUser_persists drives mint AND revoke against a
canonical users.Create'd user; FAILS before (mint 500), PASSES after.
The passcode/recovery verify in finishMfa has no dedicated per-account counter,
but it is not an unthrottled oracle: the MFA challenge is single-use (TakeChallenge
burns it before the verify), so each guess needs a fresh challenge, which needs a
fresh first-factor password auth — now rate-limited by the atomic lockout (F-D1).
A dedicated second-factor counter is a separate, tested change, deliberately not
folded into this rework to avoid destabilizing the MFA path.
The passcode/recovery verify in finishMfa has no dedicated per-account counter,
but it is not an unthrottled oracle: the MFA challenge is single-use (TakeChallenge
burns it before the verify), so each guess needs a fresh challenge, which needs a
fresh first-factor password auth — now rate-limited by the atomic lockout (F-D1).
A dedicated second-factor counter is a separate, tested change, deliberately not
folded into this rework to avoid destabilizing the MFA path.
userByPassword looped {admin, hanzo} calling users.Authenticate per org, so a
single wrong docker-login on a name present in BOTH orgs (z@hanzo.ai collides
across admin and hanzo) incremented BOTH rows — locking in ~3 requests not 5 —
and a correct hanzo/<name> login (wrong for admin/<name>) bumped admin/<name>
every use. On a PUBLIC unauthenticated endpoint this let an anonymous caller lock
the platform SuperAdmin out of every password door (login/ROPC/registry share the
one row counter) in five wrong tries, and offered a low-throttle brute-force
oracle for the super's password.
Skip the reserved candidate org in the password walk: a reserved-org
(SuperAdmin/built-in/service) principal is no longer authenticated by a guessable
WEB PASSWORD on the public registry realm — it pushes with its HIGH-ENTROPY
machine credential (API key via userByKey, or service account) which are
unchanged and are the documented CI/SuperAdmin push identity. That both (a)
removes the reserved-org password from the lockout path entirely — no unauth
account-lock DoS and no brute-force oracle on the super — and (b) collapses the
walk to the single non-reserved candidate, so a wrong attempt drives at most ONE
row's counter (login-parity, no double-speed) and a correct hanzo/<name> password
can never touch admin/<name>'s counter (no cross-org coupling).
PARITY: casdoor's registry path resolved {admin, hanzo} passwords with NO lockout
at all, so the per-account lock on this endpoint is NEW surface (added by F-D1);
narrowing the PASSWORD path to non-reserved orgs is a deliberate, tested
hardening of that new surface, aligned with ROPC which already refuses reserved-
org password grants (token.go). API-key and service-account paths are untouched.
Tests (internal/registry/registry_test.go), fail-before / pass-after:
- SuperAdminPassword_Denied: correct admin/z password -> 401 (pre-fix: 200+token).
- AdminPassword_NotDosableOnPublicRegistry: flood of 3x wrongs leaves admin/root
counter at 0 (pre-fix: 5 -> locked, DoS).
- RegistryPassword_NoCrossOrgCoupling: one wrong 'z' attempt bumps ONLY hanzo/z
(pre-fix: admin/z also 1); correct hanzo pw auths hanzo/z, admin/z untouched.
- SuperAdminKey_CanPush: SuperAdmin still pushes via API key (privileged).
go build ./... && go vet ./... && go test ./... green.
userByPassword looped {admin, hanzo} calling users.Authenticate per org, so a
single wrong docker-login on a name present in BOTH orgs (z@hanzo.ai collides
across admin and hanzo) incremented BOTH rows — locking in ~3 requests not 5 —
and a correct hanzo/<name> login (wrong for admin/<name>) bumped admin/<name>
every use. On a PUBLIC unauthenticated endpoint this let an anonymous caller lock
the platform SuperAdmin out of every password door (login/ROPC/registry share the
one row counter) in five wrong tries, and offered a low-throttle brute-force
oracle for the super's password.
Skip the reserved candidate org in the password walk: a reserved-org
(SuperAdmin/built-in/service) principal is no longer authenticated by a guessable
WEB PASSWORD on the public registry realm — it pushes with its HIGH-ENTROPY
machine credential (API key via userByKey, or service account) which are
unchanged and are the documented CI/SuperAdmin push identity. That both (a)
removes the reserved-org password from the lockout path entirely — no unauth
account-lock DoS and no brute-force oracle on the super — and (b) collapses the
walk to the single non-reserved candidate, so a wrong attempt drives at most ONE
row's counter (login-parity, no double-speed) and a correct hanzo/<name> password
can never touch admin/<name>'s counter (no cross-org coupling).
PARITY: casdoor's registry path resolved {admin, hanzo} passwords with NO lockout
at all, so the per-account lock on this endpoint is NEW surface (added by F-D1);
narrowing the PASSWORD path to non-reserved orgs is a deliberate, tested
hardening of that new surface, aligned with ROPC which already refuses reserved-
org password grants (token.go). API-key and service-account paths are untouched.
Tests (internal/registry/registry_test.go), fail-before / pass-after:
- SuperAdminPassword_Denied: correct admin/z password -> 401 (pre-fix: 200+token).
- AdminPassword_NotDosableOnPublicRegistry: flood of 3x wrongs leaves admin/root
counter at 0 (pre-fix: 5 -> locked, DoS).
- RegistryPassword_NoCrossOrgCoupling: one wrong 'z' attempt bumps ONLY hanzo/z
(pre-fix: admin/z also 1); correct hanzo pw auths hanzo/z, admin/z untouched.
- SuperAdminKey_CanPush: SuperAdmin still pushes via API key (privileged).
go build ./... && go vet ./... && go test ./... green.
The failed-attempt counter was read at handler entry, bumped in memory, and
persisted with a full-row write. A generation of C concurrent wrong attempts
each captured the same pre-increment snapshot, so all C persisted snapshot+1 —
C parallel guesses advanced the counter by only ONE. The account never locked
and the online brute-force oracle F-D1 exists to kill re-opened (Red PoC: 16
parallel wrongs -> counter 1, correct password still accepted).
Drive the increment inside a ROW-LOCKED transaction (recordAttempt): resolve the
row's real storage key via the (owner,name) query path — correct for both the
auto-int64 users.Create shape and the migrated owner/name shape — then
orm.GetForUpdate under RunInTransaction takes an exclusive row lock, re-reads the
FRESH counter, and writes back from it. C serialized wrongs now advance by
exactly C. Mechanism mirrors the wallet challenge-burn CAS (internal/wallet):
the STORE transaction serializes, not a process-local mutex — correct under the
single-writer SQLite topology today (MaxOpenConns(1) + write mutex held for the
full tx) AND a shared SQL backend under N replicas tomorrow (SELECT FOR UPDATE).
The locked read+write also removes the collateral full-row clobber: no concurrent
unrelated-field update can land between the read and the write.
The argon2id/bcrypt verify stays OUTSIDE the transaction, so a login never holds
the write lock across a password hash.
Tests (internal/users/lockout_test.go), fail-before / pass-after:
- ConcurrentWrongPasswords_NoLostUpdates: C(<threshold) parallel wrongs advance
the counter by EXACTLY C (pre-fix: 1).
- ConcurrentFlood_Locks: C=32 parallel wrongs LOCK the account; correct password
refused (pre-fix: never locks).
- SequentialLockAndReset: single-threaded lock/reset/no-early-unlock preserved.
go build ./... && go vet ./... && go test ./... green (-race on users).
The failed-attempt counter was read at handler entry, bumped in memory, and
persisted with a full-row write. A generation of C concurrent wrong attempts
each captured the same pre-increment snapshot, so all C persisted snapshot+1 —
C parallel guesses advanced the counter by only ONE. The account never locked
and the online brute-force oracle F-D1 exists to kill re-opened (Red PoC: 16
parallel wrongs -> counter 1, correct password still accepted).
Drive the increment inside a ROW-LOCKED transaction (recordAttempt): resolve the
row's real storage key via the (owner,name) query path — correct for both the
auto-int64 users.Create shape and the migrated owner/name shape — then
orm.GetForUpdate under RunInTransaction takes an exclusive row lock, re-reads the
FRESH counter, and writes back from it. C serialized wrongs now advance by
exactly C. Mechanism mirrors the wallet challenge-burn CAS (internal/wallet):
the STORE transaction serializes, not a process-local mutex — correct under the
single-writer SQLite topology today (MaxOpenConns(1) + write mutex held for the
full tx) AND a shared SQL backend under N replicas tomorrow (SELECT FOR UPDATE).
The locked read+write also removes the collateral full-row clobber: no concurrent
unrelated-field update can land between the read and the write.
The argon2id/bcrypt verify stays OUTSIDE the transaction, so a login never holds
the write lock across a password hash.
Tests (internal/users/lockout_test.go), fail-before / pass-after:
- ConcurrentWrongPasswords_NoLostUpdates: C(<threshold) parallel wrongs advance
the counter by EXACTLY C (pre-fix: 1).
- ConcurrentFlood_Locks: C=32 parallel wrongs LOCK the account; correct password
refused (pre-fix: never locks).
- SequentialLockAndReset: single-threaded lock/reset/no-early-unlock preserved.
go build ./... && go vet ./... && go test ./... green (-race on users).
wallet.provision and serviceaccounts create construct the User directly and
SetId(owner/name), so their token sub is the natural key, not a minted UUID — a
deliberate divergence from the 'sub is always a UUID' invariant. Documented in-code as
NOT an impersonation vector (owner is server-set, name deterministic, no client Id;
store.GetUserById fails closed on empty/duplicate Id) and DEFERRED: minting a UUID
would change the wallet re-login / M2M subject shape, a migration decision, not a
security-rework edit. No behavior change.
wallet.provision and serviceaccounts create construct the User directly and
SetId(owner/name), so their token sub is the natural key, not a minted UUID — a
deliberate divergence from the 'sub is always a UUID' invariant. Documented in-code as
NOT an impersonation vector (owner is server-set, name deterministic, no client Id;
store.GetUserById fails closed on empty/duplicate Id) and DEFERRED: minting a UUID
would change the wallet re-login / M2M subject shape, a migration decision, not a
security-rework edit. No behavior change.
login.go was the one credential door that omitted the store.IsReservedOrg refuse
signup.go and the ROPC grant enforce. A shared or org-choice app (whose tenant gate
accepts a user from ANY org) would mint a real SuperAdmin authorization code — or bare
session — on the correct admin password: POST /v1/iam/login type=code via such an app
resolved admin/<super> and returned a live grant.
Gate a RESERVED-org principal to an app that itself SERVES that reserved org (the
dedicated console). Placed at loginGrant ahead of the bare-session and type=code
branches — the ONE tail the credential post and the second-factor finish share — so
every credential grant shape is bound identically. The dedicated admin-console
(Organization=="admin") still signs the SuperAdmin in; a normal tenant never triggers
it, so shared/org-choice apps keep serving them.
Device approval is deliberately EXCLUDED: it has its own tenant model (a SuperAdmin may
approve a device across tenants — device.go), a blessed capability, so the gate follows
the type=device early return (TestDevice_ApprovalTenantBoundary stays green).
Tests: shared-app and org-choice-app SuperAdmin logins refused (FAIL before — mint a
real code; PASS after); admin-console SuperAdmin login still allowed; cross-org and
shared-app normal-tenant regressions unchanged.
login.go was the one credential door that omitted the store.IsReservedOrg refuse
signup.go and the ROPC grant enforce. A shared or org-choice app (whose tenant gate
accepts a user from ANY org) would mint a real SuperAdmin authorization code — or bare
session — on the correct admin password: POST /v1/iam/login type=code via such an app
resolved admin/<super> and returned a live grant.
Gate a RESERVED-org principal to an app that itself SERVES that reserved org (the
dedicated console). Placed at loginGrant ahead of the bare-session and type=code
branches — the ONE tail the credential post and the second-factor finish share — so
every credential grant shape is bound identically. The dedicated admin-console
(Organization=="admin") still signs the SuperAdmin in; a normal tenant never triggers
it, so shared/org-choice apps keep serving them.
Device approval is deliberately EXCLUDED: it has its own tenant model (a SuperAdmin may
approve a device across tenants — device.go), a blessed capability, so the gate follows
the type=device early return (TestDevice_ApprovalTenantBoundary stays green).
Tests: shared-app and org-choice-app SuperAdmin logins refused (FAIL before — mint a
real code; PASS after); admin-console SuperAdmin login still allowed; cross-org and
shared-app normal-tenant regressions unchanged.
registry.userByPassword and featurestore.VerifyPassword (LDAP bind) called
users.VerifyPassword DIRECTLY — no lockout, no counter. The public
POST /v1/iam/registry/token endpoint let an unauthenticated attacker brute-force an
admin/hanzo (SuperAdmin) password with ZERO throttle while the login door locked, so
the F-D1 lockout was not actually a choke point — two other paths skipped it.
Decomplect: hoist the lockout-aware verify out of internal/oidc into the ONE
credential package as users.Authenticate (raw VerifyPassword stays the stateless
digest primitive; Authenticate wraps it with per-account lockout). Route login, the
ROPC grant, the registry token endpoint, and the LDAP-bind seam all through it. The
raw primitive now has exactly one production caller: Authenticate.
VerifyPassword caller audit (all human-auth paths -> choke point):
- oidc login.go -> users.Authenticate
- oidc token.go (ROPC) -> users.Authenticate
- registry userByPassword -> users.Authenticate (locked = no-match, opaque 401)
- featurestore (LDAP) -> users.Authenticate (locked = bind fails)
- users.VerifyPassword -> raw digest primitive, called only by Authenticate
Registry test: mints an admin (org 'admin') via the REAL users.Create path, hammers
5 wrong passwords on the public token endpoint, asserts the correct admin password is
then REFUSED. FAILS before (mints a real sub:admin/root token), PASSES after.
registry.userByPassword and featurestore.VerifyPassword (LDAP bind) called
users.VerifyPassword DIRECTLY — no lockout, no counter. The public
POST /v1/iam/registry/token endpoint let an unauthenticated attacker brute-force an
admin/hanzo (SuperAdmin) password with ZERO throttle while the login door locked, so
the F-D1 lockout was not actually a choke point — two other paths skipped it.
Decomplect: hoist the lockout-aware verify out of internal/oidc into the ONE
credential package as users.Authenticate (raw VerifyPassword stays the stateless
digest primitive; Authenticate wraps it with per-account lockout). Route login, the
ROPC grant, the registry token endpoint, and the LDAP-bind seam all through it. The
raw primitive now has exactly one production caller: Authenticate.
VerifyPassword caller audit (all human-auth paths -> choke point):
- oidc login.go -> users.Authenticate
- oidc token.go (ROPC) -> users.Authenticate
- registry userByPassword -> users.Authenticate (locked = no-match, opaque 401)
- featurestore (LDAP) -> users.Authenticate (locked = bind fails)
- users.VerifyPassword -> raw digest primitive, called only by Authenticate
Registry test: mints an admin (org 'admin') via the REAL users.Create path, hammers
5 wrong passwords on the public token endpoint, asserts the correct admin password is
then REFUSED. FAILS before (mints a real sub:admin/root token), PASSES after.
saveLoginCounters saved via orm.Get(db, owner+"/"+name), but schema.User is
registered without WithStringKey, so a users.Create'd account (signup / SCIM /
federation / CRUD) is keyed by an auto-allocated int64 — not owner/name. The save
therefore matched only migrated casdoor rows; every post-cutover account's
SigninWrongTimes never persisted, read back 0 each request, and NEVER locked — an
unauthenticated online brute-force oracle on the public ROPC endpoint.
Re-read the row through the ONE (owner,name) query path (First → SetKey stamps the
real storage key, int64 or owner/name) and write the counters back by THAT key, so
the persist targets the exact row the verify loaded, for both account shapes. The
re-read keeps the write counter-only (fresh mirrors stored state).
Test drives the REAL signup create path (no SetId) then 5 wrong + 1 correct: FAILS
before (6th accepted, status 200), PASSES after (locked). SetId-seeded migrated-shape
tests unchanged.
saveLoginCounters saved via orm.Get(db, owner+"/"+name), but schema.User is
registered without WithStringKey, so a users.Create'd account (signup / SCIM /
federation / CRUD) is keyed by an auto-allocated int64 — not owner/name. The save
therefore matched only migrated casdoor rows; every post-cutover account's
SigninWrongTimes never persisted, read back 0 each request, and NEVER locked — an
unauthenticated online brute-force oracle on the public ROPC endpoint.
Re-read the row through the ONE (owner,name) query path (First → SetKey stamps the
real storage key, int64 or owner/name) and write the counters back by THAT key, so
the persist targets the exact row the verify loaded, for both account shapes. The
re-read keeps the write counter-only (fresh mirrors stored state).
Test drives the REAL signup create path (no SetId) then 5 wrong + 1 correct: FAILS
before (6th accepted, status 200), PASSES after (locked). SetId-seeded migrated-shape
tests unchanged.
Red F-D1 [HIGH]: SigninWrongTimes/LastSigninWrongTime exist on schema.User but were
never enforced. Casdoor locked an account after a run of wrong passwords; commit D
adopted casdoor's PUBLIC-ROPC endpoint while dropping that compensating control,
making it an unauthenticated online brute-force oracle.
Fix: verifyLoginPassword is now the ONE credential-verify choke point the login form
AND the ROPC password grant share. It enforces casdoor-parity lockout on the user
row: a wrong password increments the count (restarting when the window lapsed) and
stamps the time; at signinWrongLimit (5) within lockoutWindow (15m) the account is
locked and even the correct password is refused, with a DISTINCT message that never
reveals correctness; a correct, unlocked password resets the count. A nil user
(unknown login) shares the opaque bad-password path — no first-attempt enumeration.
Counter writes are best-effort (a persist fault never turns a correct login into an
error). login.go and token.go now route through it (their direct users.VerifyPassword
calls, and imports, removed — one verify path, no drift).
Tests: 5 wrong attempts lock the account (correct password then refused with the
distinct lock message); two rounds of (limit-1 wrong, then success) both succeed,
proving a correct password resets the counter.
Red F-D1 [HIGH]: SigninWrongTimes/LastSigninWrongTime exist on schema.User but were
never enforced. Casdoor locked an account after a run of wrong passwords; commit D
adopted casdoor's PUBLIC-ROPC endpoint while dropping that compensating control,
making it an unauthenticated online brute-force oracle.
Fix: verifyLoginPassword is now the ONE credential-verify choke point the login form
AND the ROPC password grant share. It enforces casdoor-parity lockout on the user
row: a wrong password increments the count (restarting when the window lapsed) and
stamps the time; at signinWrongLimit (5) within lockoutWindow (15m) the account is
locked and even the correct password is refused, with a DISTINCT message that never
reveals correctness; a correct, unlocked password resets the count. A nil user
(unknown login) shares the opaque bad-password path — no first-attempt enumeration.
Counter writes are best-effort (a persist fault never turns a correct login into an
error). login.go and token.go now route through it (their direct users.VerifyPassword
calls, and imports, removed — one verify path, no drift).
Tests: 5 wrong attempts lock the account (correct password then refused with the
distinct lock message); two rounds of (limit-1 wrong, then success) both succeed,
proving a correct password resets the counter.
Red F-D2 [HIGH]: passwordGrant read the login org from the `organization` request
param with no check the client may serve it — the one mint path missing the gate
mint.go/login.go/signup.go/federation.go all enforce. A public zoo-console posting
organization=admin&username=z resolved admin/z (a SuperAdmin) and minted a real
SuperAdmin token on the correct password.
Fix: before the user lookup, refuse when the target org is reserved
(store.IsReservedOrg — admin/built-in/app) OR is not one this client may serve
(org != app.Organization && !app.IsShared && app.OrgChoiceMode == ""). Opaque
invalid_grant (same shape as a bad credential), so it is no org/user oracle. This
mirrors the established signup_reserved contract; the SuperAdmin authenticates via
a minted/code-flow bearer, never public ROPC into the admin org.
Also repoints the two token-exchange reserved-org tests: their setup minted the
admin/root subject_token via ROPC into org=admin — exactly what F-D2 now forbids —
so they obtain it via a new directSubjectToken helper that cert-signs the token
(verifyToken checks only trusted-kid+signature+time). The exchange's OWN reserved-org
gate remains the thing under test.
Tests: a public console with organization=admin (correct SuperAdmin password) is
refused and mints nothing; a foreign tenant org is refused; the normal same-org
password grant still succeeds; token-exchange reserved-org gate still enforced.
Red F-D2 [HIGH]: passwordGrant read the login org from the `organization` request
param with no check the client may serve it — the one mint path missing the gate
mint.go/login.go/signup.go/federation.go all enforce. A public zoo-console posting
organization=admin&username=z resolved admin/z (a SuperAdmin) and minted a real
SuperAdmin token on the correct password.
Fix: before the user lookup, refuse when the target org is reserved
(store.IsReservedOrg — admin/built-in/app) OR is not one this client may serve
(org != app.Organization && !app.IsShared && app.OrgChoiceMode == ""). Opaque
invalid_grant (same shape as a bad credential), so it is no org/user oracle. This
mirrors the established signup_reserved contract; the SuperAdmin authenticates via
a minted/code-flow bearer, never public ROPC into the admin org.
Also repoints the two token-exchange reserved-org tests: their setup minted the
admin/root subject_token via ROPC into org=admin — exactly what F-D2 now forbids —
so they obtain it via a new directSubjectToken helper that cert-signs the token
(verifyToken checks only trusted-kid+signature+time). The exchange's OWN reserved-org
gate remains the thing under test.
Tests: a public console with organization=admin (correct SuperAdmin password) is
refused and mints nothing; a foreign tenant org is refused; the normal same-org
password grant still succeeds; token-exchange reserved-org gate still enforced.
Red F-A1 [CRITICAL]: User.Id IS the OIDC sub and the authz principal key, but it
was client-settable on create and client-mutable on update and non-unique in
storage. An org-admin of any tenant could POST a user whose id = a victim's UUID
(a public identifier); two rows then shared the sub and GetUserById.First()
returned the arbitrary (attacker-favorable) row → tenant-admin → SuperAdmin
impersonation. Closed at every layer:
- users.Create: ALWAYS mint the Id server-side (uuid.NewString); a client-supplied
Id is discarded. Plus a write-path uniqueness guard (store.GetUserById) — the
JSON store has no per-field DB UNIQUE index (confirmed: orm ModelMeta/parseStructTags
handle only default+serialize), so uniqueness is enforced at the write exactly as
clientId is, NOT by a decorative `unique` tag that the engine would ignore.
- users.Update: Id is immutable — carried from the stored row like CreatedTime; a
body-supplied Id is ignored (this ALSO fixes F-A2: a benign edit omitting id no
longer wipes the subject to "").
- store.GetUserById: FAIL CLOSED on >1 match (GetAll + count) instead of First() —
any duplicate that somehow exists refuses resolution rather than returning a
steerable row (F-L1).
- oidc.subjectOf: nil-guard (F-I1).
- signup usernamePolicyError: forbid '/' so a self-registered name can't inject a
spurious owner/name separator into the subject discriminator (F-I2).
The migrator writes schema.User via the generic engine (not users.Create), so it
still carries casdoor UUIDs verbatim — confirmed unaffected (migrate tests green).
Tests: client-supplied Id ignored on create; attacker cannot adopt a victim's Id
(collision denied); Update preserves Id (immutable) and a no-id edit keeps it;
username with '/' refused. Full suite green.
Red F-A1 [CRITICAL]: User.Id IS the OIDC sub and the authz principal key, but it
was client-settable on create and client-mutable on update and non-unique in
storage. An org-admin of any tenant could POST a user whose id = a victim's UUID
(a public identifier); two rows then shared the sub and GetUserById.First()
returned the arbitrary (attacker-favorable) row → tenant-admin → SuperAdmin
impersonation. Closed at every layer:
- users.Create: ALWAYS mint the Id server-side (uuid.NewString); a client-supplied
Id is discarded. Plus a write-path uniqueness guard (store.GetUserById) — the
JSON store has no per-field DB UNIQUE index (confirmed: orm ModelMeta/parseStructTags
handle only default+serialize), so uniqueness is enforced at the write exactly as
clientId is, NOT by a decorative `unique` tag that the engine would ignore.
- users.Update: Id is immutable — carried from the stored row like CreatedTime; a
body-supplied Id is ignored (this ALSO fixes F-A2: a benign edit omitting id no
longer wipes the subject to "").
- store.GetUserById: FAIL CLOSED on >1 match (GetAll + count) instead of First() —
any duplicate that somehow exists refuses resolution rather than returning a
steerable row (F-L1).
- oidc.subjectOf: nil-guard (F-I1).
- signup usernamePolicyError: forbid '/' so a self-registered name can't inject a
spurious owner/name separator into the subject discriminator (F-I2).
The migrator writes schema.User via the generic engine (not users.Create), so it
still carries casdoor UUIDs verbatim — confirmed unaffected (migrate tests green).
Tests: client-supplied Id ignored on create; attacker cannot adopt a victim's Id
(collision denied); Update preserves Id (immutable) and a no-id edit keeps it;
username with '/' refused. Full suite green.
INTENTIONAL security-posture decision for the cutover, flagged for Red review.
The clean-room forbade public ROPC (confidential clients only). Casdoor ALLOWS a
public client (console/chat: no client_secret, no PKCE) to complete the password
grant, so those logins would 401 `invalid_client` at cutover. Owner directive:
default to parity — do not change behavior mid-migration.
Exact, bounded relaxation in passwordGrant:
- A PUBLIC client (no registered ClientSecret) MAY now complete the password
grant with NO client_secret and NO PKCE. This is the ONLY thing newly allowed.
- A CONFIDENTIAL client (registered secret) is UNCHANGED: it must still present
that secret, verified constant-time; a wrong secret is still 401.
Untouched: publicTokenEndpointForbidden still bars internal (<org>-iam) and
reserved-org (admin/built-in/app) apps; IsPasswordEnabled still gates; the password
is verified through the same per-row argon2id/bcrypt path; unknown-user and
bad-password share one opaque invalid_grant; forbidden/deleted users are denied.
Tests: public client (no secret/PKCE) SUCCEEDS for a console app; public client with
a WRONG password is denied; a forbidden user is denied even to a public client; a
confidential client with a wrong secret is still 401; reserved-org apps still refused.
INTENTIONAL security-posture decision for the cutover, flagged for Red review.
The clean-room forbade public ROPC (confidential clients only). Casdoor ALLOWS a
public client (console/chat: no client_secret, no PKCE) to complete the password
grant, so those logins would 401 `invalid_client` at cutover. Owner directive:
default to parity — do not change behavior mid-migration.
Exact, bounded relaxation in passwordGrant:
- A PUBLIC client (no registered ClientSecret) MAY now complete the password
grant with NO client_secret and NO PKCE. This is the ONLY thing newly allowed.
- A CONFIDENTIAL client (registered secret) is UNCHANGED: it must still present
that secret, verified constant-time; a wrong secret is still 401.
Untouched: publicTokenEndpointForbidden still bars internal (<org>-iam) and
reserved-org (admin/built-in/app) apps; IsPasswordEnabled still gates; the password
is verified through the same per-row argon2id/bcrypt path; unknown-user and
bad-password share one opaque invalid_grant; forbidden/deleted users are denied.
Tests: public client (no secret/PKCE) SUCCEEDS for a console app; public client with
a WRONG password is denied; a forbidden user is denied even to a public client; a
confidential client with a wrong secret is still 401; reserved-org apps still refused.
The migrator carried no memberships, so a multi-org user's tenancy collapsed to
the home org alone (z: [hanzo,lux,zoo,pars] → [hanzo]). Casdoor's `membership`
table has the identical (owner,name,user,org,role) shape as schema.Membership, so
add it as an entitySpec driven by the generic engine — carried verbatim, keyed by
the (owner,name) natural key, idempotent on re-run. Ordered after users and
organizations, which it references. Included in the dry-run counts and the --only
selector set.
Test: 4 casdoor membership rows migrate to 4 clean rows (0 skipped); a user with
memberships in [hanzo,lux,zoo,pars] reproduces all 4 via store.MemberOrgRefs
(home ∪ explicit); a re-run is a pure no-op.
The migrator carried no memberships, so a multi-org user's tenancy collapsed to
the home org alone (z: [hanzo,lux,zoo,pars] → [hanzo]). Casdoor's `membership`
table has the identical (owner,name,user,org,role) shape as schema.Membership, so
add it as an entitySpec driven by the generic engine — carried verbatim, keyed by
the (owner,name) natural key, idempotent on re-run. Ordered after users and
organizations, which it references. Included in the dry-run counts and the --only
selector set.
Test: 4 casdoor membership rows migrate to 4 clean rows (0 skipped); a user with
memberships in [hanzo,lux,zoo,pars] reproduces all 4 via store.MemberOrgRefs
(home ∪ explicit); a re-run is a pure no-op.
resolveLoginUser resolved the identifier by EMAIL first; casdoor resolves by NAME
first (object.GetUserByFields). When two rows collide on an email — hanzo/z (name
z) and hanzo/z@hanzo.ai (name z@hanzo.ai) — the ROPC/login username "z@hanzo.ai"
must land on the NAME match, exactly as casdoor did. Email-first silently
authenticated the OTHER identity at cutover. No rows are deduped; the fix is
deterministic name-first resolution. Email fallback is unchanged for a username
that matches no name.
Test: username "z@hanzo.ai" resolves to the NAME match hanzo/z@hanzo.ai (not the
email match hanzo/z); a plain username still resolves by name; an email with no
name match still falls back to the email lookup.
resolveLoginUser resolved the identifier by EMAIL first; casdoor resolves by NAME
first (object.GetUserByFields). When two rows collide on an email — hanzo/z (name
z) and hanzo/z@hanzo.ai (name z@hanzo.ai) — the ROPC/login username "z@hanzo.ai"
must land on the NAME match, exactly as casdoor did. Email-first silently
authenticated the OTHER identity at cutover. No rows are deduped; the fix is
deterministic name-first resolution. Email fallback is unchanged for a username
that matches no name.
Test: username "z@hanzo.ai" resolves to the NAME match hanzo/z@hanzo.ai (not the
email match hanzo/z); a plain username still resolves by name; an email with no
name match still falls back to the email lookup.
The clean-room emitted `sub` = owner/name; casdoor emits the user's UUID. Every
user's sub would change at cutover, breaking sessions, external refs, and the
money-path principal keyed on `sub`. Carry casdoor's per-row UUID and mint it as
the stable `sub` going forward.
- schema.User: add `Id string json:"id" orm:"index"` — the casdoor UUID; its json
tag dominates the embedded orm storage id, so the persisted "id" is the UUID
while the primary key stays (owner,name).
- store: GetUserById + GetUserBySubject — the ONE subject decoder (no "/" ⇒ Id,
else natural key), matching how subjectOf mints a sub.
- users.Create: assign a fresh UUID when none supplied, so a native v2 user's sub
is opaque from birth.
- oidc: subjectOf() + userClaims() resolve the sub once; issueTokens/signAccessToken,
token-exchange, issue-user-token mint it; userinfo/get-account/whoami report the
SAME sub; introspection already echoes it. Token rows keep the (owner/name) key.
- authz.principal: resolve the money-path principal via GetUserBySubject (Id-first),
read Org/Admin/Super from the loaded row, fail closed on an orphan UUID.
- migrate-v1: the casdoor `id` column now maps to User.Id automatically; the UUID
is a domain field, never the storage key.
Tests: migrated user mints UUID sub (access+id+userinfo agree); empty-Id user falls
back to owner/name; native Create generates a UUID; store decoder resolves both
shapes and preserves the (owner,name) PK; migrate carries every row's UUID.
The clean-room emitted `sub` = owner/name; casdoor emits the user's UUID. Every
user's sub would change at cutover, breaking sessions, external refs, and the
money-path principal keyed on `sub`. Carry casdoor's per-row UUID and mint it as
the stable `sub` going forward.
- schema.User: add `Id string json:"id" orm:"index"` — the casdoor UUID; its json
tag dominates the embedded orm storage id, so the persisted "id" is the UUID
while the primary key stays (owner,name).
- store: GetUserById + GetUserBySubject — the ONE subject decoder (no "/" ⇒ Id,
else natural key), matching how subjectOf mints a sub.
- users.Create: assign a fresh UUID when none supplied, so a native v2 user's sub
is opaque from birth.
- oidc: subjectOf() + userClaims() resolve the sub once; issueTokens/signAccessToken,
token-exchange, issue-user-token mint it; userinfo/get-account/whoami report the
SAME sub; introspection already echoes it. Token rows keep the (owner/name) key.
- authz.principal: resolve the money-path principal via GetUserBySubject (Id-first),
read Org/Admin/Super from the loaded row, fail closed on an orphan UUID.
- migrate-v1: the casdoor `id` column now maps to User.Id automatically; the UUID
is a domain field, never the storage key.
Tests: migrated user mints UUID sub (access+id+userinfo agree); empty-Id user falls
back to owner/name; native Create generates a UUID; store decoder resolves both
shapes and preserves the (owner,name) PK; migrate carries every row's UUID.
Red F-R1 CRITICAL: the third cross-tenant PUSH path. serviceAccount
resolved store.GetApplicationByClientId GLOBALLY and returned
privileged:true on any clientId:clientSecret match, with NO candidateOrgs
bound — the boundary the prior fix added to the key/password paths but not
this sibling. Attack (Red-proven): self-onboard org "evil", POST an app
{Owner:"evil", clientId, clientSecret} (own-org admin write), docker login
with it -> privileged push to any repo -> supply-chain poisoning.
FIX (decomplected to ONE gate, per CTO): bind ALL privileged-yielding paths
in a single authoritative place so a future credential path can't skip it.
- principal now carries `owner` (the user's org, or the app's Owner).
- authenticate() = resolve() + ONE candidateOrgs gate over the resolved
principal's owner. resolve() finds WHO the credential is (any org); the
gate binds to {admin,hanzo} once. Dropped the now-redundant per-path
inCandidateOrg check in userByKey (the tail gate subsumes it).
- serviceAccount sets principal.owner = app.Owner. A tenant-org app is
denied at the gate; a real CI/service account (admin/hanzo-owned) passes.
- userPrivileged unchanged (the v1-parity push decision).
TESTS (26 green):
- TestToken_ForeignTenantApp_Denied — app Owner="evil" with a MATCHING
secret (so the 401 is the org gate, not a bad secret), GET + POST, pull
+ push scopes -> 401, no token.
- TestToken_HanzoKey_PullToken — positive control: a hanzo-org pk-/sk- Key
resolves + gets a pull token (the gate admits in-platform keys).
- existing TestToken_ServiceAccount_PullPush (admin-owned app) still pushes
— CI unaffected.
go build/vet/test ./... all green (23 pkgs ok, 0 FAIL).
Red F-R1 CRITICAL: the third cross-tenant PUSH path. serviceAccount
resolved store.GetApplicationByClientId GLOBALLY and returned
privileged:true on any clientId:clientSecret match, with NO candidateOrgs
bound — the boundary the prior fix added to the key/password paths but not
this sibling. Attack (Red-proven): self-onboard org "evil", POST an app
{Owner:"evil", clientId, clientSecret} (own-org admin write), docker login
with it -> privileged push to any repo -> supply-chain poisoning.
FIX (decomplected to ONE gate, per CTO): bind ALL privileged-yielding paths
in a single authoritative place so a future credential path can't skip it.
- principal now carries `owner` (the user's org, or the app's Owner).
- authenticate() = resolve() + ONE candidateOrgs gate over the resolved
principal's owner. resolve() finds WHO the credential is (any org); the
gate binds to {admin,hanzo} once. Dropped the now-redundant per-path
inCandidateOrg check in userByKey (the tail gate subsumes it).
- serviceAccount sets principal.owner = app.Owner. A tenant-org app is
denied at the gate; a real CI/service account (admin/hanzo-owned) passes.
- userPrivileged unchanged (the v1-parity push decision).
TESTS (26 green):
- TestToken_ForeignTenantApp_Denied — app Owner="evil" with a MATCHING
secret (so the 401 is the org gate, not a bad secret), GET + POST, pull
+ push scopes -> 401, no token.
- TestToken_HanzoKey_PullToken — positive control: a hanzo-org pk-/sk- Key
resolves + gets a pull token (the gate admits in-platform keys).
- existing TestToken_ServiceAccount_PullPush (admin-owned app) still pushes
— CI unaffected.
go build/vet/test ./... all green (23 pkgs ok, 0 FAIL).
Red CRITICAL + MEDIUM on the Gap D registry port. Two coupled fixes in the
two registry files (the lazy-resolver mechanism of the MEDIUM fix changes the
same mount/handler signatures the CRITICAL fix's file touches, so they ship
green together rather than as a non-compiling split).
CRITICAL — cross-tenant image poisoning (supply-chain). The added API-key
path resolved store.UserByAccessKey to the key's OWNER in ANY tenant org,
bypassing the v1 {admin,hanzo} boundary the password path enforces; combined
with privileged = u.IsAdmin (onboard sets IsAdmin on every org creator), a
self-onboarded tenant admin could docker push to shared repos.
- FIX 1 (restore v1 boundary): the key path now BINDS the resolved user to
candidateOrgs {admin,hanzo} (inCandidateOrg). A foreign-tenant key
resolves to nil -> 401, no token. v1 only ever authenticated those two
orgs; parity restored, hole closed at the root.
- FIX 2 (defense-in-depth): userPrivileged gates push to service-account OR
(IsSigningCertOwner(owner) && (IsAdmin || IsSuperAdmin)). IsAdmin alone is
not a push signal. Intersected with the auth bound, the only human push
identity is the admin org (SuperAdmins); CI pushes via the service
account; a hanzo-org admin authenticates but is pull-only.
MEDIUM — ephemeral signing key could ship in prod (fail-open). Inverted the
default to FAIL-CLOSED: resolveKeyring errors when no key is configured unless
the explicit dev opt-in REGISTRY_ALLOW_EPHEMERAL=true is set (retired
REGISTRY_REQUIRE_PERSISTENT_SIGNING_KEY). Resolution is now LAZY (per request,
memoized) so the fail-closed default never panics the 9 packages that mount
the full routes.Route; a missing key answers 503 (no untrusted token minted),
never an ephemeral key the registry ROOTCERTBUNDLE rejects. A configured-but-
broken key is an error in every environment, even with the opt-in.
Env: prod sets REGISTRY_SIGNING_KEY or _FILE (the current KMS key); dev may
set REGISTRY_ALLOW_EPHEMERAL=true.
Tests (21 green): TestToken_ForeignTenantKey_Denied (pk-/sk- foreign key in
username AND password, pull AND push scopes -> 401/no token),
TestToken_HanzoOrgAdmin_PullOnly (hanzo IsAdmin authenticates, pull-only),
TestToken_SuperAdmin_PullPush (admin org pushes), TestKeyring_FailsClosed_
NoEphemeralByDefault, _EphemeralRequiresOptIn, _LoadsConfiguredKey (golden
kid), _BrokenKeyIsError; existing token/kid/JWKS suite unchanged.
Red CRITICAL + MEDIUM on the Gap D registry port. Two coupled fixes in the
two registry files (the lazy-resolver mechanism of the MEDIUM fix changes the
same mount/handler signatures the CRITICAL fix's file touches, so they ship
green together rather than as a non-compiling split).
CRITICAL — cross-tenant image poisoning (supply-chain). The added API-key
path resolved store.UserByAccessKey to the key's OWNER in ANY tenant org,
bypassing the v1 {admin,hanzo} boundary the password path enforces; combined
with privileged = u.IsAdmin (onboard sets IsAdmin on every org creator), a
self-onboarded tenant admin could docker push to shared repos.
- FIX 1 (restore v1 boundary): the key path now BINDS the resolved user to
candidateOrgs {admin,hanzo} (inCandidateOrg). A foreign-tenant key
resolves to nil -> 401, no token. v1 only ever authenticated those two
orgs; parity restored, hole closed at the root.
- FIX 2 (defense-in-depth): userPrivileged gates push to service-account OR
(IsSigningCertOwner(owner) && (IsAdmin || IsSuperAdmin)). IsAdmin alone is
not a push signal. Intersected with the auth bound, the only human push
identity is the admin org (SuperAdmins); CI pushes via the service
account; a hanzo-org admin authenticates but is pull-only.
MEDIUM — ephemeral signing key could ship in prod (fail-open). Inverted the
default to FAIL-CLOSED: resolveKeyring errors when no key is configured unless
the explicit dev opt-in REGISTRY_ALLOW_EPHEMERAL=true is set (retired
REGISTRY_REQUIRE_PERSISTENT_SIGNING_KEY). Resolution is now LAZY (per request,
memoized) so the fail-closed default never panics the 9 packages that mount
the full routes.Route; a missing key answers 503 (no untrusted token minted),
never an ephemeral key the registry ROOTCERTBUNDLE rejects. A configured-but-
broken key is an error in every environment, even with the opt-in.
Env: prod sets REGISTRY_SIGNING_KEY or _FILE (the current KMS key); dev may
set REGISTRY_ALLOW_EPHEMERAL=true.
Tests (21 green): TestToken_ForeignTenantKey_Denied (pk-/sk- foreign key in
username AND password, pull AND push scopes -> 401/no token),
TestToken_HanzoOrgAdmin_PullOnly (hanzo IsAdmin authenticates, pull-only),
TestToken_SuperAdmin_PullPush (admin org pushes), TestKeyring_FailsClosed_
NoEphemeralByDefault, _EphemeralRequiresOptIn, _LoadsConfiguredKey (golden
kid), _BrokenKeyIsError; existing token/kid/JWKS suite unchanged.
Port the OCI registry token auth into the clean-room IAM so the identity
cutover does not break CI image push / cluster image pull. registry:2 at
registry.hanzo.ai points REGISTRY_AUTH_TOKEN_REALM at
/v1/iam/registry/token and trusts issued tokens via /v1/iam/registry/jwks
(its ROOTCERTBUNDLE); the clean-room lacked both.
New package internal/registry, mounted PUBLIC in routes.Route:
GET;POST /v1/iam/registry/token — Docker Registry v2 token auth
GET /v1/iam/registry/jwks — the verifying key (ROOTCERTBUNDLE set)
Wire fidelity (external verifier, byte-exact): jwt.MapClaims with iss
fixed "hanzo-iam", aud a bare STRING, exp/nbf/iat integer seconds, the
access[] array; RS256 with the libtrust kid (uppercase base32 of the first
240 bits of SHA-256 over the DER SPKI, colon-grouped quads) computed
identically to the beego source and pinned by a golden-vector test.
Auth (all fail-closed): user password via the SAME cred (argon2id) path
login uses; confidential app clientId:clientSecret (constant-time) as the
CI/service account; and the hk-/pk-/sk- API key via the ONE resolver
store.UserByAccessKey (no second key path). Authz mirrors the beego
source: privileged (service account / admin / SuperAdmin) gets every
requested action, any other authenticated principal is pull-only; a scope
with no authorized action is omitted — never a silent grant.
Signing key: ONE RSA key per process, loaded from REGISTRY_SIGNING_KEY /
REGISTRY_SIGNING_KEY_FILE (KMS -> KMSSecret -> Secret -> env, the one
clean-room secret path). Injecting the CURRENT key material keeps the
registry's existing ROOTCERTBUNDLE valid at cutover (same key -> same kid
-> no repoint). REGISTRY_REQUIRE_PERSISTENT_SIGNING_KEY=true makes a
missing key a hard boot failure; dev/test falls back to an ephemeral key.
16 tests green: service-account/admin/SuperAdmin pull+push, user pull-only,
push-only denied (empty access), hk- key via password and username,
bad/empty/unknown creds 401, OAuth2 POST flow, multi-scope, bare login,
JWKS round-trip verification, kid golden vector + determinism.
Port the OCI registry token auth into the clean-room IAM so the identity
cutover does not break CI image push / cluster image pull. registry:2 at
registry.hanzo.ai points REGISTRY_AUTH_TOKEN_REALM at
/v1/iam/registry/token and trusts issued tokens via /v1/iam/registry/jwks
(its ROOTCERTBUNDLE); the clean-room lacked both.
New package internal/registry, mounted PUBLIC in routes.Route:
GET;POST /v1/iam/registry/token — Docker Registry v2 token auth
GET /v1/iam/registry/jwks — the verifying key (ROOTCERTBUNDLE set)
Wire fidelity (external verifier, byte-exact): jwt.MapClaims with iss
fixed "hanzo-iam", aud a bare STRING, exp/nbf/iat integer seconds, the
access[] array; RS256 with the libtrust kid (uppercase base32 of the first
240 bits of SHA-256 over the DER SPKI, colon-grouped quads) computed
identically to the beego source and pinned by a golden-vector test.
Auth (all fail-closed): user password via the SAME cred (argon2id) path
login uses; confidential app clientId:clientSecret (constant-time) as the
CI/service account; and the hk-/pk-/sk- API key via the ONE resolver
store.UserByAccessKey (no second key path). Authz mirrors the beego
source: privileged (service account / admin / SuperAdmin) gets every
requested action, any other authenticated principal is pull-only; a scope
with no authorized action is omitted — never a silent grant.
Signing key: ONE RSA key per process, loaded from REGISTRY_SIGNING_KEY /
REGISTRY_SIGNING_KEY_FILE (KMS -> KMSSecret -> Secret -> env, the one
clean-room secret path). Injecting the CURRENT key material keeps the
registry's existing ROOTCERTBUNDLE valid at cutover (same key -> same kid
-> no repoint). REGISTRY_REQUIRE_PERSISTENT_SIGNING_KEY=true makes a
missing key a hard boot failure; dev/test falls back to an ephemeral key.
16 tests green: service-account/admin/SuperAdmin pull+push, user pull-only,
push-only denied (empty access), hk- key via password and username,
bad/empty/unknown creds 401, OAuth2 POST flow, multi-scope, bare login,
JWKS round-trip verification, kid golden vector + determinism.
F3 (LOW): CapKeyResolve matches app NAME (via Allowed → p.App), like all
four sibling authz.Caps, while the issuetoken mint verbs (appInList) match
clientId. Keying CapKeyResolve on clientId would (1) make it the sole
clientId-based Cap, inconsistent with its own family, and (2) require adding
ClientId to the Principal shape. The owner-pin already defeats the
name-collision vector, and name==clientId under <org>-<app>, so the two are
equivalent in practice. Documented in cap.go; gate NOT weakened.
F3 (LOW): CapKeyResolve matches app NAME (via Allowed → p.App), like all
four sibling authz.Caps, while the issuetoken mint verbs (appInList) match
clientId. Keying CapKeyResolve on clientId would (1) make it the sole
clientId-based Cap, inconsistent with its own family, and (2) require adding
ClientId to the Principal shape. The owner-pin already defeats the
name-collision vector, and name==clientId under <org>-<app>, so the two are
equivalent in practice. Documented in cap.go; gate NOT weakened.
memberships ensure/remove gated on authz.Can(POST, organizations, 'admin',
in.Org). Because the membership row is always owned by the reserved 'admin'
org, that check takes the reserved-org branch of authorize() and, for a
CapOrgAdmin app, returns Allowed(CapOrgAdmin) with NO binding to in.Org — so
a brand console could create Membership{User:anyone, Org:'admin'}, which
flows into the target's orgs claim and the edge honors X-Org-Id in orgs =
SuperAdmin-org tenancy.
FIX: a shared mayGrant() gate for ensure AND remove refuses
store.IsReservedOrg(in.Org) unless authz.IsSuper(ctx). A CapOrgAdmin client
keeps its power over customer orgs; only a real SuperAdmin may target a
reserved org.
Test: TestEnsureMembership_reservedOrgRequiresSuper — CapOrgAdmin app denied
into admin+built-in (ensure and revoke), still allowed into a normal org,
and a SuperAdmin allowed into admin (escape hatch).
memberships ensure/remove gated on authz.Can(POST, organizations, 'admin',
in.Org). Because the membership row is always owned by the reserved 'admin'
org, that check takes the reserved-org branch of authorize() and, for a
CapOrgAdmin app, returns Allowed(CapOrgAdmin) with NO binding to in.Org — so
a brand console could create Membership{User:anyone, Org:'admin'}, which
flows into the target's orgs claim and the edge honors X-Org-Id in orgs =
SuperAdmin-org tenancy.
FIX: a shared mayGrant() gate for ensure AND remove refuses
store.IsReservedOrg(in.Org) unless authz.IsSuper(ctx). A CapOrgAdmin client
keeps its power over customer orgs; only a real SuperAdmin may target a
reserved org.
Test: TestEnsureMembership_reservedOrgRequiresSuper — CapOrgAdmin app denied
into admin+built-in (ensure and revoke), still allowed into a normal org,
and a SuperAdmin allowed into admin (escape hatch).
store.userOwningKey trusted Key.User's owner verbatim while Key.User,
AccessKey and AccessSecret are ALL attacker-controlled on write and keys
CRUD authorizes only (Key.Owner, Key.Name) — never the User field. An org
admin could plant Key{owner:attackerOrg, user:'admin/z', accessSecret:
'sk-live-KNOWN'} in its OWN org, then present the known secret so cloud's
get-user?accessKey resolved it to {owner:admin,name:z,isAdmin:true} = a
platform SuperAdmin (or any victim tenant's user).
Two-layer canonical fix:
- store.userOwningKey (authoritative, propagates to the registry branch that
reuses UserByAccessKey): a pk-/sk- key may resolve ONLY to a user in the
KEY ROW's own tenant — reject any resolved owner != k.Owner (ErrNotFound).
A non-super can never own a Key under a reserved org (authorize gates keys
writes), so no pk-/sk- key can ever reach a SuperAdmin identity.
- keys.create + keys.update (defense in depth): reject a '/'-qualified
Key.User whose owner != Key.Owner, so no forged row is ever persisted.
Tests: store TestUserByAccessKey_RejectsCrossTenantUserRef (forged super +
victim keys → ErrNotFound though the users exist); keys
TestKeys_RejectCrossTenantUserOnWrite (create/update reject cross-tenant,
accept same-owner/bare); compat TestGetUserByAccessKey_CrossTenantForgeryDenied
(forged Key seeded directly → get-user?accessKey yields no identity).
store.userOwningKey trusted Key.User's owner verbatim while Key.User,
AccessKey and AccessSecret are ALL attacker-controlled on write and keys
CRUD authorizes only (Key.Owner, Key.Name) — never the User field. An org
admin could plant Key{owner:attackerOrg, user:'admin/z', accessSecret:
'sk-live-KNOWN'} in its OWN org, then present the known secret so cloud's
get-user?accessKey resolved it to {owner:admin,name:z,isAdmin:true} = a
platform SuperAdmin (or any victim tenant's user).
Two-layer canonical fix:
- store.userOwningKey (authoritative, propagates to the registry branch that
reuses UserByAccessKey): a pk-/sk- key may resolve ONLY to a user in the
KEY ROW's own tenant — reject any resolved owner != k.Owner (ErrNotFound).
A non-super can never own a Key under a reserved org (authorize gates keys
writes), so no pk-/sk- key can ever reach a SuperAdmin identity.
- keys.create + keys.update (defense in depth): reject a '/'-qualified
Key.User whose owner != Key.Owner, so no forged row is ever persisted.
Tests: store TestUserByAccessKey_RejectsCrossTenantUserRef (forged super +
victim keys → ErrNotFound though the users exist); keys
TestKeys_RejectCrossTenantUserOnWrite (create/update reject cross-tenant,
accept same-owner/bare); compat TestGetUserByAccessKey_CrossTenantForgeryDenied
(forged Key seeded directly → get-user?accessKey yields no identity).
The Signer.Sign signature gained an orgs param in GAP C; the wallet test
harness mints a token through it. Chained onto NewRSASigner(...).Sign so
the initial call-site sweep missed it. No production code — a machine-path
harness token carries no membership set, so nil is correct.
The Signer.Sign signature gained an orgs param in GAP C; the wallet test
harness mints a token through it. Chained onto NewRSASigner(...).Sign so
the initial call-site sweep missed it. No production code — a machine-path
harness token carries no membership set, so nil is correct.
cloud's identity boundary resolves API keys via GET /v1/iam/get-user?
accessKey=<hk-/pk-/sk-> expecting {owner,name,email,isAdmin}. The
clean-room get-user resolved only owner/name/id, so EVERY hk-/pk-/sk- key
failed closed to anonymous at cutover. Resolve the key to its principal —
gated as a trusted service capability.
- store.UserByAccessKey: the ONE key->principal resolver. hk- => the User
row's AccessKey (user-owned or service-account key); pk- => schema.Key
AccessKey -> owning user; sk- => schema.Key AccessSecret -> owning user.
Fail-closed: empty/unknown/wrong-shape/user-less => orm.ErrNotFound,
never a wrong user.
- CapKeyResolve (env IAM_KEY_RESOLVE_APPS, fail-secure deny-all) — a
credential-disclosure boundary held by the cloud service app only.
- get-user is handler-authorized via a new authz.handlerAuthorizedExact
(exact, NOT a prefix: '/v1/iam/get-user' is a prefix of get-users, whose
Guard read-gate must stay). The handler authorizes both variants: the
owner/name read through the SAME authz.Can the Guard applied (no
regression, cross-tenant still 403), the key read behind CapKeyResolve
AND app-only (p.App != '' — a human holds a cap vacuously).
- Response is a minimal {owner,name,email,isAdmin} projection, TIGHTER than
User.Mask (which leaves AccessKey): a pk-/sk- resolution must never
disclose the resolved user's hk- credential.
Tests: store resolves each shape + fails closed on 9 bad inputs; compat
cap-holder resolves hk-/pk-/sk- with correct fields and zero secret leak
(incl. the user's hk- key on pk-/sk-); non-cap denied; unknown => not-found;
empty accessKey falls through to owner/name.
cloud's identity boundary resolves API keys via GET /v1/iam/get-user?
accessKey=<hk-/pk-/sk-> expecting {owner,name,email,isAdmin}. The
clean-room get-user resolved only owner/name/id, so EVERY hk-/pk-/sk- key
failed closed to anonymous at cutover. Resolve the key to its principal —
gated as a trusted service capability.
- store.UserByAccessKey: the ONE key->principal resolver. hk- => the User
row's AccessKey (user-owned or service-account key); pk- => schema.Key
AccessKey -> owning user; sk- => schema.Key AccessSecret -> owning user.
Fail-closed: empty/unknown/wrong-shape/user-less => orm.ErrNotFound,
never a wrong user.
- CapKeyResolve (env IAM_KEY_RESOLVE_APPS, fail-secure deny-all) — a
credential-disclosure boundary held by the cloud service app only.
- get-user is handler-authorized via a new authz.handlerAuthorizedExact
(exact, NOT a prefix: '/v1/iam/get-user' is a prefix of get-users, whose
Guard read-gate must stay). The handler authorizes both variants: the
owner/name read through the SAME authz.Can the Guard applied (no
regression, cross-tenant still 403), the key read behind CapKeyResolve
AND app-only (p.App != '' — a human holds a cap vacuously).
- Response is a minimal {owner,name,email,isAdmin} projection, TIGHTER than
User.Mask (which leaves AccessKey): a pk-/sk- resolution must never
disclose the resolved user's hk- credential.
Tests: store resolves each shape + fails closed on 9 bad inputs; compat
cap-holder resolves hk-/pk-/sk- with correct fields and zero secret leak
(incl. the user's hk- key on pk-/sk-); non-cap denied; unknown => not-found;
empty accessKey falls through to owner/name.
cloud's clients/team invite path hard-codes get-memberships /
add-membership / delete-membership; the clean-room served only REST
/v1/iam/memberships (list+ensure, no delete). Serve the verbs over the
SAME store + the SAME authz gates — no second implementation.
- store.DeleteMembership: idempotent revoke keyed by the same (user, org)
natural key EnsureMembership uses; absent row => (false, nil).
- memberships.Route registers the three verbs: get/add REUSE the REST
list/ensure handlers verbatim; delete adds a remove handler under the
same authz.Can(POST, organizations, admin, org) gate as ensure.
- authz.handlerAuthorizedPrefixes gains /v1/iam/get-memberships: its
target rides in ?user=/?org= (not owner/name), so the Guard authenticates
and the list handler's scoped() check is the tenant gate — exactly as its
REST twin /v1/iam/memberships already is. The POST verbs are raw handlers
the Guard never pre-authorizes; each self-authorizes.
Tests: get by ?user and ?org; add then get shows it; delete removes and is
idempotent; cross-tenant add/delete/read all refused auth:Unauthorized
operation; unauthenticated => 401.
cloud's clients/team invite path hard-codes get-memberships /
add-membership / delete-membership; the clean-room served only REST
/v1/iam/memberships (list+ensure, no delete). Serve the verbs over the
SAME store + the SAME authz gates — no second implementation.
- store.DeleteMembership: idempotent revoke keyed by the same (user, org)
natural key EnsureMembership uses; absent row => (false, nil).
- memberships.Route registers the three verbs: get/add REUSE the REST
list/ensure handlers verbatim; delete adds a remove handler under the
same authz.Can(POST, organizations, admin, org) gate as ensure.
- authz.handlerAuthorizedPrefixes gains /v1/iam/get-memberships: its
target rides in ?user=/?org= (not owner/name), so the Guard authenticates
and the list handler's scoped() check is the tenant gate — exactly as its
REST twin /v1/iam/memberships already is. The POST verbs are raw handlers
the Guard never pre-authorizes; each self-authorizes.
Tests: get by ?user and ?org; add then get shows it; delete removes and is
idempotent; cross-tenant add/delete/read all refused auth:Unauthorized
operation; unauthenticated => 401.
Clean-room user tokens carried owner/organization but NOT the membership
set, so a multi-org identity would silently collapse to home-org-only at
the prod cutover. Thread the tenancy set into every user mint.
- Claims gains Orgs []schema.OrgRef (json orgs,omitempty).
- Sign/SignID/SignUserToken take an orgs param and stamp claims.Orgs;
nil (a machine token) omits the claim. Signer stays schema.User-decoupled.
- store.MemberOrgRefs is the ONE resolver: home org first (role from
HomeRole), then explicit MembershipsByUser rows, deduped (home wins,
never twice). Mirrors beego token_jwt.go home-union-explicit; nil-safe.
- issueTokens (code/refresh/password) resolves the user once via userClaims
and threads orgs into access + id token; token-exchange and
issue-user-token thread the already-resolved user; client_credentials
passes nil.
Tests: store home-first/dedup + home-only/nil; oidc drives the real
authorization_code path and asserts orgs on access AND id token, home-only,
and NO orgs on a client_credentials machine token.
Clean-room user tokens carried owner/organization but NOT the membership
set, so a multi-org identity would silently collapse to home-org-only at
the prod cutover. Thread the tenancy set into every user mint.
- Claims gains Orgs []schema.OrgRef (json orgs,omitempty).
- Sign/SignID/SignUserToken take an orgs param and stamp claims.Orgs;
nil (a machine token) omits the claim. Signer stays schema.User-decoupled.
- store.MemberOrgRefs is the ONE resolver: home org first (role from
HomeRole), then explicit MembershipsByUser rows, deduped (home wins,
never twice). Mirrors beego token_jwt.go home-union-explicit; nil-safe.
- issueTokens (code/refresh/password) resolves the user once via userClaims
and threads orgs into access + id token; token-exchange and
issue-user-token thread the already-resolved user; client_credentials
passes nil.
Tests: store home-first/dedup + home-only/nil; oidc drives the real
authorization_code path and asserts orgs on access AND id token, home-only,
and NO orgs on a client_credentials machine token.
alpine:latest is the runtime base providing the sqlcipher CLI the migrator's
--wal-inclusive path pipes the raw 32-byte DEK to on stdin, so the base image
sits in the decryption-key trust path. A floating :latest is not acceptable for
a one-shot migration of irreplaceable auth data — a moved/trojaned base could
exfiltrate every credential. Pin both bases to current manifest-list digests
(golang was version- but not digest-pinned; alpine was fully floating).
alpine:latest @ sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
golang:1.26.4 @ sha256:f96cc555eb8db430159a3aa6797cd5bae561945b7b0fe7d0e284c63a3b291609
RED re-verify of v1.32.6 (migrator WAL guards) LOW finding; canary runs this
exact image. No functional change.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
alpine:latest is the runtime base providing the sqlcipher CLI the migrator's
--wal-inclusive path pipes the raw 32-byte DEK to on stdin, so the base image
sits in the decryption-key trust path. A floating :latest is not acceptable for
a one-shot migration of irreplaceable auth data — a moved/trojaned base could
exfiltrate every credential. Pin both bases to current manifest-list digests
(golang was version- but not digest-pinned; alpine was fully floating).
alpine:latest @ sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
golang:1.26.4 @ sha256:f96cc555eb8db430159a3aa6797cd5bae561945b7b0fe7d0e284c63a3b291609
RED re-verify of v1.32.6 (migrator WAL guards) LOW finding; canary runs this
exact image. No functional change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Close the X-Forwarded-Host footgun repo-wide and make "never echo the request
host into `iss`" structural in every issuer mode. One host accessor now: the
header-immune zip.Ctx.Host() (zip has no trusted-proxy knob, so a client
X-Forwarded-Host is ignored), the same seam the OIDC issuer resolver uses.
Part A — internal/oidc/issuer.go (issuer resolver uniformity):
- Fix#1: gate the host-relative dev issuer behind an explicit opt-in
(IAM_DEV_HOST_RELATIVE=1). Absent it, empty IAM_ISSUER + unset map is a HARD
boot error (errNoIssuer) instead of a silent fail-open host echo. issuerFor
echoes the host ONLY inside the opt-in branch; every other branch returns a
config value or the fixed devFallbackIssuer, so the no-echo property is
structural, not just emergent from construction.
- Fix#3: https-validate a non-empty IAM_ISSUER at construction, the same bar
map entries already clear (checked first, before the map).
Part B — retire httpx.EffectiveHost (internal/httpx/response.go, internal/wallet/*):
- EffectiveHost read X-Forwarded-Host DIRECTLY, bypassing zip's (absent)
proxy-trust, so TrustProxy=false did NOT protect it. It fed the SIWE `domain`
(the EIP-4361/CAIP-122 anti-phishing binding a user signs) and the CSRF
same-origin check. A client who could inject X-Forwarded-Host could steer the
signed domain (cross-brand phishing) and pass the same-origin check cross-site
(wallet-linking CSRF).
- Route all three wallet call-sites (nonce domain, verify domain, same-origin
check) through c.Host(), then DELETE EffectiveHost so exactly one host
accessor remains repo-wide. c.Host() is the true routed brand host the ingress
preserves — exactly what the SIWE domain must bind to — and immune to the
header, so the binding and CSRF check are correct AND unspoofable.
Tests: issuer boot-error/opt-in/https (Part A) + SIWE domain & CSRF
header-immunity driven end-to-end, plus a legit same-brand login still
succeeds (Part B). A mutation reintroducing the old X-Forwarded-Host behavior
fails all four Part B guards, including a demonstrated cross-site wallet-link
CSRF — proving they are non-vacuous.
CGO_ENABLED=0 GOWORK=off go build ./... && go vet ./... && go test ./... : green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Close the X-Forwarded-Host footgun repo-wide and make "never echo the request
host into `iss`" structural in every issuer mode. One host accessor now: the
header-immune zip.Ctx.Host() (zip has no trusted-proxy knob, so a client
X-Forwarded-Host is ignored), the same seam the OIDC issuer resolver uses.
Part A — internal/oidc/issuer.go (issuer resolver uniformity):
- Fix#1: gate the host-relative dev issuer behind an explicit opt-in
(IAM_DEV_HOST_RELATIVE=1). Absent it, empty IAM_ISSUER + unset map is a HARD
boot error (errNoIssuer) instead of a silent fail-open host echo. issuerFor
echoes the host ONLY inside the opt-in branch; every other branch returns a
config value or the fixed devFallbackIssuer, so the no-echo property is
structural, not just emergent from construction.
- Fix#3: https-validate a non-empty IAM_ISSUER at construction, the same bar
map entries already clear (checked first, before the map).
Part B — retire httpx.EffectiveHost (internal/httpx/response.go, internal/wallet/*):
- EffectiveHost read X-Forwarded-Host DIRECTLY, bypassing zip's (absent)
proxy-trust, so TrustProxy=false did NOT protect it. It fed the SIWE `domain`
(the EIP-4361/CAIP-122 anti-phishing binding a user signs) and the CSRF
same-origin check. A client who could inject X-Forwarded-Host could steer the
signed domain (cross-brand phishing) and pass the same-origin check cross-site
(wallet-linking CSRF).
- Route all three wallet call-sites (nonce domain, verify domain, same-origin
check) through c.Host(), then DELETE EffectiveHost so exactly one host
accessor remains repo-wide. c.Host() is the true routed brand host the ingress
preserves — exactly what the SIWE domain must bind to — and immune to the
header, so the binding and CSRF check are correct AND unspoofable.
Tests: issuer boot-error/opt-in/https (Part A) + SIWE domain & CSRF
header-immunity driven end-to-end, plus a legit same-brand login still
succeeds (Part B). A mutation reintroducing the old X-Forwarded-Host behavior
fails all four Part B guards, including a demonstrated cross-site wallet-link
CSRF — proving they are non-vacuous.
CGO_ENABLED=0 GOWORK=off go build ./... && go vet ./... && go test ./... : green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Close the two HIGH silent-data-loss vectors RED flagged in the WAL-inclusive
Casdoor->iam2 migrator, plus the release-image sqlcipher gap. A silent bug here
loses production auth data at cutover, so both guards fail closed.
HIGH #1 (wal.go) — non-atomic multi-file copy of a possibly-live shard. The
WAL-inclusive path copied iam.db, -wal and -shm as three separate reads; a
checkpoint firing mid-copy moves committed frames out of the -wal we already read
into a main db we did not (then TRUNCATEs the -wal), silently dropping those rows.
Add a read-consistency fence: snapshot (existence,size,mtime) of all three files
before the copy and re-snapshot after; if anything moved, the source was written
during the window and the copied triple may be internally inconsistent -> abort
loudly. Pass => a consistent point-in-time image (zero frames lost); fail => a
hard refusal. Never a silent drop. Plus a defensive post-export drain check: the
copy's -wal must be emptied by wal_checkpoint(TRUNCATE) or the run aborts.
HIGH #2 (encrypted.go, main.go) — WAL-blind default was a silent lossy default.
The default checkpointed path only printed a non-blocking warning; forgetting
--wal-inclusive silently dropped every uncheckpointed-WAL row and reported
success. Now the default path HARD-FAILS on any shard carrying a non-empty -wal,
naming --wal-inclusive (capture the rows) or the new --ignore-wal (intentionally
drop them, with a loud warning). Checked before the dest store is opened, so a
refusal writes nothing.
Dockerfile — build /migrate-v1 alongside /iam2 and apk add sqlcipher (alpine
ships SQLCipher 4.x: 4.5.6 stable / 4.6.x edge, v4 format matches the production
data and the pure-Go codec) so ONE image serves both the server and the migration
Job; fix the stale image.source label iam2 -> iam.
Tests (real C sqlcipher present, the e2e is NOT skipped): concurrent-writer /
moving-WAL abort; default-path -wal hard-fail + --ignore-wal override; guard unit
matrix. Full tree green. Both new guards proven load-bearing via neuter-and-fail
negative controls.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Close the two HIGH silent-data-loss vectors RED flagged in the WAL-inclusive
Casdoor->iam2 migrator, plus the release-image sqlcipher gap. A silent bug here
loses production auth data at cutover, so both guards fail closed.
HIGH #1 (wal.go) — non-atomic multi-file copy of a possibly-live shard. The
WAL-inclusive path copied iam.db, -wal and -shm as three separate reads; a
checkpoint firing mid-copy moves committed frames out of the -wal we already read
into a main db we did not (then TRUNCATEs the -wal), silently dropping those rows.
Add a read-consistency fence: snapshot (existence,size,mtime) of all three files
before the copy and re-snapshot after; if anything moved, the source was written
during the window and the copied triple may be internally inconsistent -> abort
loudly. Pass => a consistent point-in-time image (zero frames lost); fail => a
hard refusal. Never a silent drop. Plus a defensive post-export drain check: the
copy's -wal must be emptied by wal_checkpoint(TRUNCATE) or the run aborts.
HIGH #2 (encrypted.go, main.go) — WAL-blind default was a silent lossy default.
The default checkpointed path only printed a non-blocking warning; forgetting
--wal-inclusive silently dropped every uncheckpointed-WAL row and reported
success. Now the default path HARD-FAILS on any shard carrying a non-empty -wal,
naming --wal-inclusive (capture the rows) or the new --ignore-wal (intentionally
drop them, with a loud warning). Checked before the dest store is opened, so a
refusal writes nothing.
Dockerfile — build /migrate-v1 alongside /iam2 and apk add sqlcipher (alpine
ships SQLCipher 4.x: 4.5.6 stable / 4.6.x edge, v4 format matches the production
data and the pure-Go codec) so ONE image serves both the server and the migration
Job; fix the stale image.source label iam2 -> iam.
Tests (real C sqlcipher present, the e2e is NOT skipped): concurrent-writer /
moving-WAL abort; default-path -wal hard-fail + --ignore-wal override; guard unit
matrix. Full tree green. Both new guards proven load-bearing via neuter-and-fail
negative controls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
iam2 runs as ONE instance behind the ingress for every brand host (hanzo.id,
lux.id, id.zoo.network, pars.id, and their iam.* aliases). A single pinned
IAM_ISSUER made every non-matching brand emit the wrong `iss`, failing all their
RP validations. This adds a fail-closed, config-driven issuer resolver so each
brand emits its OWN pinned issuer from the SAME instance.
- internal/oidc/issuer.go: the ONE resolver. newIssuerResolver parses
IAM_ISSUER (default) + IAM_ISSUER_MAP (JSON host->issuer) once into an
immutable value. issuerFor(host): configured brand -> its pinned issuer;
unknown/spoofed host -> the pinned default (fail-closed, never echoes the
host); no config at all -> dev host-relative from the TRUSTED host. A map
without a default, malformed JSON, or a non-https/empty entry is a hard
startup error (fail LOUD, never silently mint under the wrong iss).
- tokenIssuer and federationBaseURL now both route through resolveIssuer(c.Host()).
c.Host() is zip's X-Forwarded-Host-immune accessor, so the issuer is ALWAYS a
trusted CONFIG value — a client header can only SELECT a configured brand,
never inject an arbitrary/foreign iss. This is the single source of truth:
discovery `issuer`, the derived `jwks_uri`, token `iss`, userinfo, device
verify, and the federation callback origin all resolve identically per brand.
- main.go serve(): InitIssuerResolver() pins the map before the listener opens.
- Backward compatible: unset IAM_ISSUER_MAP == today's single-issuer behavior.
Tests: table resolution (brands, aliases, case/whitespace/port/trailing-dot),
attacker-host-never-echoed (incl. suffix/prefix confusion), backward-compat,
hard-error config, and HTTP e2e proving token `iss` == discovery `issuer` ==
`jwks_uri` base per brand and that a spoofed Host/X-Forwarded-Host fails closed.
go build/vet/test all green (CGO_ENABLED=0 GOWORK=off).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
iam2 runs as ONE instance behind the ingress for every brand host (hanzo.id,
lux.id, id.zoo.network, pars.id, and their iam.* aliases). A single pinned
IAM_ISSUER made every non-matching brand emit the wrong `iss`, failing all their
RP validations. This adds a fail-closed, config-driven issuer resolver so each
brand emits its OWN pinned issuer from the SAME instance.
- internal/oidc/issuer.go: the ONE resolver. newIssuerResolver parses
IAM_ISSUER (default) + IAM_ISSUER_MAP (JSON host->issuer) once into an
immutable value. issuerFor(host): configured brand -> its pinned issuer;
unknown/spoofed host -> the pinned default (fail-closed, never echoes the
host); no config at all -> dev host-relative from the TRUSTED host. A map
without a default, malformed JSON, or a non-https/empty entry is a hard
startup error (fail LOUD, never silently mint under the wrong iss).
- tokenIssuer and federationBaseURL now both route through resolveIssuer(c.Host()).
c.Host() is zip's X-Forwarded-Host-immune accessor, so the issuer is ALWAYS a
trusted CONFIG value — a client header can only SELECT a configured brand,
never inject an arbitrary/foreign iss. This is the single source of truth:
discovery `issuer`, the derived `jwks_uri`, token `iss`, userinfo, device
verify, and the federation callback origin all resolve identically per brand.
- main.go serve(): InitIssuerResolver() pins the map before the listener opens.
- Backward compatible: unset IAM_ISSUER_MAP == today's single-issuer behavior.
Tests: table resolution (brands, aliases, case/whitespace/port/trailing-dot),
attacker-host-never-echoed (incl. suffix/prefix confusion), backward-compat,
hard-error config, and HTTP e2e proving token `iss` == discovery `issuer` ==
`jwks_uri` base per brand and that a spoofed Host/X-Forwarded-Host fails closed.
go build/vet/test all green (CGO_ENABLED=0 GOWORK=off).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the app-capability/mint escalation RED found after the prior pass: an
app principal's authority was keyed on its bare NAME, and the mint gate trusted
the resolved row's clientId as globally unique — neither the app's OWNING org nor
clientId uniqueness was enforced. Once public signup opens, a tenant could
signup -> onboard -> register <theirOrg>/hanzo-console (or an app whose clientId
collides with a mint-allow-listed one) and Basic-auth as it to inherit platform
capabilities / mint tokens for any user.
The fix is ONE conceptual change — pin the app's OWNING org to a reserved platform
signing owner (store.IsSigningCertOwner) — applied at the two gates an app's
authority flows through:
- [CRITICAL A] Principal carries AppOwner (set from the app row's Owner, not its
served Organization). authz.Allowed — the single funnel for EVERY app capability
(CapOrgAdmin/CapUserAdmin in authz, CapKeyMint/CapServiceAccountRead in
serviceaccounts) — grants nothing unless AppOwner is admin/built-in. The NAME
allowlist is thereby reserved to the admin-owned app, as its comment always
claimed but never enforced.
- [HIGH B] oidc mintAllowed/adminMintAllowed take the resolved app and require the
same owner-pin, so a colliding-clientId tenant app mints nothing. Additionally:
clientId is now globally unique — enforced at the applications create/update
write (ensureClientIdUnique), the JSON-document store's equivalent of the
(owner,name) natural key since it has no column for a DB UNIQUE index — and
store.GetApplicationByClientId resolves DETERMINISTICALLY, admin-preferring, so a
stray duplicate resolves to the platform row on every backend (the First()
no-ORDER-BY vector, unspecified on Postgres, is closed).
- [INFO] raw entity CRUD now gates the reserved-owner clause on store.IsReservedOrg
(admin/built-in/app), the SAME predicate signup/onboarding use, so an "app"-org
user is platform-reserved consistently across every surface.
All legit allow-listed/minter apps are admin-owned (seed Owner="admin"), so no
legitimate grant regresses. PoC tests for both attacks now DENY (incl. RED's four
assertions verbatim), and the full suite is green. [LOW] verified read-only against
the live iam-init-data ConfigMap: no admin/built-in/app-org app relies on
client_credentials/password, and all 80 seeded clientIds are already globally
unique (no dedup migration needed).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Closes the app-capability/mint escalation RED found after the prior pass: an
app principal's authority was keyed on its bare NAME, and the mint gate trusted
the resolved row's clientId as globally unique — neither the app's OWNING org nor
clientId uniqueness was enforced. Once public signup opens, a tenant could
signup -> onboard -> register <theirOrg>/hanzo-console (or an app whose clientId
collides with a mint-allow-listed one) and Basic-auth as it to inherit platform
capabilities / mint tokens for any user.
The fix is ONE conceptual change — pin the app's OWNING org to a reserved platform
signing owner (store.IsSigningCertOwner) — applied at the two gates an app's
authority flows through:
- [CRITICAL A] Principal carries AppOwner (set from the app row's Owner, not its
served Organization). authz.Allowed — the single funnel for EVERY app capability
(CapOrgAdmin/CapUserAdmin in authz, CapKeyMint/CapServiceAccountRead in
serviceaccounts) — grants nothing unless AppOwner is admin/built-in. The NAME
allowlist is thereby reserved to the admin-owned app, as its comment always
claimed but never enforced.
- [HIGH B] oidc mintAllowed/adminMintAllowed take the resolved app and require the
same owner-pin, so a colliding-clientId tenant app mints nothing. Additionally:
clientId is now globally unique — enforced at the applications create/update
write (ensureClientIdUnique), the JSON-document store's equivalent of the
(owner,name) natural key since it has no column for a DB UNIQUE index — and
store.GetApplicationByClientId resolves DETERMINISTICALLY, admin-preferring, so a
stray duplicate resolves to the platform row on every backend (the First()
no-ORDER-BY vector, unspecified on Postgres, is closed).
- [INFO] raw entity CRUD now gates the reserved-owner clause on store.IsReservedOrg
(admin/built-in/app), the SAME predicate signup/onboarding use, so an "app"-org
user is platform-reserved consistently across every surface.
All legit allow-listed/minter apps are admin-owned (seed Owner="admin"), so no
legitimate grant regresses. PoC tests for both attacks now DENY (incl. RED's four
assertions verbatim), and the full suite is green. [LOW] verified read-only against
the live iam-init-data ConfigMap: no admin/built-in/app-org app relies on
client_credentials/password, and all 80 seeded clientIds are already globally
unique (no dedup migration needed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 7-round Casdoor audit's invariant 1 (no admin-org self-signup) was the one
gap in iam2's clean-room model: onboarding and federated provisioning refused a
reserved system org, but POST /v1/iam/signup did not. Its only org gate was the
same-org/shared/org-choice tenant check — so a signup-enabled app that is admin-org,
shared, or org-choice admitted `organization=admin`, and a user under "admin" IS a
SuperAdmin (authz derives Super from owner == "admin"). Reachability depends on the
deploy-time init_data (no signup app ships in-repo), so this is closed structurally.
- store.IsReservedOrg — ONE reserved-org predicate {admin, built-in, app}, composed
on IsSigningCertOwner so a new signing owner is covered for free. Replaces the
package-private oidc.reservedOrgs map; onboard + federation now consult it too, so
the reserved set can never drift across the three self-service surfaces.
- signup: refuse a reserved org before the tenant gate, independent of the app —
byte-identical message to the tenant refuse, so there is no reserved-vs-tenant oracle.
- token endpoint (invariant 5, defense in depth): publicTokenEndpointForbidden refuses
a reserved-Organization app (admin/built-in/app) on client_credentials + password,
composing the existing <org>-iam gate — an admin-org app cannot mint on the public
endpoint, structurally.
- signup.go: corrected a stale "bcrypt" comment (the canonical users.Create path
stamps argon2id).
Tests: store.IsReservedOrg; signup refusal through shared / admin-org / org-choice
apps (+ no-oracle proof, + legitimate-tenant regression); token-endpoint reserved-org
refusal on both machine grants (+ tenant-org still-works regression); and the
lux.cloud signup-readiness proof (public PKCE lux-cloud client → PLAIN non-admin user
in the lux org, argon2id, EmailVerified=false).
Patch bump v1.32.2 -> v1.32.3 (tag cut on merge, post-RED, owner-gated).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The 7-round Casdoor audit's invariant 1 (no admin-org self-signup) was the one
gap in iam2's clean-room model: onboarding and federated provisioning refused a
reserved system org, but POST /v1/iam/signup did not. Its only org gate was the
same-org/shared/org-choice tenant check — so a signup-enabled app that is admin-org,
shared, or org-choice admitted `organization=admin`, and a user under "admin" IS a
SuperAdmin (authz derives Super from owner == "admin"). Reachability depends on the
deploy-time init_data (no signup app ships in-repo), so this is closed structurally.
- store.IsReservedOrg — ONE reserved-org predicate {admin, built-in, app}, composed
on IsSigningCertOwner so a new signing owner is covered for free. Replaces the
package-private oidc.reservedOrgs map; onboard + federation now consult it too, so
the reserved set can never drift across the three self-service surfaces.
- signup: refuse a reserved org before the tenant gate, independent of the app —
byte-identical message to the tenant refuse, so there is no reserved-vs-tenant oracle.
- token endpoint (invariant 5, defense in depth): publicTokenEndpointForbidden refuses
a reserved-Organization app (admin/built-in/app) on client_credentials + password,
composing the existing <org>-iam gate — an admin-org app cannot mint on the public
endpoint, structurally.
- signup.go: corrected a stale "bcrypt" comment (the canonical users.Create path
stamps argon2id).
Tests: store.IsReservedOrg; signup refusal through shared / admin-org / org-choice
apps (+ no-oracle proof, + legitimate-tenant regression); token-endpoint reserved-org
refusal on both machine grants (+ tenant-org still-works regression); and the
lux.cloud signup-readiness proof (public PKCE lux-cloud client → PLAIN non-admin user
in the lux org, argon2id, EmailVerified=false).
Patch bump v1.32.2 -> v1.32.3 (tag cut on merge, post-RED, owner-gated).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The encrypted-source path decrypted only each shard's CHECKPOINTED main db
(sqlcipher.DecryptFile), so rows still in an uncheckpointed `-wal` were
invisible. On the real production store most org shards carry 600-800KB of
uncheckpointed WAL, so a cutover built on the checkpointed image alone
undercounts users and locks recent signups out.
Add `--wal-inclusive` (with `--sqlcipher-bin`, default `sqlcipher`): per shard,
copy iam.db(+-wal/-shm) into a fresh 0700 temp, derive the DEK with the SAME
pure-Go recipe (deriveDEK, now shared with the checkpointed path), then drive
the C sqlcipher binary to `wal_checkpoint(TRUNCATE)` + `sqlcipher_export` a
plaintext copy, which the existing Migrate engine reads. The checkpointed path
stays the default so nothing regresses.
Safety: the DEK reaches the child ONLY on stdin (raw x'..' key), never on argv
or in logs; script bytes + hex are zeroed and child stderr is scrubbed of the
hex. A non-zero exit (-bail) or a non-SQLite export (wrong key) fails LOUDLY;
--wal-inclusive with an absent binary errors at preflight (never silent
fallback). The plaintext temp dir is shredded on every path incl. error.
Tests (CGO_ENABLED=0): a self-exec fake-sqlcipher proves key-off-argv,
plaintext validation, non-zero-exit and missing-binary fail loud, and shred;
and a real-C-sqlcipher end-to-end builds an org shard whose user lives ONLY in
an uncheckpointed -wal, proving the default path MISSES it while
--wal-inclusive recovers it and its golden argon2id digest verifies (skipped
when the binary is absent).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The encrypted-source path decrypted only each shard's CHECKPOINTED main db
(sqlcipher.DecryptFile), so rows still in an uncheckpointed `-wal` were
invisible. On the real production store most org shards carry 600-800KB of
uncheckpointed WAL, so a cutover built on the checkpointed image alone
undercounts users and locks recent signups out.
Add `--wal-inclusive` (with `--sqlcipher-bin`, default `sqlcipher`): per shard,
copy iam.db(+-wal/-shm) into a fresh 0700 temp, derive the DEK with the SAME
pure-Go recipe (deriveDEK, now shared with the checkpointed path), then drive
the C sqlcipher binary to `wal_checkpoint(TRUNCATE)` + `sqlcipher_export` a
plaintext copy, which the existing Migrate engine reads. The checkpointed path
stays the default so nothing regresses.
Safety: the DEK reaches the child ONLY on stdin (raw x'..' key), never on argv
or in logs; script bytes + hex are zeroed and child stderr is scrubbed of the
hex. A non-zero exit (-bail) or a non-SQLite export (wrong key) fails LOUDLY;
--wal-inclusive with an absent binary errors at preflight (never silent
fallback). The plaintext temp dir is shredded on every path incl. error.
Tests (CGO_ENABLED=0): a self-exec fake-sqlcipher proves key-off-argv,
plaintext validation, non-zero-exit and missing-binary fail loud, and shred;
and a real-C-sqlcipher end-to-end builds an org shard whose user lives ONLY in
an uncheckpointed -wal, proving the default path MISSES it while
--wal-inclusive recovers it and its golden argon2id digest verifies (skipped
when the binary is absent).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add pkg/store — the in-process project store surface for a host binary that
EMBEDS iam2 (hanzoai/cloud) rather than talking to it over HTTP. GetProjects /
GetOrganizationProjects / GetProject / AddProject / DeleteProject take an explicit
orm.DB (v2 has no package-global engine) and reproduce the ONE project CRUD path's
orm calls, preserving the "owner/name" id semantics, so an embedder reads/writes
the SAME `projects` rows the mounted /v1/iam/projects surface serves. GetProject
returns (nil,nil) on miss — the embedder pre-check convention the retired iam-v1
object store used.
Also alias model.Project = schema.Project (mirroring model.OrgRef) so embedders
share the ONE project type, never a local clone.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Add pkg/store — the in-process project store surface for a host binary that
EMBEDS iam2 (hanzoai/cloud) rather than talking to it over HTTP. GetProjects /
GetOrganizationProjects / GetProject / AddProject / DeleteProject take an explicit
orm.DB (v2 has no package-global engine) and reproduce the ONE project CRUD path's
orm calls, preserving the "owner/name" id semantics, so an embedder reads/writes
the SAME `projects` rows the mounted /v1/iam/projects surface serves. GetProject
returns (nil,nil) on miss — the embedder pre-check convention the retired iam-v1
object store used.
Also alias model.Project = schema.Project (mirroring model.OrgRef) so embedders
share the ONE project type, never a local clone.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a pure-Go encrypted-source path to the Phase-5 cutover migrator so the
whole fork -> clean migration is one command. The plaintext --src path is
unchanged.
New --src-datadir reads the SQLCipher/envelope-encrypted, sharded prod layout
in place: the GLOBAL shard (<dir>/iam.db; certs/apps/orgs; principal
global/"iam") first, then every <dir>/orgs/<slug>/iam.db (users; principal
org/<slug>) in sorted order. Each shard is decrypted with the proven recipe --
sqlite.DeriveKey (HKDF) -> read .dek sidecar -> sqlite.UnwrapDEK (AES-256-GCM)
-> sqlcipher.DecryptFile (pure-Go page codec) -> a 0600 temp -> the existing
Migrate engine (modernc, read-only) -> shred. Shards merge because Migrate
upserts by natural key.
Driver collision: DeriveKey/UnwrapDEK/PrincipalAAD are pure funcs in the ROOT
hanzoai/sqlite package that migrate.go already imports for the "sqlite" driver;
promoting that to a named import registers no new driver (one modernc
registrant under CGO_ENABLED=0), and hanzoai/sqlcipher registers none. So the
decrypt and the read live in one binary -- no os/exec helper, crypto stays in
hanzoai/sqlite. Documented in encrypted.go.
Safety: master key read from a NAMED env var (--src-master-key-env, default
IAM_KMS_MASTER_KEY), never an arg, never logged; wrong key fails loud at
UnwrapDEK before any write; temps shredded on every path incl. error; a
--note logs that the run captures checkpointed state (no -wal merge).
Tests (CGO_ENABLED=0): build a real encrypted fixture in-test via a reserved
canvas (DecryptFile the C libsqlcipher interop vector -> reserved plaintext;
modernc preserves the 80-byte reserve; EncryptFile our own schema) and run the
full DeriveKey->Unwrap->DecryptFile->migrate chain across a global + two org
shards -- the golden argon2id digest verifies under internal/cred after
decryption, wrong master fails loud with no writes, temps are shredded.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Add a pure-Go encrypted-source path to the Phase-5 cutover migrator so the
whole fork -> clean migration is one command. The plaintext --src path is
unchanged.
New --src-datadir reads the SQLCipher/envelope-encrypted, sharded prod layout
in place: the GLOBAL shard (<dir>/iam.db; certs/apps/orgs; principal
global/"iam") first, then every <dir>/orgs/<slug>/iam.db (users; principal
org/<slug>) in sorted order. Each shard is decrypted with the proven recipe --
sqlite.DeriveKey (HKDF) -> read .dek sidecar -> sqlite.UnwrapDEK (AES-256-GCM)
-> sqlcipher.DecryptFile (pure-Go page codec) -> a 0600 temp -> the existing
Migrate engine (modernc, read-only) -> shred. Shards merge because Migrate
upserts by natural key.
Driver collision: DeriveKey/UnwrapDEK/PrincipalAAD are pure funcs in the ROOT
hanzoai/sqlite package that migrate.go already imports for the "sqlite" driver;
promoting that to a named import registers no new driver (one modernc
registrant under CGO_ENABLED=0), and hanzoai/sqlcipher registers none. So the
decrypt and the read live in one binary -- no os/exec helper, crypto stays in
hanzoai/sqlite. Documented in encrypted.go.
Safety: master key read from a NAMED env var (--src-master-key-env, default
IAM_KMS_MASTER_KEY), never an arg, never logged; wrong key fails loud at
UnwrapDEK before any write; temps shredded on every path incl. error; a
--note logs that the run captures checkpointed state (no -wal merge).
Tests (CGO_ENABLED=0): build a real encrypted fixture in-test via a reserved
canvas (DecryptFile the C libsqlcipher interop vector -> reserved plaintext;
modernc preserves the 80-byte reserve; EncryptFile our own schema) and run the
full DeriveKey->Unwrap->DecryptFile->migrate chain across a global + two org
shards -- the golden argon2id digest verifies under internal/cred after
decryption, wrong master fails loud with no writes, temps are shredded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reads the legacy Casdoor-fork SQLite iam.db and upserts every identity
record into the clean IAM v2 orm store, preserving credentials and signing
keys byte-for-byte (the cutover gate).
- cmd/migrate-v1: standalone `migrate-v1 --src <iam.db> --dest <data-dir>
[--dry-run] [--only ...]`. Opens the source read-only; opens the dest
through the SAME store.Open the server uses (schema parity).
- Column mapping is discovered, not assumed: PRAGMA table_info + sqlite_master,
matched to clean schema fields by a normalized key (lowercase, strip
underscores) so xorm snake_case lines up with camelCase json tags. The one
rename normalization can't bridge — legacy `password` column -> clean
PasswordHash — is declared explicitly, so no user's hash is dropped.
- Credential/key material (PasswordHash/Type/Salt, Cert.PrivateKey/Certificate)
is reconstructed as JSON and unmarshaled, never passed through a lossy typed
conversion. Natural key is owner/name (== the clean OIDC sub); the legacy
per-row UUID has no clean home and is reported as a gap.
- Idempotent: create/overwrite-if-changed/no-op, so re-runs don't duplicate or
churn. --dry-run reports counts + a secret-redacted sample without writing.
- Entity order: organizations -> certs -> applications -> providers -> users
-> roles -> permissions. Membership has no legacy table (noted, skipped).
- store.Open extracted from main.go so the server and the migrator share one
store-open path.
Tests (CGO_ENABLED=0): synthetic legacy iam.db proves PasswordHash and
Cert.PrivateKey land verbatim, cred.Verify succeeds against the migrated
argon2id golden vector (own type + org fallback), re-run is a pure no-op, and
--dry-run writes nothing.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Reads the legacy Casdoor-fork SQLite iam.db and upserts every identity
record into the clean IAM v2 orm store, preserving credentials and signing
keys byte-for-byte (the cutover gate).
- cmd/migrate-v1: standalone `migrate-v1 --src <iam.db> --dest <data-dir>
[--dry-run] [--only ...]`. Opens the source read-only; opens the dest
through the SAME store.Open the server uses (schema parity).
- Column mapping is discovered, not assumed: PRAGMA table_info + sqlite_master,
matched to clean schema fields by a normalized key (lowercase, strip
underscores) so xorm snake_case lines up with camelCase json tags. The one
rename normalization can't bridge — legacy `password` column -> clean
PasswordHash — is declared explicitly, so no user's hash is dropped.
- Credential/key material (PasswordHash/Type/Salt, Cert.PrivateKey/Certificate)
is reconstructed as JSON and unmarshaled, never passed through a lossy typed
conversion. Natural key is owner/name (== the clean OIDC sub); the legacy
per-row UUID has no clean home and is reported as a gap.
- Idempotent: create/overwrite-if-changed/no-op, so re-runs don't duplicate or
churn. --dry-run reports counts + a secret-redacted sample without writing.
- Entity order: organizations -> certs -> applications -> providers -> users
-> roles -> permissions. Membership has no legacy table (noted, skipped).
- store.Open extracted from main.go so the server and the migrator share one
store-open path.
Tests (CGO_ENABLED=0): synthetic legacy iam.db proves PasswordHash and
Cert.PrivateKey land verbatim, cred.Verify succeeds against the migrated
argon2id golden vector (own type + org fallback), re-run is a pure no-op, and
--dry-run writes nothing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cloud reads the `orgs` claim (the tenancy set) off a v2 token as []OrgRef,
today importing it from the dead github.com/hanzoai/iam-v1. Export the same
shape from v2's consumer surface so iam-v1 can die:
- schema.OrgRef{Org string `json:"org"`; Role string `json:"role,omitempty"`}
— the claim-side projection of a Membership row (Membership is how the
relation is stored, OrgRef is how it travels in a JWT). Same JSON tags as
iam-v1, so a token minted by v2 and read by a consumer round-trips
byte-for-byte.
- schema.OrgRefsFromMemberships / (*Membership).AsOrgRef — the ONE way to
build the orgs-claim slice from stored memberships.
- pkg/model.OrgRef aliases schema.OrgRef (one canonical type, no drift),
the ONE import path for external consumers: github.com/hanzoai/iam/pkg/model.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
cloud reads the `orgs` claim (the tenancy set) off a v2 token as []OrgRef,
today importing it from the dead github.com/hanzoai/iam-v1. Export the same
shape from v2's consumer surface so iam-v1 can die:
- schema.OrgRef{Org string `json:"org"`; Role string `json:"role,omitempty"`}
— the claim-side projection of a Membership row (Membership is how the
relation is stored, OrgRef is how it travels in a JWT). Same JSON tags as
iam-v1, so a token minted by v2 and read by a consumer round-trips
byte-for-byte.
- schema.OrgRefsFromMemberships / (*Membership).AsOrgRef — the ONE way to
build the orgs-claim slice from stored memberships.
- pkg/model.OrgRef aliases schema.OrgRef (one canonical type, no drift),
the ONE import path for external consumers: github.com/hanzoai/iam/pkg/model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Workspace between Organization and Project, mirroring the Project entity
across every layer of IAM v2 (native orm, no Casdoor/xorm):
- schema.Workspace (orm.Model[Workspace]) — same shape as Project plus a
Bucket field: IAM records the storage binding, storage owns the physical
bucket name. Registered as kind "workspaces" in schema.go.
- internal/workspaces: the ONE workspace CRUD path (List/Get/Create/Update/
Delete), a 1:1 mirror of internal/projects, mounted after the Guard.
- compat: get-organization-workspaces (ScopeSwitcher read, ?organization=,
handler-authorized via authz.Scope) + add-/delete-workspace verbs, the same
seam add-/delete-project use.
- Project gains a Workspace FK (empty ⇒ org-level, backward compatible),
settable through the same projects.Input/apply path as every other field.
Native tests (real mounted router): lifecycle, bucket+isDefault round-trip,
Project Workspace-FK round-trip, write-needs-admin, cross-tenant isolation.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Add Workspace between Organization and Project, mirroring the Project entity
across every layer of IAM v2 (native orm, no Casdoor/xorm):
- schema.Workspace (orm.Model[Workspace]) — same shape as Project plus a
Bucket field: IAM records the storage binding, storage owns the physical
bucket name. Registered as kind "workspaces" in schema.go.
- internal/workspaces: the ONE workspace CRUD path (List/Get/Create/Update/
Delete), a 1:1 mirror of internal/projects, mounted after the Guard.
- compat: get-organization-workspaces (ScopeSwitcher read, ?organization=,
handler-authorized via authz.Scope) + add-/delete-workspace verbs, the same
seam add-/delete-project use.
- Project gains a Workspace FK (empty ⇒ org-level, backward compatible),
settable through the same projects.Input/apply path as every other field.
Native tests (real mounted router): lifecycle, bucket+isDefault round-trip,
Project Workspace-FK round-trip, write-needs-admin, cross-tenant isolation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The clean-room rewrite takes the canonical hanzoai/iam name; the retired Casdoor
fork now lives at hanzoai/iam-v1. Module path + self-imports rewritten; builds green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The clean-room rewrite takes the canonical hanzoai/iam name; the retired Casdoor
fork now lives at hanzoai/iam-v1. Module path + self-imports rewritten; builds green.
Co-Authored-By: Claude <noreply@anthropic.com>
Two commits: (1) a redirect-flow 2FA resume so a federated login runs the same
second-factor gate a password login does — the callback parks a subject-pinned
KindFederation LoginChallenge and mints only after the factor verifies at
POST /v1/iam/oauth/federation/mfa; (2) POST /v1/iam/unlink, the account-linking
law's fail-closed inverse (self or SuperAdmin, app-permission-gated). go build,
vet, and full test ./... green.
Two commits: (1) a redirect-flow 2FA resume so a federated login runs the same
second-factor gate a password login does — the callback parks a subject-pinned
KindFederation LoginChallenge and mints only after the factor verifies at
POST /v1/iam/oauth/federation/mfa; (2) POST /v1/iam/unlink, the account-linking
law's fail-closed inverse (self or SuperAdmin, app-permission-gated). go build,
vet, and full test ./... green.
Port the account-linking law's inverse from feat/social onto main's connector
model: the account holder (self) or a SuperAdmin may clear one provider link; an
org admin may not (unlinking is unpicking a sign-in method, not tenant admin). A
self-unlink additionally needs the application's CanUnlink flag, so an org that
mandates federated sign-in cannot let users strand themselves; a SuperAdmin is the
platform recovery path and is not bound by it. Re-linking still runs the full
verified-subject / verified-email law, so unlink only ever LEAVES an account
unlinked.
- Reads/writes the link through the ONE connectorFor registry (never reflecting
the provider type onto a Go field name — the bug that made v1's GitLab unlink
silently no-op). Self-authenticates via callerOf (session/bearer) on the public
group, exactly as get-account/userinfo do (oidc cannot import authz).
Tests: self-unlink clears the connector when permitted; cross-user is refused and
the target's link survives; a forbidding app blocks self-unlink; an
unauthenticated request is refused and clears nothing.
Port the account-linking law's inverse from feat/social onto main's connector
model: the account holder (self) or a SuperAdmin may clear one provider link; an
org admin may not (unlinking is unpicking a sign-in method, not tenant admin). A
self-unlink additionally needs the application's CanUnlink flag, so an org that
mandates federated sign-in cannot let users strand themselves; a SuperAdmin is the
platform recovery path and is not bound by it. Re-linking still runs the full
verified-subject / verified-email law, so unlink only ever LEAVES an account
unlinked.
- Reads/writes the link through the ONE connectorFor registry (never reflecting
the provider type onto a Go field name — the bug that made v1's GitLab unlink
silently no-op). Self-authenticates via callerOf (session/bearer) on the public
group, exactly as get-account/userinfo do (oidc cannot import authz).
Tests: self-unlink clears the connector when permitted; cross-user is refused and
the target's link survives; a forbidding app blocks self-unlink; an
unauthenticated request is refused and clears nothing.
A 2FA-enrolled user signing in through Google/GitHub skipped the factor a password
login demands: the callback minted the authorization code directly. Now that the
MFA gate is live, close the bypass with a redirect-flow 2FA resume.
- The callback, after linkOrProvision resolves the user, runs the second-factor
gate BEFORE minting: if the user owes a factor (factor.Enabled and not
remembered) it mints NOTHING — it parks the resume in a single-use, expiring,
subject-pinned LoginChallenge (the SAME lifecycle the password gate uses, new
KindFederation) and redirects the browser to the hosted 2FA page. An org that
REQUIRES an unenrolled factor fails closed.
- POST /v1/iam/oauth/federation/mfa is the resume: it spends the challenge, loads
the PINNED user from its subject (never the request), verifies the factor
through the shared factor.Verify / factor.UseRecovery seam, and only then mints
the code for the PINNED authorize request. The challenge id rides the httpOnly,
SameSite=Lax cookie, so a cross-site POST carries none and fails closed.
- federationMint is the ONE mint path both the no-factor completion and the
post-2FA resume reach; the resume body carries the factor and NOTHING else, so
the target user and redirect_uri are structurally unswappable mid-flow.
Tests (federation_mfa_test.go): an enrolled user via federation gets NO code
without the factor then resumes to one; an unenrolled user flows through
unchanged; the challenge is single-use and expiring; user + redirect_uri pinning
holds against a steering body; a missing challenge fails closed; recovery codes
resume too. go build/vet/test ./... green.
A 2FA-enrolled user signing in through Google/GitHub skipped the factor a password
login demands: the callback minted the authorization code directly. Now that the
MFA gate is live, close the bypass with a redirect-flow 2FA resume.
- The callback, after linkOrProvision resolves the user, runs the second-factor
gate BEFORE minting: if the user owes a factor (factor.Enabled and not
remembered) it mints NOTHING — it parks the resume in a single-use, expiring,
subject-pinned LoginChallenge (the SAME lifecycle the password gate uses, new
KindFederation) and redirects the browser to the hosted 2FA page. An org that
REQUIRES an unenrolled factor fails closed.
- POST /v1/iam/oauth/federation/mfa is the resume: it spends the challenge, loads
the PINNED user from its subject (never the request), verifies the factor
through the shared factor.Verify / factor.UseRecovery seam, and only then mints
the code for the PINNED authorize request. The challenge id rides the httpOnly,
SameSite=Lax cookie, so a cross-site POST carries none and fails closed.
- federationMint is the ONE mint path both the no-factor completion and the
post-2FA resume reach; the resume body carries the factor and NOTHING else, so
the target user and redirect_uri are structurally unswappable mid-flow.
Tests (federation_mfa_test.go): an enrolled user via federation gets NO code
without the factor then resumes to one; an unenrolled user flows through
unchanged; the challenge is single-use and expiring; user + redirect_uri pinning
holds against a steering body; a missing challenge fails closed; recovery codes
resume too. go build/vet/test ./... green.
Re-implemented on main's current architecture (not merged from feat/org): app
principals whose entire authority is a capability allowlist (closes v1's global-
admin-client hole), service-account identities with argon2id key secrets returned
once, and the User×Org×Role membership relation. Uses main's internal/cred (no
duplicate). The orgs token claim is a noted follow-up (needs the jwt Subject work).
go build/vet/test ./... green.
Re-implemented on main's current architecture (not merged from feat/org): app
principals whose entire authority is a capability allowlist (closes v1's global-
admin-client hole), service-account identities with argon2id key secrets returned
once, and the User×Org×Role membership relation. Uses main's internal/cred (no
duplicate). The orgs token claim is a noted follow-up (needs the jwt Subject work).
go build/vet/test ./... green.
Three org/tenancy primitives main lacked, re-implemented on main's current
architecture (zip-group Route, main's internal/cred). Closes v1's "every client
credential is a global admin" hole.
- Confidential-client capabilities: authz.Principal gains App (the application
NAME when the request authenticated via client_secret_basic); authz.app resolves
`Authorization: Basic <clientId:secret>` against a constant-time secret compare;
cap.go is the allowlist model (Cap{Name,Env}, Allowed, BoundToOrg, capFor). An
app principal is NEVER Admin and NEVER Super — its ENTIRE authority is its
capability allowlist, keyed on the admin-owned application name, so a leaked
credential can neither cross a tenant nor reach signing material, and an unmapped
entity or unset allowlist denies. httpx.Basic is the one RFC 7617 parser.
- Service accounts (/v1/iam/service-accounts): agent/bot identities as
Type=service-account user rows — no password, key secret stored ONLY as an
argon2id digest (internal/cred), the raw secret returned exactly once at mint and
never again. create/rotate/revoke need the mint capability (app) or org-admin
over the target org (human); list takes the read-only capability and is
tenant-bound by the <org>-<app> name. keys.Mint exported as the one minting
primitive.
- Memberships (/v1/iam/memberships): the (User x Org x Role) relation + store ops
(EnsureMembership idempotent + no-downgrade, MembershipsByUser/ByOrg, home-org
BackfillMemberships). Registered as the `memberships` kind. NOTE: emitting the
`orgs` token claim needs the jwt Subject work (deliberately NOT landed here); the
relation ships stored + queryable, the claim is a follow-up.
Structural: memberships/serviceaccounts Route on the AUTHED group and self-
authorize via the Principal/capabilities the Guard attached; their query-targeted
reads join handlerAuthorizedPrefixes so the Guard authenticates and the handler
org-scopes (no revived publicPaths).
Tests: authz capability policy (app acts only on its allowlisted entity, never
crosses to signing material, a non-allowlisted client is inert, never Super/Admin;
Allowed/BoundToOrg/capFor fail-secure); service-account mint (argon2id digest,
one-time, rotation retires the prior secret) + admin/read gates (capability-gated,
tenant-bound list); membership Ensure idempotency + no-downgrade + by-user/by-org +
backfill. go build/vet/test ./... all green.
Three org/tenancy primitives main lacked, re-implemented on main's current
architecture (zip-group Route, main's internal/cred). Closes v1's "every client
credential is a global admin" hole.
- Confidential-client capabilities: authz.Principal gains App (the application
NAME when the request authenticated via client_secret_basic); authz.app resolves
`Authorization: Basic <clientId:secret>` against a constant-time secret compare;
cap.go is the allowlist model (Cap{Name,Env}, Allowed, BoundToOrg, capFor). An
app principal is NEVER Admin and NEVER Super — its ENTIRE authority is its
capability allowlist, keyed on the admin-owned application name, so a leaked
credential can neither cross a tenant nor reach signing material, and an unmapped
entity or unset allowlist denies. httpx.Basic is the one RFC 7617 parser.
- Service accounts (/v1/iam/service-accounts): agent/bot identities as
Type=service-account user rows — no password, key secret stored ONLY as an
argon2id digest (internal/cred), the raw secret returned exactly once at mint and
never again. create/rotate/revoke need the mint capability (app) or org-admin
over the target org (human); list takes the read-only capability and is
tenant-bound by the <org>-<app> name. keys.Mint exported as the one minting
primitive.
- Memberships (/v1/iam/memberships): the (User x Org x Role) relation + store ops
(EnsureMembership idempotent + no-downgrade, MembershipsByUser/ByOrg, home-org
BackfillMemberships). Registered as the `memberships` kind. NOTE: emitting the
`orgs` token claim needs the jwt Subject work (deliberately NOT landed here); the
relation ships stored + queryable, the claim is a follow-up.
Structural: memberships/serviceaccounts Route on the AUTHED group and self-
authorize via the Principal/capabilities the Guard attached; their query-targeted
reads join handlerAuthorizedPrefixes so the Guard authenticates and the handler
org-scopes (no revived publicPaths).
Tests: authz capability policy (app acts only on its allowlisted entity, never
crosses to signing material, a non-allowlisted client is inert, never Super/Admin;
Allowed/BoundToOrg/capFor fail-secure); service-account mint (argon2id digest,
one-time, rotation retires the prior secret) + admin/read gates (capability-gated,
tenant-bound list); membership Ensure idempotency + no-downgrade + by-user/by-org +
backfill. go build/vet/test ./... all green.
Re-implemented on main's current architecture: extends internal/mfa (no fork) via
a decomplected leaf domain internal/mfa/factor that the gate and enrollment share,
adds schema.LoginChallenge distinct from the web3 Challenge, gates every
interactive sign-in before minting. Recovery codes now hashed at rest. 12 gate
tests + full go test ./... green.
Re-implemented on main's current architecture: extends internal/mfa (no fork) via
a decomplected leaf domain internal/mfa/factor that the gate and enrollment share,
adds schema.LoginChallenge distinct from the web3 Challenge, gates every
interactive sign-in before minting. Recovery codes now hashed at rest. 12 gate
tests + full go test ./... green.
The MFA GATE that main's TOTP enrollment surface never had: a verified password
proves ONE factor, and the sign-in is held until a SECOND lands — before any
token or device approval. Built on main's existing internal/mfa + schema, not a
second mfa package.
- internal/mfa/factor: the pure multi-factor DOMAIN decomplected out of the
enrollment surface — Verify (the ONE TOTP check both enrollment and the gate
call), UseRecovery (bcrypt-or-legacy-plaintext, one-time), Enabled/Prompt/
AllProps/Props, Copy/Save (column-scoped, so an MFA write can't carry isAdmin).
A LEAF (imports only store+schema, never authz/oidc), which is what lets the
gate use it without an authz→oidc→mfa→authz import cycle.
- internal/oidc/mfa_gate.go: gate() answers RequiredMfa (org demands an unenrolled
factor) / NextMfa (data2 carries the choosable factors, no code minted) —
verbatim v1 wire strings; finishMfa() verifies the factor and loads the user
from the CHALLENGE subject, never the request body; the "remember this device"
window fails closed on an unparsable deadline and preserves the zero-window =
always-challenge behavior every live org relies on.
- internal/oidc/challenge.go + schema.LoginChallenge (kind login_challenges): a
server-side, single-use, owner-scoped row replacing v1's beego cookie session —
distinct from the web3 Challenge (that is a pre-auth nonce; this is bound to a
known subject). TakeChallenge spends on read, so a captured id never replays.
- login.go: the gate runs for EVERY interactive sign-in; loginGrant is the one
minting tail (device approval / session / code) both the credential post and
the second-factor finish reach — so a second factor over a device approval
lands at approveDevice, not a token. httpx.Ok gains a variadic data2.
- HARDENING: enrollment now stores recovery codes as bcrypt digests
(factor.HashRecoveryCodes), never the plaintext bearer credential; UseRecovery
still verifies v1-era plaintext rows so no live 2FA user loses their way back.
Tests: internal/oidc/login_mfa_test.go (12 cases — the enrolled-user-challenged
regression, single-use, subject-binding, recovery hashed + legacy plaintext,
repeat-factor refusal, remember-window round-trip + zero-window, org-required
enrollment, unenrolled-unchanged). go build ./..., go vet ./..., full go test
./... green; main's TotP enrollment tests unchanged.
The MFA GATE that main's TOTP enrollment surface never had: a verified password
proves ONE factor, and the sign-in is held until a SECOND lands — before any
token or device approval. Built on main's existing internal/mfa + schema, not a
second mfa package.
- internal/mfa/factor: the pure multi-factor DOMAIN decomplected out of the
enrollment surface — Verify (the ONE TOTP check both enrollment and the gate
call), UseRecovery (bcrypt-or-legacy-plaintext, one-time), Enabled/Prompt/
AllProps/Props, Copy/Save (column-scoped, so an MFA write can't carry isAdmin).
A LEAF (imports only store+schema, never authz/oidc), which is what lets the
gate use it without an authz→oidc→mfa→authz import cycle.
- internal/oidc/mfa_gate.go: gate() answers RequiredMfa (org demands an unenrolled
factor) / NextMfa (data2 carries the choosable factors, no code minted) —
verbatim v1 wire strings; finishMfa() verifies the factor and loads the user
from the CHALLENGE subject, never the request body; the "remember this device"
window fails closed on an unparsable deadline and preserves the zero-window =
always-challenge behavior every live org relies on.
- internal/oidc/challenge.go + schema.LoginChallenge (kind login_challenges): a
server-side, single-use, owner-scoped row replacing v1's beego cookie session —
distinct from the web3 Challenge (that is a pre-auth nonce; this is bound to a
known subject). TakeChallenge spends on read, so a captured id never replays.
- login.go: the gate runs for EVERY interactive sign-in; loginGrant is the one
minting tail (device approval / session / code) both the credential post and
the second-factor finish reach — so a second factor over a device approval
lands at approveDevice, not a token. httpx.Ok gains a variadic data2.
- HARDENING: enrollment now stores recovery codes as bcrypt digests
(factor.HashRecoveryCodes), never the plaintext bearer credential; UseRecovery
still verifies v1-era plaintext rows so no live 2FA user loses their way back.
Tests: internal/oidc/login_mfa_test.go (12 cases — the enrolled-user-challenged
regression, single-use, subject-binding, recovery hashed + legacy plaintext,
repeat-factor refusal, remember-window round-trip + zero-window, org-required
enrollment, unenrolled-unchanged). go build ./..., go vet ./..., full go test
./... green; main's TotP enrollment tests unchanged.
Re-implemented on main's current architecture (public zip-group + the one token
endpoint + the login endpoint), not merged from feat/token. Confidential clients
authenticate at the device request and the poll (§3.1/§3.4); public device
clients bind by client_id. 15 device tests + full go test ./... green.
Re-implemented on main's current architecture (public zip-group + the one token
endpoint + the login endpoint), not merged from feat/token. Confidential clients
authenticate at the device request and the poll (§3.1/§3.4); public device
clients bind by client_id. 15 device tests + full go test ./... green.
Browserless CLI sign-in (`hanzo login` on a GPU box, over ssh, in CI),
re-implemented on main's current architecture: routeDevice on the public group,
the poll dispatched from the one token endpoint beside the existing
introspection/revocation, approval on the existing login endpoint. No revived
publicPaths — reachability is group membership.
- POST /v1/iam/oauth/device mints a device_code + a 40-bit unambiguous user_code
(RFC 8628 §6.1 alphabet, uniform draw) and returns the verification URIs; the
pending grant is a Token row (Code=device_code, UserCode=user_code, User empty
until approved), not process-local state — it survives restart and replicas.
- POST /v1/iam/login {type:"device"} binds the approver onto the row against the
identity the credential check just proved; approval is org-scoped (a foreign
tenant is refused, a SuperAdmin crosses deliberately) and the user_code refusal
is non-differential (unknown/expired/used are indistinguishable — no oracle).
- grant_type=urn:...:device_code polls the token endpoint: authorization_pending
until approved, then mints exactly once through the shared issueTokens.
- CLIENT AUTHENTICATION (RFC 8628 §3.1 request + §3.4 poll -> RFC 6749 §3.2.1):
a confidential client (registered secret) authenticates its secret at BOTH
legs, checked before the pending/mint split so an unauthenticated confidential
poll never learns the grant state; a public device client is bound by client_id
alone. Kind guards both ways: an auth code can't redeem at the device grant
(no PKCE/redirect there) and a device code can't redeem at the auth-code grant
(no human approved).
- schema.Token gains UserCode (indexed); store.GetTokenByUserCode +
store.IsSuperAdmin (the reserved-org predicate, below the authz seam);
discovery advertises device_authorization_endpoint + the device grant.
Tests: internal/oidc device_test.go (15 cases incl. confidential-client auth,
tenant boundary, non-differential refusal, both cross-grant redemption guards);
the pre-existing error-taxonomy test updated (device_code is now implemented, so
its "unsupported" example moves to RFC 7523 jwt-bearer). go build ./... and full
go test ./... green.
Browserless CLI sign-in (`hanzo login` on a GPU box, over ssh, in CI),
re-implemented on main's current architecture: routeDevice on the public group,
the poll dispatched from the one token endpoint beside the existing
introspection/revocation, approval on the existing login endpoint. No revived
publicPaths — reachability is group membership.
- POST /v1/iam/oauth/device mints a device_code + a 40-bit unambiguous user_code
(RFC 8628 §6.1 alphabet, uniform draw) and returns the verification URIs; the
pending grant is a Token row (Code=device_code, UserCode=user_code, User empty
until approved), not process-local state — it survives restart and replicas.
- POST /v1/iam/login {type:"device"} binds the approver onto the row against the
identity the credential check just proved; approval is org-scoped (a foreign
tenant is refused, a SuperAdmin crosses deliberately) and the user_code refusal
is non-differential (unknown/expired/used are indistinguishable — no oracle).
- grant_type=urn:...:device_code polls the token endpoint: authorization_pending
until approved, then mints exactly once through the shared issueTokens.
- CLIENT AUTHENTICATION (RFC 8628 §3.1 request + §3.4 poll -> RFC 6749 §3.2.1):
a confidential client (registered secret) authenticates its secret at BOTH
legs, checked before the pending/mint split so an unauthenticated confidential
poll never learns the grant state; a public device client is bound by client_id
alone. Kind guards both ways: an auth code can't redeem at the device grant
(no PKCE/redirect there) and a device code can't redeem at the auth-code grant
(no human approved).
- schema.Token gains UserCode (indexed); store.GetTokenByUserCode +
store.IsSuperAdmin (the reserved-org predicate, below the authz seam);
discovery advertises device_authorization_endpoint + the device grant.
Tests: internal/oidc device_test.go (15 cases incl. confidential-client auth,
tenant boundary, non-differential refusal, both cross-grant redemption guards);
the pre-existing error-taxonomy test updated (device_code is now implemented, so
its "unsupported" example moves to RFC 7523 jwt-bearer). go build ./... and full
go test ./... green.
Rebased onto main and integrated onto the current structural-auth model:
wallet.Route registers GET /v1/iam/web3/nonce + POST /v1/iam/web3/verify on the
public route group (before the Guard), authz.Optional resolves a public route's
optional caller, and schema registers the Challenge + Wallet kinds. The feature
is entirely net-new (no web3 surface existed on main); go build ./... and
go test ./... are green.
Rebased onto main and integrated onto the current structural-auth model:
wallet.Route registers GET /v1/iam/web3/nonce + POST /v1/iam/web3/verify on the
public route group (before the Guard), authz.Optional resolves a public route's
optional caller, and schema registers the Challenge + Wallet kinds. The feature
is entirely net-new (no web3 surface existed on main); go build ./... and
go test ./... are green.
Keyless wallet login for IAM v2 over github.com/luxwallet/connect/go — the SAME
VerifyProof the TypeScript SDK runs, so Go and TS verify identically.
- GET /v1/iam/web3/nonce mints a single-use CAIP-122 challenge; POST
/v1/iam/web3/verify verifies a signed proof and IS the login. Both are
anonymous by construction (public allowlist) — they precede any token.
- internal/wallet: HTTP shell (wallet.go) + chain-agnostic core (verify.go, no
zip.Ctx, unit-testable) + transactional store (store.go). The nonce is BURNED
before any crypto runs, so a captured proof cannot be redeemed twice; the burn
and the (chain,address) link close their races with transactions, since orm
has no conditional UPDATE or UNIQUE constraint.
- schema.Challenge (web3_nonce) binds the signed Domain for phishing defense and
re-checks Chain at verify; schema.Wallet is the (Chain,Address)->user side
table (globally unique pair), address stored exactly as the verifier
canonicalized it (EVM lowercased, case-sensitive chains trimmed only).
- oidc.MintFor/ResolveApp (mint.go): the shared tail of every interactive login
— tenant isolation, redirect binding, PKCE, code mint — so wallet sign-in
inherits the same rules as password login instead of restating them.
- authz.Optional resolves a public route's optional caller (fail-closed, nil
when anonymous or the bearer does not verify); store.GetOrganizationByName;
drift-compare mappings for web3_nonce and wallet_link.
- deps: luxwallet/connect/go and its luxfi / secp256k1 / base58 / curve25519
graph; golang.org/x/crypto 0.52.0->0.53.0 and siblings bumped by that graph.
Keyless wallet login for IAM v2 over github.com/luxwallet/connect/go — the SAME
VerifyProof the TypeScript SDK runs, so Go and TS verify identically.
- GET /v1/iam/web3/nonce mints a single-use CAIP-122 challenge; POST
/v1/iam/web3/verify verifies a signed proof and IS the login. Both are
anonymous by construction (public allowlist) — they precede any token.
- internal/wallet: HTTP shell (wallet.go) + chain-agnostic core (verify.go, no
zip.Ctx, unit-testable) + transactional store (store.go). The nonce is BURNED
before any crypto runs, so a captured proof cannot be redeemed twice; the burn
and the (chain,address) link close their races with transactions, since orm
has no conditional UPDATE or UNIQUE constraint.
- schema.Challenge (web3_nonce) binds the signed Domain for phishing defense and
re-checks Chain at verify; schema.Wallet is the (Chain,Address)->user side
table (globally unique pair), address stored exactly as the verifier
canonicalized it (EVM lowercased, case-sensitive chains trimmed only).
- oidc.MintFor/ResolveApp (mint.go): the shared tail of every interactive login
— tenant isolation, redirect binding, PKCE, code mint — so wallet sign-in
inherits the same rules as password login instead of restating them.
- authz.Optional resolves a public route's optional caller (fail-closed, nil
when anonymous or the bearer does not verify); store.GetOrganizationByName;
drift-compare mappings for web3_nonce and wallet_link.
- deps: luxwallet/connect/go and its luxfi / secp256k1 / base58 / curve25519
graph; golang.org/x/crypto 0.52.0->0.53.0 and siblings bumped by that graph.
Password VERIFICATION was already scheme-aware (argon2id for v1 rows, bcrypt
for iam2-minted rows), but new/updated passwords were hashed with bcrypt.
Switch the ONE hashing path to argon2id — the state-of-the-art scheme (PHC
winner) — so every credential iam2 mints is the strongest one, one way to
hash everywhere.
- cred.Hash: new argon2id (PHC) hasher, OWASP-aligned params (64 MiB, 2
passes, p=1); params + per-hash salt ride in the digest so Verify reads
them back. cred is the single place hashing lives.
- users.Create/Update + bootstrap.upsertUser route through cred.Hash and
stamp PasswordType=argon2id; signup inherits it via the canonical path.
- Verify stays scheme-aware, so pre-existing bcrypt/argon2id rows keep
validating — no forced reset. New rows are argon2id.
Tests updated to expect the argon2id stamp; full suite green.
Password VERIFICATION was already scheme-aware (argon2id for v1 rows, bcrypt
for iam2-minted rows), but new/updated passwords were hashed with bcrypt.
Switch the ONE hashing path to argon2id — the state-of-the-art scheme (PHC
winner) — so every credential iam2 mints is the strongest one, one way to
hash everywhere.
- cred.Hash: new argon2id (PHC) hasher, OWASP-aligned params (64 MiB, 2
passes, p=1); params + per-hash salt ride in the digest so Verify reads
them back. cred is the single place hashing lives.
- users.Create/Update + bootstrap.upsertUser route through cred.Hash and
stamp PasswordType=argon2id; signup inherits it via the canonical path.
- Verify stays scheme-aware, so pre-existing bcrypt/argon2id rows keep
validating — no forced reset. New rows are argon2id.
Tests updated to expect the argon2id stamp; full suite green.
Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
Red-team found a critical privilege escalation in the v0.15.0 federation
broker: social login could mint a SuperAdmin (or take over a cross-tenant
account). Two root causes, both closed:
F1(A) — Application.Organization was attacker-controlled and unauthorized:
apps Create/Update copied Organization from the body while the authz seam
gated only the top-level Owner. Add authz.CanSetOrg (one policy: super may
set any org; anyone else only their own, never a reserved platform org or
another tenant) and enforce it on the Organization field at app write.
F1(B) — federation link/provision had no reserved-org guard: it minted the
IdP identity into app.Organization with no check, so Organization="admin"
yielded Owner="admin" = SuperAdmin. Add the reserved-org refusal (never
admin/built-in/app) + the login.go-style app-org-legitimacy check to
linkOrProvision/provisionFederatedUser — re-asserted at the mint boundary.
F2 — SSRF: requireSafeURL permitted any https host (private/link-local/
169.254.169.254/ULA) and http-loopback. Now refuses private/loopback/
link-local/metadata addresses AT DIAL TIME (after DNS resolution), so a
tenant-writable IssuerUrl/Custom*Url can't drive an internal fetch.
Red's 3 PoCs are added and now REFUSE (federation refused, no admin-org
user provisioned, linkOrProvision errors); a legitimate platform-app-for-a-
tenant case and the SSRF-refusal case are covered. Full suite green.
Red-team found a critical privilege escalation in the v0.15.0 federation
broker: social login could mint a SuperAdmin (or take over a cross-tenant
account). Two root causes, both closed:
F1(A) — Application.Organization was attacker-controlled and unauthorized:
apps Create/Update copied Organization from the body while the authz seam
gated only the top-level Owner. Add authz.CanSetOrg (one policy: super may
set any org; anyone else only their own, never a reserved platform org or
another tenant) and enforce it on the Organization field at app write.
F1(B) — federation link/provision had no reserved-org guard: it minted the
IdP identity into app.Organization with no check, so Organization="admin"
yielded Owner="admin" = SuperAdmin. Add the reserved-org refusal (never
admin/built-in/app) + the login.go-style app-org-legitimacy check to
linkOrProvision/provisionFederatedUser — re-asserted at the mint boundary.
F2 — SSRF: requireSafeURL permitted any https host (private/link-local/
169.254.169.254/ULA) and http-loopback. Now refuses private/loopback/
link-local/metadata addresses AT DIAL TIME (after DNS resolution), so a
tenant-writable IssuerUrl/Custom*Url can't drive an internal fetch.
Red's 3 PoCs are added and now REFUSE (federation refused, no admin-org
user provisioned, linkOrProvision errors); a legitimate platform-app-for-a-
tenant case and the SSRF-refusal case are covered. Full suite green.
Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
The || fallback chain resolved to a token that cannot read the private transitive
dep hanzoai/dbx (via hanzoai/orm) — go mod download 404'd on it. Pin GH_PAT (the
org admin:org+write:packages secret hanzoai/cloud uses to fetch the SAME private
cross-repo modules) directly, for both the ghcr login and the GIT_AUTH_TOKEN build
secret. No fallback: the automatic GITHUB_TOKEN only reads its own repo, so it can
never fetch dbx.
The || fallback chain resolved to a token that cannot read the private transitive
dep hanzoai/dbx (via hanzoai/orm) — go mod download 404'd on it. Pin GH_PAT (the
org admin:org+write:packages secret hanzoai/cloud uses to fetch the SAME private
cross-repo modules) directly, for both the ghcr login and the GIT_AUTH_TOKEN build
secret. No fallback: the automatic GITHUB_TOKEN only reads its own repo, so it can
never fetch dbx.
The build used runs-on: ubuntu-latest — GitHub-hosted, which is billing-frozen for
the org, so the run never STARTED and thus never produced an image (the earlier
failures were the frozen queue, not the build). Route to hanzo-build-linux-amd64,
the ARC self-hosted scale set every working hanzo build uses. Combined with the
private-dep + ghcr-write fixes, this build can finally publish ghcr.io/hanzoai/iam2.
The build used runs-on: ubuntu-latest — GitHub-hosted, which is billing-frozen for
the org, so the run never STARTED and thus never produced an image (the earlier
failures were the frozen queue, not the build). Route to hanzo-build-linux-amd64,
the ARC self-hosted scale set every working hanzo build uses. Combined with the
private-dep + ghcr-write fixes, this build can finally publish ghcr.io/hanzoai/iam2.
Every iam2 image build has failed, so no image was ever published and iam2 could
not be rolled. Two causes, both fixed by mirroring hanzoai/cloud's proven pattern:
* go mod download had no credentials for iam2's PRIVATE modules (hanzoai/orm,
hanzoai/sqlite), so it failed before compiling. The Dockerfile now sets
GOPRIVATE=github.com/hanzoai/* and mounts a GIT_AUTH_TOKEN secret to rewrite
github.com to an authenticated fetch; the workflow passes the token as that
build secret. Without a token it is a no-op, so a public build still works.
* The automatic GITHUB_TOKEN is denied write to ghcr.io/hanzoai/* — the same
permission_denied: write_package cloud hit. Add permissions: packages: write
and prefer GH_PAT for the registry login.
A green build here publishes ghcr.io/hanzoai/iam2:<tag>, the prerequisite for the
iam2 canary deployment.
Every iam2 image build has failed, so no image was ever published and iam2 could
not be rolled. Two causes, both fixed by mirroring hanzoai/cloud's proven pattern:
* go mod download had no credentials for iam2's PRIVATE modules (hanzoai/orm,
hanzoai/sqlite), so it failed before compiling. The Dockerfile now sets
GOPRIVATE=github.com/hanzoai/* and mounts a GIT_AUTH_TOKEN secret to rewrite
github.com to an authenticated fetch; the workflow passes the token as that
build secret. Without a token it is a no-op, so a public build still works.
* The automatic GITHUB_TOKEN is denied write to ghcr.io/hanzoai/* — the same
permission_denied: write_package cloud hit. Add permissions: packages: write
and prefer GH_PAT for the registry login.
A green build here publishes ghcr.io/hanzoai/iam2:<tag>, the prerequisite for the
iam2 canary deployment.
The shadow-canary deploy in prod surfaced this: init_data seeds signing
certs (owner=admin, RS256) with NO key material — a signing key cannot ride
the init_data.json ConfigMap (it's a secret). iam2's JWKS filters to certs
that carry key material, so it published {keys:[]} → iam2 could neither sign
tokens nor be trusted by relying parties. The legacy Beego iam avoids this
by generating+persisting a keypair on first boot; iam2 didn't.
Fix: at seed time, for a reserved-org (JWKS-eligible) signing cert with a
recognized algorithm and no key material, generate a keypair (RSA for
RS256/512, ECDSA for ES256/384/512) and PEM-encode the private half — the
same first-boot provisioning the legacy iam does. Gated to reserved-org
certs; SSL, tenant-owned, ML-DSA/unrecognized, and already-keyed certs are
left untouched. Safe every boot: the key is minted in memory and the
new-only upsert persists it exactly once, so a re-seed keeps the existing
row (stable kid + key, no rotation).
Tests: keyless reserved RS256/ES256 certs get parseable keys; SSL + tenant
certs stay keyless; an explicit key is never overwritten.
The shadow-canary deploy in prod surfaced this: init_data seeds signing
certs (owner=admin, RS256) with NO key material — a signing key cannot ride
the init_data.json ConfigMap (it's a secret). iam2's JWKS filters to certs
that carry key material, so it published {keys:[]} → iam2 could neither sign
tokens nor be trusted by relying parties. The legacy Beego iam avoids this
by generating+persisting a keypair on first boot; iam2 didn't.
Fix: at seed time, for a reserved-org (JWKS-eligible) signing cert with a
recognized algorithm and no key material, generate a keypair (RSA for
RS256/512, ECDSA for ES256/384/512) and PEM-encode the private half — the
same first-boot provisioning the legacy iam does. Gated to reserved-org
certs; SSL, tenant-owned, ML-DSA/unrecognized, and already-keyed certs are
left untouched. Safe every boot: the key is minted in memory and the
new-only upsert persists it exactly once, so a re-seed keeps the existing
row (stable kid + key, no rotation).
Tests: keyless reserved RS256/ES256 certs get parseable keys; SSL + tenant
certs stay keyless; an explicit key is never overwritten.
Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
iam2 completes Google/GitHub (and the existing provider set) social sign-in as a
standard OIDC/OAuth2 Relying Party — the one remaining login-backend gap before
hanzo.id cuts over from Casdoor. No Casdoor verbs, no tokens-in-query, no legacy
/oauth/* paths (HIP-0111).
- authorize `?provider=<name>` (after client_id + EXACT redirect_uri + PKCE
validation) stashes the app-leg request in a single-use, expiring,
browser-bound FederationState and 302s to the IdP with an IdP-leg S256 PKCE
verifier and (OIDC) a nonce.
- fixed public callback /v1/iam/oauth/callback burns the transaction (expiry +
browser-binding cookie), exchanges the IdP code, and VERIFIES the response:
OIDC id_token signature (discovered JWKS, alg pinned to RS/ES), iss, aud, exp,
nonce; GitHub userinfo + verified primary email.
- links by provider subject, else by VERIFIED email, else provisions a new user
(never isAdmin, no password); mints iam2's own PKCE/redirect/nonce-bound
authorization code so the SPA's existing code->token exchange is unchanged.
- new schema.FederationState entity + store helpers + a connector registry that
passes the EXACT lowercase json field name (dodging orm's LowercaseFirst
footgun that would break the GitHub connector lookup).
Fail-closed on every path; no IdP tokens persisted; no secrets logged. 15 new
tests drive the real mounted routes against httptest mock IdPs (real discovery/
JWKS/RS256 id_token + GitHub userinfo, no live calls): happy path + code
exchange, link-by-verified-email, relogin-by-subject no-dup, unknown/replayed
state, missing/wrong browser cookie, nonce/signature/alg-none/audience
rejection, unverified-email no-autolink, non-allowlisted redirect refused.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
iam2 completes Google/GitHub (and the existing provider set) social sign-in as a
standard OIDC/OAuth2 Relying Party — the one remaining login-backend gap before
hanzo.id cuts over from Casdoor. No Casdoor verbs, no tokens-in-query, no legacy
/oauth/* paths (HIP-0111).
- authorize `?provider=<name>` (after client_id + EXACT redirect_uri + PKCE
validation) stashes the app-leg request in a single-use, expiring,
browser-bound FederationState and 302s to the IdP with an IdP-leg S256 PKCE
verifier and (OIDC) a nonce.
- fixed public callback /v1/iam/oauth/callback burns the transaction (expiry +
browser-binding cookie), exchanges the IdP code, and VERIFIES the response:
OIDC id_token signature (discovered JWKS, alg pinned to RS/ES), iss, aud, exp,
nonce; GitHub userinfo + verified primary email.
- links by provider subject, else by VERIFIED email, else provisions a new user
(never isAdmin, no password); mints iam2's own PKCE/redirect/nonce-bound
authorization code so the SPA's existing code->token exchange is unchanged.
- new schema.FederationState entity + store helpers + a connector registry that
passes the EXACT lowercase json field name (dodging orm's LowercaseFirst
footgun that would break the GitHub connector lookup).
Fail-closed on every path; no IdP tokens persisted; no secrets logged. 15 new
tests drive the real mounted routes against httptest mock IdPs (real discovery/
JWKS/RS256 id_token + GitHub userinfo, no live calls): happy path + code
exchange, link-by-verified-email, relogin-by-subject no-dup, unknown/replayed
state, missing/wrong browser cookie, nonce/signature/alg-none/audience
rejection, unverified-email no-autolink, non-allowlisted redirect refused.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add server.NewApp(db) + server.Handler(db) — build the whole IAM v2 surface
as one self-contained zip.App and adapt it to a net/http handler. This is
the drop-in shape hanzoai/cloud uses to swap the legacy Beego IAM catch-all
for iam2: mounted behind the /v1/iam/* (and root /.well-known/*) wildcards,
the specific self-service routes the account fold layers in front still win
by Fiber specificity, so the swap is collision-free — identical topology to
the Beego mount it replaces.
Add server.NewApp(db) + server.Handler(db) — build the whole IAM v2 surface
as one self-contained zip.App and adapt it to a net/http handler. This is
the drop-in shape hanzoai/cloud uses to swap the legacy Beego IAM catch-all
for iam2: mounted behind the /v1/iam/* (and root /.well-known/*) wildcards,
the specific self-service routes the account fold layers in front still win
by Fiber specificity, so the swap is collision-free — identical topology to
the Beego mount it replaces.
Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
Add the `projects` entity (v2 kind, org-owned like users/roles) with the
full typed REST CRUD (internal/projects) plus the three Casdoor verbs the
console ScopeSwitcher / Projects page hard-codes through the /org/iam proxy:
- get-organization-projects (?organization=, handler-authorized: any org
member may list its org's projects — the switcher is shown to everyone,
not only admins — scoped by authz.Scope so no param widens the tenant)
- add-project / delete-project (typed ops → app.Authorize gates the write
to an org-admin of that org, the same clause as add-role)
CRUD lives once in internal/projects; the verb aliases reuse it via New —
no CRUD reimplemented in compat. get-organization-projects is registered in
authz.handlerAuthorizedPrefixes because its target rides in ?organization=,
which the Guard cannot pre-authorize generically (the read analogue of
SCIM's path-targeted authorization).
Tests drive the real router: lifecycle (add → member-list → delete), the
write-needs-admin gate (regular user 403 on write, 200 on list), and
cross-tenant scoping (a hanzo user asking ?organization=orgb never sees
orgb's projects).
Add the `projects` entity (v2 kind, org-owned like users/roles) with the
full typed REST CRUD (internal/projects) plus the three Casdoor verbs the
console ScopeSwitcher / Projects page hard-codes through the /org/iam proxy:
- get-organization-projects (?organization=, handler-authorized: any org
member may list its org's projects — the switcher is shown to everyone,
not only admins — scoped by authz.Scope so no param widens the tenant)
- add-project / delete-project (typed ops → app.Authorize gates the write
to an org-admin of that org, the same clause as add-role)
CRUD lives once in internal/projects; the verb aliases reuse it via New —
no CRUD reimplemented in compat. get-organization-projects is registered in
authz.handlerAuthorizedPrefixes because its target rides in ?organization=,
which the Guard cannot pre-authorize generically (the read analogue of
SCIM's path-targeted authorization).
Tests drive the real router: lifecycle (add → member-list → delete), the
write-needs-admin gate (regular user 403 on write, 200 on list), and
cross-tenant scoping (a hanzo user asking ?organization=orgb never sees
orgb's projects).
Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
Port the account security page's MFA flow to iam2: initiate → verify →
enable, plus delete-mfa and set-preferred-mfa. Serves the console's
existing /v1/iam/mfa/setup/{initiate,verify,enable} + /v1/iam/delete-mfa +
/v1/iam/set-preferred-mfa contract.
Enrollment is self-service, mounted AFTER the Guard so it acts on the
authenticated caller's own user (authz.From). The handshake is stateless:
initiate mints a TOTP secret + otpauth URL + recovery code the client
holds; verify checks a passcode against the echoed secret; enable commits
it to the user row. Touching another user's MFA requires admin authority
over that org (authz.Can) — the same seam SCIM writes use — because the
general user-write policy correctly refuses a non-admin writing a user row.
Tests drive the real mounted router: full enroll lifecycle (a generated
TOTP code verifies and persists), bad-code rejection, the cross-user admin
gate (regular user 403, org-admin/super 200), and bearer-required.
Port the account security page's MFA flow to iam2: initiate → verify →
enable, plus delete-mfa and set-preferred-mfa. Serves the console's
existing /v1/iam/mfa/setup/{initiate,verify,enable} + /v1/iam/delete-mfa +
/v1/iam/set-preferred-mfa contract.
Enrollment is self-service, mounted AFTER the Guard so it acts on the
authenticated caller's own user (authz.From). The handshake is stateless:
initiate mints a TOTP secret + otpauth URL + recovery code the client
holds; verify checks a passcode against the echoed secret; enable commits
it to the user row. Touching another user's MFA requires admin authority
over that org (authz.Can) — the same seam SCIM writes use — because the
general user-write policy correctly refuses a non-admin writing a user row.
Tests drive the real mounted router: full enroll lifecycle (a generated
TOTP code verifies and persists), bad-code rejection, the cross-user admin
gate (regular user 403, org-admin/super 200), and bearer-required.
Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
Parity audit C5: the K8s operator (operator-core/src/iam_admin.rs) reconciles an IAM
CR's spec.applications[]/users[] by POSTing to /v1/iam/admin/{applications,users}/upsert
— wiring the service-account OAuth apps KMS/signers authenticate with, no human admin.
iam2 didn't serve these, so an embed would lose IAM reconciliation.
New internal/bootstrap: both idempotent upsert endpoints, keyed by the natural key
(apps: admin/name; users: owner/name) — create OR update, so a ~30s reconcile is a
no-op once converged. Auth is the unified SERVICE TOKEN presented as Bearer,
validated constant-time against HANZO_API_KEY / KMS_SERVICE_TOKEN / IAM_SERVICE_TOKEN
(the same pipeline the old iam used); unset → fail closed. Registered on the PUBLIC
group (self-authenticated, not a bearer principal). Response matches operator-core's
parser: {status:"ok", action:"created"|"updated", data:{...}}. A missing clientSecret
preserves the existing one (no rotation storm); user passwords are bcrypt-hashed.
Tests: app create→idempotent-update (secret preserved), user create (password hashed),
service-token required (no/wrong token → 401).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Parity audit C5: the K8s operator (operator-core/src/iam_admin.rs) reconciles an IAM
CR's spec.applications[]/users[] by POSTing to /v1/iam/admin/{applications,users}/upsert
— wiring the service-account OAuth apps KMS/signers authenticate with, no human admin.
iam2 didn't serve these, so an embed would lose IAM reconciliation.
New internal/bootstrap: both idempotent upsert endpoints, keyed by the natural key
(apps: admin/name; users: owner/name) — create OR update, so a ~30s reconcile is a
no-op once converged. Auth is the unified SERVICE TOKEN presented as Bearer,
validated constant-time against HANZO_API_KEY / KMS_SERVICE_TOKEN / IAM_SERVICE_TOKEN
(the same pipeline the old iam used); unset → fail closed. Registered on the PUBLIC
group (self-authenticated, not a bearer principal). Response matches operator-core's
parser: {status:"ok", action:"created"|"updated", data:{...}}. A missing clientSecret
preserves the existing one (no rotation storm); user passwords are bcrypt-hashed.
Tests: app create→idempotent-update (secret preserved), user create (password hashed),
service-token required (no/wrong token → 401).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Parity audit C2: the console admin BFF forwards literal Casdoor verbs, but compat
served only add-organization/add-user/update-user/update-application — so the
console's Users/Roles/Providers/Apps/Tenants admin MUTATIONS all 404'd. Added the
missing verb aliases over the SAME entity CRUD the REST routes use (one path,
wrapped in the casibase {status,data} envelope; each a TYPED zip.Post so the ONE
app.Authorize seam authorizes the decoded target — no CRUD reimplemented):
delete-user, add-/delete-application, add-/update-/delete-provider,
add-/update-/delete-role, update-/delete-organization.
Minimal exports to reuse the entity logic: applications.Delete, roles.New,
providers.Add/Update/Delete. Tests: delete-user lifecycle, add-provider (super-only,
non-super 403 — platform-owned), add-role (org-admin, tenant-owned), update-org.
With C1 (issue-user-token) the console's authenticated + admin surface is now fully
served by iam2.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Parity audit C2: the console admin BFF forwards literal Casdoor verbs, but compat
served only add-organization/add-user/update-user/update-application — so the
console's Users/Roles/Providers/Apps/Tenants admin MUTATIONS all 404'd. Added the
missing verb aliases over the SAME entity CRUD the REST routes use (one path,
wrapped in the casibase {status,data} envelope; each a TYPED zip.Post so the ONE
app.Authorize seam authorizes the decoded target — no CRUD reimplemented):
delete-user, add-/delete-application, add-/update-/delete-provider,
add-/update-/delete-role, update-/delete-organization.
Minimal exports to reuse the entity logic: applications.Delete, roles.New,
providers.Add/Update/Delete. Tests: delete-user lifecycle, add-provider (super-only,
non-super 403 — platform-owned), add-role (org-admin, tenant-owned), update-org.
With C1 (issue-user-token) the console's authenticated + admin surface is now fully
served by iam2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Parity audit found the P0 gap: the console's ENTIRE authenticated surface calls
POST /v1/iam/issue-user-token (identity.ts issueUserToken → adminBearer → the
bearer-proxy behind every /v1/* BFF call + the /admin/iam,/org/iam,/admin/kms,/ai
proxies). v0.7.0 retired that verb for the RFC 8693 token-exchange grant, but the
console has ZERO token-exchange usage — so the swap would take the whole console dark.
Restored as a COMPAT SHIM over the exact same machinery token-exchange uses
(authorizeMinter allow-list + reserved-org gate + SignUserToken + audit): a
confidential, allow-listed client mints a token bound to the ?id=<owner>/<name>
target user (optional ?aud= resource), returning {accessToken, expiresIn}. Same
authority, same red-hardened controls; equivalent to token-exchange minus the
subject_token proof (the console has the user's id, not a token — the reason the
shim exists). RFC 8693 stays the canonical forward path; the console migrates to it
and this shim retires. Tests: mints the target user's verifiable token, off-allowlist
403.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Parity audit found the P0 gap: the console's ENTIRE authenticated surface calls
POST /v1/iam/issue-user-token (identity.ts issueUserToken → adminBearer → the
bearer-proxy behind every /v1/* BFF call + the /admin/iam,/org/iam,/admin/kms,/ai
proxies). v0.7.0 retired that verb for the RFC 8693 token-exchange grant, but the
console has ZERO token-exchange usage — so the swap would take the whole console dark.
Restored as a COMPAT SHIM over the exact same machinery token-exchange uses
(authorizeMinter allow-list + reserved-org gate + SignUserToken + audit): a
confidential, allow-listed client mints a token bound to the ?id=<owner>/<name>
target user (optional ?aud= resource), returning {accessToken, expiresIn}. Same
authority, same red-hardened controls; equivalent to token-exchange minus the
subject_token proof (the console has the user's id, not a token — the reason the
shim exists). RFC 8693 stays the canonical forward path; the console migrates to it
and this shim retires. Tests: mints the target user's verifiable token, off-allowlist
403.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
internal/e2e boots the WHOLE mounted router (routes.Route) and drives the real
client flows in sequence, asserting the response contract each depends on — the
proof that the old Casdoor IAM's clients work against iam2, beyond the per-package
unit tests:
- OIDC: discovery (self-consistent + RFC 7662/7009/8414 endpoints advertised) →
JWKS → PKCE login → authorization_code → userinfo (owner+isAdmin) → introspect
(active) → revoke (→ inactive).
- Non-interactive grants: password (RFC 6749 §4.3) + RFC 8693 token exchange.
- Admin console Casdoor surface: get-account (security contract), get-organizations
(OrgSwitcher, SuperAdmin sees all), get-users (owner-scoped, no secret leak).
- SCIM 2.0 provisioning: create → get → delete.
All green. This harness also verifies any parity gap-fixes going forward.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
internal/e2e boots the WHOLE mounted router (routes.Route) and drives the real
client flows in sequence, asserting the response contract each depends on — the
proof that the old Casdoor IAM's clients work against iam2, beyond the per-package
unit tests:
- OIDC: discovery (self-consistent + RFC 7662/7009/8414 endpoints advertised) →
JWKS → PKCE login → authorization_code → userinfo (owner+isAdmin) → introspect
(active) → revoke (→ inactive).
- Non-interactive grants: password (RFC 6749 §4.3) + RFC 8693 token exchange.
- Admin console Casdoor surface: get-account (security contract), get-organizations
(OrgSwitcher, SuperAdmin sees all), get-users (owner-scoped, no secret leak).
- SCIM 2.0 provisioning: create → get → delete.
All green. This harness also verifies any parity gap-fixes going forward.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the group refactor: the flat Mount(app *zip.App, db) convention is
gone from internal/*. Every registration func is now Route, mirroring commerce's
Route(zip.Router) idiom:
- 13 entity CRUD packages + compat + scim: Mount → Route (still *App — the
typed-op projection into REST/OpenAPI/MCP needs it).
- internal helpers organizations.mount → route, compat.mountWrites → routeWrites.
- the route table internal/routes: Mount → Route (server.Mount embeds it).
server.Mount(app, db) stays the ONE Mount — the frozen public embed entry the
host (cloud) calls; its signature is unchanged, only its body now calls
routes.Route. feature.Mount(app, store) is the feature-seam interface method (a
different signature), untouched.
Pure rename — no route, path, behavior, or auth outcome changes. Callers updated
(main.go, server.go, authz/compat/scim tests). gofmt clean, go build ./... green,
go test ./... green. feature/, pkg/model/, internal/featurestore/ untouched.
Completes the group refactor: the flat Mount(app *zip.App, db) convention is
gone from internal/*. Every registration func is now Route, mirroring commerce's
Route(zip.Router) idiom:
- 13 entity CRUD packages + compat + scim: Mount → Route (still *App — the
typed-op projection into REST/OpenAPI/MCP needs it).
- internal helpers organizations.mount → route, compat.mountWrites → routeWrites.
- the route table internal/routes: Mount → Route (server.Mount embeds it).
server.Mount(app, db) stays the ONE Mount — the frozen public embed entry the
host (cloud) calls; its signature is unchanged, only its body now calls
routes.Route. feature.Mount(app, store) is the feature-seam interface method (a
different signature), untouched.
Pure rename — no route, path, behavior, or auth outcome changes. Callers updated
(main.go, server.go, authz/compat/scim tests). gofmt clean, go build ./... green,
go test ./... green. feature/, pkg/model/, internal/featurestore/ untouched.
Auth is now decided by WHICH GROUP a route is registered on, not a hand-
maintained allow-list. routes.Mount wires two phases around one seam:
- PUBLIC group (app.Group("") + oidc.Route + /healthz) registered BEFORE
app.Use(authz.Guard): a matched public route terminates fiber's middleware
walk, so the Guard never runs on it. Membership here IS "public".
- app.Use(authz.Guard) is the ONE authentication seam; every route after it
(typed entity CRUD, Casdoor verb aliases, SCIM, and the framework's /mcp +
/openapi projections) requires a verified bearer.
A public route can no longer be accidentally gated, nor an authed route
accidentally public — the front-door bug the old publicPaths list had to patch
is structurally impossible.
- Delete authz.publicPaths + isPublic entirely; drop the isPublic checks from
Guard and Authorize (every typed op is authed by construction). Keep Can/
IsSuper/pathAuthorized (SCIM) intact.
- oidc.Mount(*App) → oidc.Route(zip.Router); MountToken/Login/FrontDoor/
IntrospectRevoke/IssueToken → unexported route*(zip.Router) helpers on the
public group. Absolute paths preserved (root + /v1/iam), so every URL is
byte-identical (discovery, JWKS, AS-metadata, oauth/*, login, front door,
introspect/revoke, key minters).
- Replace the isPublic unit test with an end-to-end structural boundary proof:
TestPublicRoutesNeedNoBearer extended to the full front door + AS metadata;
TestUnauthenticatedWriteIs401 / TestCrossOrgWriteIs403 /
TestFrameworkSideDoorsAreGated prove authed + /mcp + /openapi stay gated.
server.Mount, feature/, pkg/model/, internal/featurestore/ untouched.
gofmt clean, go build ./... green, go test ./... green.
Auth is now decided by WHICH GROUP a route is registered on, not a hand-
maintained allow-list. routes.Mount wires two phases around one seam:
- PUBLIC group (app.Group("") + oidc.Route + /healthz) registered BEFORE
app.Use(authz.Guard): a matched public route terminates fiber's middleware
walk, so the Guard never runs on it. Membership here IS "public".
- app.Use(authz.Guard) is the ONE authentication seam; every route after it
(typed entity CRUD, Casdoor verb aliases, SCIM, and the framework's /mcp +
/openapi projections) requires a verified bearer.
A public route can no longer be accidentally gated, nor an authed route
accidentally public — the front-door bug the old publicPaths list had to patch
is structurally impossible.
- Delete authz.publicPaths + isPublic entirely; drop the isPublic checks from
Guard and Authorize (every typed op is authed by construction). Keep Can/
IsSuper/pathAuthorized (SCIM) intact.
- oidc.Mount(*App) → oidc.Route(zip.Router); MountToken/Login/FrontDoor/
IntrospectRevoke/IssueToken → unexported route*(zip.Router) helpers on the
public group. Absolute paths preserved (root + /v1/iam), so every URL is
byte-identical (discovery, JWKS, AS-metadata, oauth/*, login, front door,
introspect/revoke, key minters).
- Replace the isPublic unit test with an end-to-end structural boundary proof:
TestPublicRoutesNeedNoBearer extended to the full front door + AS metadata;
TestUnauthenticatedWriteIs401 / TestCrossOrgWriteIs403 /
TestFrameworkSideDoorsAreGated prove authed + /mcp + /openapi stay gated.
server.Mount, feature/, pkg/model/, internal/featurestore/ untouched.
gofmt clean, go build ./... green, go test ./... green.
feature.Store gains GetProvider(owner,name) over the core's store.GetProvider,
and pkg/model aliases schema.Provider. Unblocks hanzoiam/saml SP-initiated login
(Hanzo as Service Provider to an external IdP). Completes the seam: the union of
what SCIM/SAML/LDAP need.
feature.Store gains GetProvider(owner,name) over the core's store.GetProvider,
and pkg/model aliases schema.Provider. Unblocks hanzoiam/saml SP-initiated login
(Hanzo as Service Provider to an external IdP). Completes the seam: the union of
what SCIM/SAML/LDAP need.
feature.Store gains SetPassword (core hashes plaintext once, discards it) and
VerifyPassword (argon2id v1 / bcrypt v2, per the org's password type). Hashing
and verification stay in the ONE place (internal/users) — enterprise modules
never see a digest. Unblocks hanzoiam/ldap bind + hanzoiam/scim password attr.
feature.Store gains SetPassword (core hashes plaintext once, discards it) and
VerifyPassword (argon2id v1 / bcrypt v2, per the org's password type). Hashing
and verification stay in the ONE place (internal/users) — enterprise modules
never see a digest. Unblocks hanzoiam/ldap bind + hanzoiam/scim password attr.
Document the RFC-standard endpoint surface (OAuth grants incl. token-exchange,
introspection/revocation, AS-metadata, UserInfo=get-account contract, SCIM 2.0),
the deploy env, and the remaining client-migration for cutover.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Document the RFC-standard endpoint surface (OAuth grants incl. token-exchange,
introspection/revocation, AS-metadata, UserInfo=get-account contract, SCIM 2.0),
the deploy env, and the remaining client-migration for cutover.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OIDC UserInfo (/v1/iam/oauth/userinfo) now emits `isAdmin` (the gateway
admin-guard's SuperAdmin-predicate input, with owner==adminOrg) and `type` (the
console's anonymous-user check), read from the loaded user record — authoritative,
never a token claim, matching the authz Principal. Emitted regardless of scope
(identity, not profile), as an EXPLICIT bool so the admin-guard reads a definite
false rather than inferring it from a missing key.
This makes standard OIDC UserInfo a drop-in for the retired Casdoor get-account
security contract (HIP-0111): a consumer reads sub/owner/organization/email/
email_verified/isAdmin/type off the RFC endpoint, so the gateway admin-guard +
console resolveUser can migrate off get-account to /oauth/userinfo. (The migration
of those clients is the follow-on; UserInfo now carries what they need.)
Tests: admin-guard contract (isAdmin:true + type present at openid-only scope),
non-admin explicit-false, existing scope-gating unaffected.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
OIDC UserInfo (/v1/iam/oauth/userinfo) now emits `isAdmin` (the gateway
admin-guard's SuperAdmin-predicate input, with owner==adminOrg) and `type` (the
console's anonymous-user check), read from the loaded user record — authoritative,
never a token claim, matching the authz Principal. Emitted regardless of scope
(identity, not profile), as an EXPLICIT bool so the admin-guard reads a definite
false rather than inferring it from a missing key.
This makes standard OIDC UserInfo a drop-in for the retired Casdoor get-account
security contract (HIP-0111): a consumer reads sub/owner/organization/email/
email_verified/isAdmin/type off the RFC endpoint, so the gateway admin-guard +
console resolveUser can migrate off get-account to /oauth/userinfo. (The migration
of those clients is the follow-on; UserInfo now carries what they need.)
Tests: admin-guard contract (isAdmin:true + type present at openid-only scope),
non-admin explicit-false, existing scope-gating unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Red review of v0.8.0 found the SCIM write path never reached authz.Authorize (the
op-invoke seam where the admin/self policy lives) — SCIM registers RAW handlers and
calls users.API directly, so the only authz was authz.Scope (pins the org, NOT the
admin flag). Proven exploits (all now closed, red's 6 proof tests green):
- a regular org member self-promoted to org-admin in one PUT (isAdmin extension)
- reset an org-admin's password via PATCH → full admin account takeover
- created/deleted users; a machine token (no user row) got full user-write
Plus a HIGH (PUT/PATCH rebuilt from a blank record → stripped MFA enrollment +
resurrected soft-deleted accounts), MEDIUMs (count<=0 → orm Limit(0) → unbounded
dump; client-supplied isAdmin), a LOW (raw err.Error() on 500).
Fixes (one policy, one place):
- authz.Can(ctx, method, entity, owner, name) exposes the SAME predicate the op
seam applies, for raw handlers. Every SCIM write (POST/PUT/PATCH/DELETE) and the
list/get reads now gate through it → regular user 403, org-admin/super admitted.
authz.IsSuper gates the privileged isAdmin field (provision-don't-promote: only a
super may set it).
- PUT/PATCH now read-modify-write the FULL current row (overlay mapped attrs onto
cur), preserving MFA/IsDeleted/Type/Groups/Ldap — never rebuilt from blank.
- count<=0 clamps to the default page size (never unbounded).
- active is *bool (RFC 7643 default-true when omitted); generic 500 (no detail).
Tests: red's red_bypass_test.go (6, all green) + regression matrix (org-admin
allowed, isAdmin super-only, PATCH preserves MFA, PUT no-resurrect/no-MFA-strip).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Red review of v0.8.0 found the SCIM write path never reached authz.Authorize (the
op-invoke seam where the admin/self policy lives) — SCIM registers RAW handlers and
calls users.API directly, so the only authz was authz.Scope (pins the org, NOT the
admin flag). Proven exploits (all now closed, red's 6 proof tests green):
- a regular org member self-promoted to org-admin in one PUT (isAdmin extension)
- reset an org-admin's password via PATCH → full admin account takeover
- created/deleted users; a machine token (no user row) got full user-write
Plus a HIGH (PUT/PATCH rebuilt from a blank record → stripped MFA enrollment +
resurrected soft-deleted accounts), MEDIUMs (count<=0 → orm Limit(0) → unbounded
dump; client-supplied isAdmin), a LOW (raw err.Error() on 500).
Fixes (one policy, one place):
- authz.Can(ctx, method, entity, owner, name) exposes the SAME predicate the op
seam applies, for raw handlers. Every SCIM write (POST/PUT/PATCH/DELETE) and the
list/get reads now gate through it → regular user 403, org-admin/super admitted.
authz.IsSuper gates the privileged isAdmin field (provision-don't-promote: only a
super may set it).
- PUT/PATCH now read-modify-write the FULL current row (overlay mapped attrs onto
cur), preserving MFA/IsDeleted/Type/Groups/Ldap — never rebuilt from blank.
- count<=0 clamps to the default page size (never unbounded).
- active is *bool (RFC 7643 default-true when omitted); generic 500 (no detail).
Tests: red's red_bypass_test.go (6, all green) + regression matrix (org-admin
allowed, isAdmin super-only, PATCH preserves MFA, PUT no-resurrect/no-MFA-strip).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per HIP-0111 (RFC-only), identity provisioning is SCIM 2.0 — the replacement for the
Casdoor entity verbs (get-users/get-user/add-user/update-user/delete-user). New
internal/scim serves /v1/iam/scim/v2:
- Users: GET list (owner-scoped, filter `userName|emails eq "x"`, startIndex/count
pagination, ListResponse envelope), POST create (password write-only → hashed via
the canonical users.API), GET/PUT/PATCH/DELETE on the two-segment item path
{owner}/{name} (the SCIM id is the natural key "owner/name", appended verbatim by
clients — no slash-in-id encoding trap). PATCH implements the RFC 7644 §3.5.2 op
subset (add/replace/remove on active/displayName/password/name.*/emails/phones,
plus path-less merge) — read-modify-write so a partial change never blanks the row.
- ServiceProviderConfig advertises patch+filter+changePassword; SCIM Error envelope
with scimType; application/scim+json handled content-type-independently.
- SECURITY: every user projected through schema.User.Mask() (no hash/secret ever
crosses a response); password is write-only. Authorization: the SCIM subtree is a
new "path-authorized" category in the Guard — authenticated (bearer required) but
the handler scopes via authz.Scope on the PATH target (SCIM ids ride the path, not
the query), so a non-super is pinned to its own org and can't reach another tenant
by spelling its id.
Tests (real router): create→get→delete lifecycle, ListResponse + owner-scoping
(super sees all, org-admin own-org-only), userName filter, PATCH deactivate, no
secret leak, cross-tenant re-scope→404 (no leak), requires-auth, SPConfig.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Per HIP-0111 (RFC-only), identity provisioning is SCIM 2.0 — the replacement for the
Casdoor entity verbs (get-users/get-user/add-user/update-user/delete-user). New
internal/scim serves /v1/iam/scim/v2:
- Users: GET list (owner-scoped, filter `userName|emails eq "x"`, startIndex/count
pagination, ListResponse envelope), POST create (password write-only → hashed via
the canonical users.API), GET/PUT/PATCH/DELETE on the two-segment item path
{owner}/{name} (the SCIM id is the natural key "owner/name", appended verbatim by
clients — no slash-in-id encoding trap). PATCH implements the RFC 7644 §3.5.2 op
subset (add/replace/remove on active/displayName/password/name.*/emails/phones,
plus path-less merge) — read-modify-write so a partial change never blanks the row.
- ServiceProviderConfig advertises patch+filter+changePassword; SCIM Error envelope
with scimType; application/scim+json handled content-type-independently.
- SECURITY: every user projected through schema.User.Mask() (no hash/secret ever
crosses a response); password is write-only. Authorization: the SCIM subtree is a
new "path-authorized" category in the Guard — authenticated (bearer required) but
the handler scopes via authz.Scope on the PATH target (SCIM ids ride the path, not
the query), so a non-super is pinned to its own org and can't reach another tenant
by spelling its id.
Tests (real router): create→get→delete lifecycle, ListResponse + owner-scoping
(super sees all, org-admin own-org-only), userName filter, PATCH deactivate, no
secret leak, cross-tenant re-scope→404 (no leak), requires-auth, SPConfig.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per HIP-0111 (RFC-standard only), the Casdoor issue-user-token verb is replaced by
the standard OAuth 2.0 Token Exchange grant on /v1/iam/oauth/token:
- grant_type=urn:ietf:params:oauth:grant-type:token-exchange. An allow-listed
confidential client presents a subject_token identifying the end user and receives
an access token bound to that user (subject + owner = the user's, so a resource
server scopes on the validated owner claim), re-scoped via RFC 8707 resource/
audience to a downstream server, azp = the acting client. RFC 8693 §2.2 response
(access_token/issued_token_type/token_type/expires_in/scope).
- Reuses the red-hardened controls verbatim: mint allow-list matched by the globally
unique clientId ONLY (the closed CRITICAL), reserved-org (admin/built-in) subject
gated behind the separate IAM_ADMIN_MINT_ALLOWED_APPS capability, audit on every
exchange, and the trusted-cert signing. Stronger than the retired verb: the caller
must prove the subject with a verifiable subject_token, not just name an ?id=.
- /v1/iam/issue-user-token is GONE (removed from routes + the authz allowlist);
discovery advertises the token-exchange grant. The `hk-` Cloud API-key primitives
(mint/revoke-user-keys) stay — a product credential with no RFC, flagged for a
product call — over the same authorizeMinter seam.
Tests (token_exchange_test): mints for a subject + verifies claims/aud, off-allowlist
403, the name-collision priv-esc regression guard (moved from the red-team file),
reserved-org needs the admin capability (and admits with it), invalid subject_token →
invalid_grant, public client 401, audit emitted.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Per HIP-0111 (RFC-standard only), the Casdoor issue-user-token verb is replaced by
the standard OAuth 2.0 Token Exchange grant on /v1/iam/oauth/token:
- grant_type=urn:ietf:params:oauth:grant-type:token-exchange. An allow-listed
confidential client presents a subject_token identifying the end user and receives
an access token bound to that user (subject + owner = the user's, so a resource
server scopes on the validated owner claim), re-scoped via RFC 8707 resource/
audience to a downstream server, azp = the acting client. RFC 8693 §2.2 response
(access_token/issued_token_type/token_type/expires_in/scope).
- Reuses the red-hardened controls verbatim: mint allow-list matched by the globally
unique clientId ONLY (the closed CRITICAL), reserved-org (admin/built-in) subject
gated behind the separate IAM_ADMIN_MINT_ALLOWED_APPS capability, audit on every
exchange, and the trusted-cert signing. Stronger than the retired verb: the caller
must prove the subject with a verifiable subject_token, not just name an ?id=.
- /v1/iam/issue-user-token is GONE (removed from routes + the authz allowlist);
discovery advertises the token-exchange grant. The `hk-` Cloud API-key primitives
(mint/revoke-user-keys) stay — a product credential with no RFC, flagged for a
product call — over the same authorizeMinter seam.
Tests (token_exchange_test): mints for a subject + verifies claims/aud, off-allowlist
403, the name-collision priv-esc regression guard (moved from the red-team file),
reserved-org needs the admin capability (and admits with it), invalid subject_token →
invalid_grant, public client 401, audit emitted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Standard token-management endpoints on the OAuth surface, per HIP-0111 (RFC-only):
- POST /v1/iam/oauth/introspect (RFC 7662): client-authenticated (confidential,
constant-time). Active iff the grant row still exists (revocation-aware, the same
liveness check userinfo makes) AND the JWT verifies; returns the standard claims
(active/scope/client_id/sub/aud/exp/iat/nbf/iss/jti + owner/organization). An
inactive/absent token returns {active:false} only.
- POST /v1/iam/oauth/revoke (RFC 7009): a confidential client revokes a token issued
to IT — an access token deletes that grant row, a refresh token revokes the whole
rotation family. Unknown/other-client tokens are a silent 200 (no oracle, §2.2).
- GET /.well-known/oauth-authorization-server (RFC 8414), root + v1: the discovery
document at the OAuth well-known path (superset). discovery now advertises
introspection_endpoint + revocation_endpoint.
All three added to authz's public allowlist (they self-authenticate the client, not
a bearer). Tests: active→claims, revoked→inactive + bearer dies at userinfo, unknown
token→200, confidential-auth required.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Standard token-management endpoints on the OAuth surface, per HIP-0111 (RFC-only):
- POST /v1/iam/oauth/introspect (RFC 7662): client-authenticated (confidential,
constant-time). Active iff the grant row still exists (revocation-aware, the same
liveness check userinfo makes) AND the JWT verifies; returns the standard claims
(active/scope/client_id/sub/aud/exp/iat/nbf/iss/jti + owner/organization). An
inactive/absent token returns {active:false} only.
- POST /v1/iam/oauth/revoke (RFC 7009): a confidential client revokes a token issued
to IT — an access token deletes that grant row, a refresh token revokes the whole
rotation family. Unknown/other-client tokens are a silent 200 (no oracle, §2.2).
- GET /.well-known/oauth-authorization-server (RFC 8414), root + v1: the discovery
document at the OAuth well-known path (superset). discovery now advertises
introspection_endpoint + revocation_endpoint.
All three added to authz's public allowlist (they self-authenticate the client, not
a bearer). Tests: active→claims, revoked→inactive + bearer dies at userinfo, unknown
token→200, confidential-auth required.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The plug point for the hanzoiam/* enterprise modules (SCIM/SAML/LDAP), so the
proprietary clean-room core never carries Casdoor's Apache-2.0 code:
- pkg/model: PUBLIC identity DTOs (User/Application/Organization) as ALIASES of
the internal schema — a module shares ONE type with the core, no mapping, and
never imports internal/.
- feature: the Store interface (the union of object.* calls the copied Casdoor
code makes) + Feature{Name,Mount} + Register/MountAll. Dependency flows one way
(module → feature); the core never imports a module.
- internal/featurestore: Store impl over the orm store, so a module reads/writes
the SAME identity data as the core (one store, no second copy).
- server.Mount now feature.MountAll(app, featurestore.New(db)) — no-op until a
host registers a module, fail-fast if a registered one can't mount.
Test: a registered feature mounts + is listed; the store is injected. Full suite green.
The plug point for the hanzoiam/* enterprise modules (SCIM/SAML/LDAP), so the
proprietary clean-room core never carries Casdoor's Apache-2.0 code:
- pkg/model: PUBLIC identity DTOs (User/Application/Organization) as ALIASES of
the internal schema — a module shares ONE type with the core, no mapping, and
never imports internal/.
- feature: the Store interface (the union of object.* calls the copied Casdoor
code makes) + Feature{Name,Mount} + Register/MountAll. Dependency flows one way
(module → feature); the core never imports a module.
- internal/featurestore: Store impl over the orm store, so a module reads/writes
the SAME identity data as the core (one store, no second copy).
- server.Mount now feature.MountAll(app, featurestore.New(db)) — no-op until a
host registers a module, fail-fast if a registered one can't mount.
Test: a registered feature mounts + is listed; the store is injected. Full suite green.
The 5 session/identity front-door routes + 4 Casdoor write verbs the console/
gateway call but iam2 didn't serve. Composed from existing pieces, one authz seam,
no logic duplicated.
Front door (oidc.MountFrontDoor, public + self-resolving via callerOf):
- POST /v1/iam/signin — the code→session exchange: redeem+burn the authorization
code (store), sessions.Set, return the get-account envelope (shared helper
accountEnvelopeFor). The console's post<Account>('iam/signin',{code,state}).
- GET /v1/iam/whoami — lightweight caller identity (owner/name/id/isAdmin).
- POST /v1/iam/onboard — first-run org: organizations.Create + in-place user
org-move to admin; returns the console's {org}/{error} contract.
- POST /v1/iam/update-preferences — self, shallow-merge into
Properties["hanzo.preferences"] (v1 me_preferences contract).
- GET /v1/iam/linked-accounts — the caller's non-empty connector columns.
Casdoor write-aliases (compat/writes.go, typed ops → the ONE Authorize seam):
- add-organization → organizations.Create, add-user/update-user → users
Create/Update, update-application → applications.Update (now exported). Casibase
{status,data:<masked entity>} envelope; authz identical to the REST twins.
authz: the whole front-door surface (incl. the already-registered-but-gated
get-account/signup/send-verification-code) is added to publicPaths — each handler
resolves+self-scopes the caller, so they are reachable with a session cookie yet
never act on anyone else. Without this they 401'd at the Guard through routes.Mount.
Tests: oidc/frontdoor_e2e_test.go (signin→session→get-account, replay refused,
whoami, preferences round-trip, onboard create+move, linked-accounts) and
compat/writes_test.go (write-alias authz super/org-admin/cross-tenant + no-leak,
front-door public-through-Guard). gofmt clean, go build + go test ./... green.
The 5 session/identity front-door routes + 4 Casdoor write verbs the console/
gateway call but iam2 didn't serve. Composed from existing pieces, one authz seam,
no logic duplicated.
Front door (oidc.MountFrontDoor, public + self-resolving via callerOf):
- POST /v1/iam/signin — the code→session exchange: redeem+burn the authorization
code (store), sessions.Set, return the get-account envelope (shared helper
accountEnvelopeFor). The console's post<Account>('iam/signin',{code,state}).
- GET /v1/iam/whoami — lightweight caller identity (owner/name/id/isAdmin).
- POST /v1/iam/onboard — first-run org: organizations.Create + in-place user
org-move to admin; returns the console's {org}/{error} contract.
- POST /v1/iam/update-preferences — self, shallow-merge into
Properties["hanzo.preferences"] (v1 me_preferences contract).
- GET /v1/iam/linked-accounts — the caller's non-empty connector columns.
Casdoor write-aliases (compat/writes.go, typed ops → the ONE Authorize seam):
- add-organization → organizations.Create, add-user/update-user → users
Create/Update, update-application → applications.Update (now exported). Casibase
{status,data:<masked entity>} envelope; authz identical to the REST twins.
authz: the whole front-door surface (incl. the already-registered-but-gated
get-account/signup/send-verification-code) is added to publicPaths — each handler
resolves+self-scopes the caller, so they are reachable with a session cookie yet
never act on anyone else. Without this they 401'd at the Guard through routes.Mount.
Tests: oidc/frontdoor_e2e_test.go (signin→session→get-account, replay refused,
whoami, preferences round-trip, onboard create+move, linked-accounts) and
compat/writes_test.go (write-alias authz super/org-admin/cross-tenant + no-leak,
front-door public-through-Guard). gofmt clean, go build + go test ./... green.
- Password grant (RFC 6749 §4.3) on the standard /v1/iam/oauth/token — the durable
first-party console session. Confidential clients only (a public client + password
grant is a phishing footgun), app must have password login enabled, credentials
verified through the SAME algorithm-aware per-row path the login form uses
(argon2id every live v1 row, bcrypt new); one opaque invalid_grant for both
unknown-user and bad-password (no enumeration oracle); mints via the ONE shared
issueTokens path (access + id_token on openid + rotating refresh on offline_access).
- IAM_ISSUER pin: tokenIssuer honors the IAM_ISSUER env (e.g. https://hanzo.id) so a
deployment serving both hanzo.id and iam.hanzo.ai emits ONE stable `iss` the embedded
KMS + every resource server validate against — also closes the red INFO finding that
X-Forwarded-Host could steer `iss`. Unset → host-relative (dev).
- ONE token endpoint: /v1/iam/oauth/token (the RFC / discovery token_endpoint). The
Casdoor `access_token` alias is NOT served — no backwards-compat duplicate spelling;
the clients are fixed to the standard path (console session.ts + gateway admin-guard
+ waitlist-guard), not the backend shimmed. discovery advertises `password`.
Tests: password grant mints a verifiable user token (offline_access→refresh,
openid→id_token), wrong/unknown → opaque invalid_grant, public client → 401.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
- Password grant (RFC 6749 §4.3) on the standard /v1/iam/oauth/token — the durable
first-party console session. Confidential clients only (a public client + password
grant is a phishing footgun), app must have password login enabled, credentials
verified through the SAME algorithm-aware per-row path the login form uses
(argon2id every live v1 row, bcrypt new); one opaque invalid_grant for both
unknown-user and bad-password (no enumeration oracle); mints via the ONE shared
issueTokens path (access + id_token on openid + rotating refresh on offline_access).
- IAM_ISSUER pin: tokenIssuer honors the IAM_ISSUER env (e.g. https://hanzo.id) so a
deployment serving both hanzo.id and iam.hanzo.ai emits ONE stable `iss` the embedded
KMS + every resource server validate against — also closes the red INFO finding that
X-Forwarded-Host could steer `iss`. Unset → host-relative (dev).
- ONE token endpoint: /v1/iam/oauth/token (the RFC / discovery token_endpoint). The
Casdoor `access_token` alias is NOT served — no backwards-compat duplicate spelling;
the clients are fixed to the standard path (console session.ts + gateway admin-guard
+ waitlist-guard), not the backend shimmed. discovery advertises `password`.
Tests: password grant mints a verifiable user token (offline_access→refresh,
openid→id_token), wrong/unknown → opaque invalid_grant, public client → 401.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Red review found a proven privilege escalation: mintAllowed matched the app's
per-owner-unique Name as well as its global clientId, so a tenant org-admin could
register an app named `hanzo-console` in their OWN org (chosen secret + the platform
cert name read from the public JWKS) and mint a fully-valid owner="admin" SuperAdmin
token — collapsing the entire tenant boundary. Fixes (iam2 is pre-prod → fixes forward):
- CRITICAL: mintAllowed matches the GLOBALLY-unique clientId ONLY (dropped the
app.Name match). The red-team PoC is kept as a permanent regression guard, flipped
to assert the collision is now refused (403, no token).
- MEDIUM (defense-in-depth): a RESERVED-org (admin/built-in) target now requires a
SEPARATE capability (IAM_ADMIN_MINT_ALLOWED_APPS), so even a valid general minter
can't reach a SuperAdmin identity — a leaked general-minter secret is contained. The
console, which legitimately drives admin.hanzo.ai, holds both capabilities (proven by
a test: unset admin list → 403, set → 200).
- LOW: emit a best-effort AuditLog on every issue/mint/revoke (who minted for whom) —
the escalation was previously invisible.
- LOW: issue-user-token is POST-only now (was GET+POST; a mint/bearer must never ride
a cacheable GET where client_secret could reach logs).
Verified-safe by red (unchanged): the read-modify-write preserves PasswordHash +
isAdmin; forbidden/deleted targets refused pre-mint; constant-time secret compare;
public-client rejection; GetSigningCert's reserved-owner kid restriction.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Red review found a proven privilege escalation: mintAllowed matched the app's
per-owner-unique Name as well as its global clientId, so a tenant org-admin could
register an app named `hanzo-console` in their OWN org (chosen secret + the platform
cert name read from the public JWKS) and mint a fully-valid owner="admin" SuperAdmin
token — collapsing the entire tenant boundary. Fixes (iam2 is pre-prod → fixes forward):
- CRITICAL: mintAllowed matches the GLOBALLY-unique clientId ONLY (dropped the
app.Name match). The red-team PoC is kept as a permanent regression guard, flipped
to assert the collision is now refused (403, no token).
- MEDIUM (defense-in-depth): a RESERVED-org (admin/built-in) target now requires a
SEPARATE capability (IAM_ADMIN_MINT_ALLOWED_APPS), so even a valid general minter
can't reach a SuperAdmin identity — a leaked general-minter secret is contained. The
console, which legitimately drives admin.hanzo.ai, holds both capabilities (proven by
a test: unset admin list → 403, set → 200).
- LOW: emit a best-effort AuditLog on every issue/mint/revoke (who minted for whom) —
the escalation was previously invisible.
- LOW: issue-user-token is POST-only now (was GET+POST; a mint/bearer must never ride
a cacheable GET where client_secret could reach logs).
Verified-safe by red (unchanged): the read-modify-write preserves PasswordHash +
isAdmin; forbidden/deleted targets refused pre-mint; constant-time secret compare;
public-client rejection; GetSigningCert's reserved-owner kid restriction.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The front-door + session layer are wired, so the comments that said the
credential/session paths 'land with a later increment' were stale (AI-residual).
Rewrote frontdoor.go + getaccount.go headers to state what IS, and closed §4 in
MIGRATION.md with the durable-session mechanism. No code change.
The front-door + session layer are wired, so the comments that said the
credential/session paths 'land with a later increment' were stale (AI-residual).
Rewrote frontdoor.go + getaccount.go headers to state what IS, and closed §4 in
MIGRATION.md with the durable-session mechanism. No code change.
login (type=login) now issues a signed session cookie; get-account resolves the
caller by cookie FIRST (the portal + gateway-admin-guard path) then bearer (the
API path) — two credentials, one identity via callerOf. Revocable: the cookie's
sid is registered in the Session row and checked on every resolve.
- sessions.Set/Resolve over the cookie primitives; key derived from the platform
signing cert (store.PlatformSigningCert) — no new secret to provision.
- registerSID/sidActive mirror the Sessions.Create persist path (one way to
write a session).
Fix caught by the e2e: Set passed ttl=0 to Issue, expiring the payload on mint;
now bounds both the signed expiry and cookie MaxAge by one sessionTTL (14d).
Tests: login→cookie→get-account resolves alice (redacted, no bearer); a FORGED
cookie stays anonymous. Full suite green (authz/compat/cred/oidc/schema/seed/
sessions). §4 residual (get-account+signup+send-verification-code+session) done —
iam2 is Phase-4 shadow-embed ready.
login (type=login) now issues a signed session cookie; get-account resolves the
caller by cookie FIRST (the portal + gateway-admin-guard path) then bearer (the
API path) — two credentials, one identity via callerOf. Revocable: the cookie's
sid is registered in the Session row and checked on every resolve.
- sessions.Set/Resolve over the cookie primitives; key derived from the platform
signing cert (store.PlatformSigningCert) — no new secret to provision.
- registerSID/sidActive mirror the Sessions.Create persist path (one way to
write a session).
Fix caught by the e2e: Set passed ttl=0 to Issue, expiring the payload on mint;
now bounds both the signed expiry and cookie MaxAge by one sessionTTL (14d).
Tests: login→cookie→get-account resolves alice (redacted, no bearer); a FORGED
cookie stays anonymous. Full suite green (authz/compat/cred/oidc/schema/seed/
sessions). §4 residual (get-account+signup+send-verification-code+session) done —
iam2 is Phase-4 shadow-embed ready.
Extends issue-user-token with the console API-keys page's two primitives, over the
SAME confidential-client seam (one authorizeMinter: client_secret_basic/_post +
constant-time verify + the fail-closed IAM_KEY_MINT_ALLOWED_APPS allow-list) and
the same ?id=<owner>/<name> target resolution — so all three primitives share one
auth path, one target path, one error envelope (no divergence).
- POST /v1/iam/mint-user-keys → {status:ok, data:{accessKey}}: (re)generates the
target user's durable `hk-` Cloud API key (schema.User.AccessKey), persisted via a
read-modify-write that preserves every other field; the console's getUserKey reads
it back from get-user.
- POST /v1/iam/revoke-user-keys → {status:ok, data:{affected:true}}: clears the key
(AccessKey + AccessSecret + hash).
- Both public-path (self-authenticate the client, not a bearer) + fail-closed.
Tests: mint generates a persisted hk- key, revoke clears it, off-allowlist 403.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Extends issue-user-token with the console API-keys page's two primitives, over the
SAME confidential-client seam (one authorizeMinter: client_secret_basic/_post +
constant-time verify + the fail-closed IAM_KEY_MINT_ALLOWED_APPS allow-list) and
the same ?id=<owner>/<name> target resolution — so all three primitives share one
auth path, one target path, one error envelope (no divergence).
- POST /v1/iam/mint-user-keys → {status:ok, data:{accessKey}}: (re)generates the
target user's durable `hk-` Cloud API key (schema.User.AccessKey), persisted via a
read-modify-write that preserves every other field; the console's getUserKey reads
it back from get-user.
- POST /v1/iam/revoke-user-keys → {status:ok, data:{affected:true}}: clears the key
(AccessKey + AccessSecret + hash).
- Both public-path (self-authenticate the client, not a bearer) + fail-closed.
Tests: mint generates a persisted hk- key, revoke clears it, off-allowlist 403.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every console admin call (IAM + KMS proxies) and the keyless-AI proxy mint their
upstream bearer at POST /v1/iam/issue-user-token; absent, /admin/* and /ai 502
before any verb. Implemented to the contract verified against the authoritative
consumer (console src/lib/server/identity.ts):
- Confidential-client auth (client_secret_basic or _post, constant-time) + a
capability allow-list (IAM_KEY_MINT_ALLOWED_APPS, FAIL-CLOSED — an unset list
permits nothing; this hands out a user's full authority).
- Acts on-behalf-of the ?id=<owner>/<name> target user: the minted access token's
subject AND owner claim are the TARGET USER's (not the client's), so a resource
server that scopes on the validated owner claim (cloud SanitizeIdentity) scopes
to the user's tenant. azp records the minting client. Signed under the client's
trusted signing cert + canonical issuer, so the same JWKS verifies it — the token
is indistinguishable from one the user obtained directly.
- Audience (RFC 8707): ?aud= resource wins (the admin path pins <brand>-cloud so a
reserved-admin operator's token is accepted); default = the target user's own app.
- Envelope {status:"ok", data:{accessToken, expiresIn}} — the exact camelCase shape
identity.ts consumes. Persists the token by hash (revocable, userinfo-resolvable).
- Not Bearer-gated (authenticates the CLIENT, not an end-user) → listed in authz's
public allowlist; the handler does its own, tighter auth.
New jwt.Signer.SignUserToken (decoupled from schema — the handler passes the values
it authorized). Tests (9): mints the target user's authority + verifies under JWKS,
aud override, default aud, wrong secret 401, off-allowlist 403, empty-allowlist
fail-closed, no-auth 401, unknown user error, forbidden user 403.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Every console admin call (IAM + KMS proxies) and the keyless-AI proxy mint their
upstream bearer at POST /v1/iam/issue-user-token; absent, /admin/* and /ai 502
before any verb. Implemented to the contract verified against the authoritative
consumer (console src/lib/server/identity.ts):
- Confidential-client auth (client_secret_basic or _post, constant-time) + a
capability allow-list (IAM_KEY_MINT_ALLOWED_APPS, FAIL-CLOSED — an unset list
permits nothing; this hands out a user's full authority).
- Acts on-behalf-of the ?id=<owner>/<name> target user: the minted access token's
subject AND owner claim are the TARGET USER's (not the client's), so a resource
server that scopes on the validated owner claim (cloud SanitizeIdentity) scopes
to the user's tenant. azp records the minting client. Signed under the client's
trusted signing cert + canonical issuer, so the same JWKS verifies it — the token
is indistinguishable from one the user obtained directly.
- Audience (RFC 8707): ?aud= resource wins (the admin path pins <brand>-cloud so a
reserved-admin operator's token is accepted); default = the target user's own app.
- Envelope {status:"ok", data:{accessToken, expiresIn}} — the exact camelCase shape
identity.ts consumes. Persists the token by hash (revocable, userinfo-resolvable).
- Not Bearer-gated (authenticates the CLIENT, not an end-user) → listed in authz's
public allowlist; the handler does its own, tighter auth.
New jwt.Signer.SignUserToken (decoupled from schema — the handler passes the values
it authorized). Tests (9): mints the target user's authority + verifies under JWKS,
aud override, default aud, wrong secret 401, off-allowlist 403, empty-allowlist
fail-closed, no-auth 401, unknown user error, forbidden user 403.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The last two native front-door endpoints HIP-0111 §4 gates cutover on. Both
return the casibase {status,msg,data} envelope and mount in MountFrontDoor next
to get-account.
signup (POST /v1/iam/signup, JSON): mirrors v1 controllers/account.go Signup —
resolve app (clientId or name) → enforce EnableSignUp + EnablePassword + tenant
isolation → username policy (object/check.go CheckUserSignup) → uniqueness →
org PasswordOptions complexity (object/check_password_complexity.go) → create.
Creation goes through the ONE canonical path (users.New(db).Create): bcrypt-hash
once, PasswordType=bcrypt (what internal/cred verifies for new rows), return the
row REDACTED. No plaintext ever stored — proven by test reading the row back.
send-verification-code (POST /v1/iam/send-verification-code, multipart/form-data
per §4, read via fiber FormValue): validates dest+type+applicationId
(form.VerificationForm.CheckParameter), mints an unbiased crypto/rand 6-digit
OTP, persists it as the new `verifications` entity, and reports ok honestly.
Email/SMS delivery is hanzoai/notify's concern (not wired into iam2 yet) — a
documented seam, never a faked "sent". CheckVerificationCode completes the
constant-time, expiry-gated validation surface.
New entity: schema.VerificationRecord (kind `verifications`) — v1's
`verification` table, the 14th identity kind; store.AddVerificationRecord +
GetLatestVerificationRecord.
Deliberate seams vs v1 (missing iam2 deps, not shortcuts): signup lands the user
in the app's existing org (v1's TenantOrgForSignup founder-org mint needs an
org-create helper + Org.Parent, unmodeled); no captcha verification (no captcha
provider modeled); phone E.164 normalization not ported.
gofmt clean, go build/vet green, go test ./... green (incl -race on the new
handlers). MIGRATION.md §4/§6 updated to reality.
The last two native front-door endpoints HIP-0111 §4 gates cutover on. Both
return the casibase {status,msg,data} envelope and mount in MountFrontDoor next
to get-account.
signup (POST /v1/iam/signup, JSON): mirrors v1 controllers/account.go Signup —
resolve app (clientId or name) → enforce EnableSignUp + EnablePassword + tenant
isolation → username policy (object/check.go CheckUserSignup) → uniqueness →
org PasswordOptions complexity (object/check_password_complexity.go) → create.
Creation goes through the ONE canonical path (users.New(db).Create): bcrypt-hash
once, PasswordType=bcrypt (what internal/cred verifies for new rows), return the
row REDACTED. No plaintext ever stored — proven by test reading the row back.
send-verification-code (POST /v1/iam/send-verification-code, multipart/form-data
per §4, read via fiber FormValue): validates dest+type+applicationId
(form.VerificationForm.CheckParameter), mints an unbiased crypto/rand 6-digit
OTP, persists it as the new `verifications` entity, and reports ok honestly.
Email/SMS delivery is hanzoai/notify's concern (not wired into iam2 yet) — a
documented seam, never a faked "sent". CheckVerificationCode completes the
constant-time, expiry-gated validation surface.
New entity: schema.VerificationRecord (kind `verifications`) — v1's
`verification` table, the 14th identity kind; store.AddVerificationRecord +
GetLatestVerificationRecord.
Deliberate seams vs v1 (missing iam2 deps, not shortcuts): signup lands the user
in the app's existing org (v1's TenantOrgForSignup founder-org mint needs an
org-create helper + Org.Parent, unmodeled); no captcha verification (no captcha
provider modeled); phone E.164 normalization not ported.
gofmt clean, go build/vet green, go test ./... green (incl -race on the new
handlers). MIGRATION.md §4/§6 updated to reality.
The #1 cutover blocker: every live console/gateway/portal client hard-codes the
Casdoor verb spellings (get-users, get-organizations, get-user?id=…) in the v1
{status,data,data2} envelope, but iam2's native surface is REST — so a backend
swap 404s every console IAM page. internal/compat serves those verbs as READ
aliases over the SAME orm store, redaction, and authz as the REST handlers
(generic listHandler[T]/getHandler[T]; paginate only when both p+pageSize;
data2=total).
Redaction is consolidated to ONE contract: schema.Mask() per entity, returning a
masked COPY (never mutates the receiver — the login-verify path must never see a
blanked hash). users.redact/organizations.masked deleted; every read handler +
compat call .Mask(). This CLOSES latent leaks (red review):
applications/providers returned clientSecret raw on get/list; Application's
enriched joins (OrganizationObj, Providers[].Provider, CertObj) carried a linked
entity's own secret past the top-level mask; User.Mask now also blanks the live
VerificationCode.
Guard read-authorization understands the Casdoor ?id=<owner>/<name> shape
(authz.ReadTarget, exported) so non-super org-admins aren't 403'd on id-reads;
explicit owner/name still win and the handler re-scopes every query owner through
authz.Scope, so a ?owner=x&id=y/z split cannot cross tenants.
Tests: schema/mask_test (per-entity no-leak + no-mutation, enriched joins),
compat/aliases_test (v1 envelope, pagination, no-secret-leak through the REAL
router, owner-scoping, cross-tenant + regular-user denial, ?id= fallback).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The #1 cutover blocker: every live console/gateway/portal client hard-codes the
Casdoor verb spellings (get-users, get-organizations, get-user?id=…) in the v1
{status,data,data2} envelope, but iam2's native surface is REST — so a backend
swap 404s every console IAM page. internal/compat serves those verbs as READ
aliases over the SAME orm store, redaction, and authz as the REST handlers
(generic listHandler[T]/getHandler[T]; paginate only when both p+pageSize;
data2=total).
Redaction is consolidated to ONE contract: schema.Mask() per entity, returning a
masked COPY (never mutates the receiver — the login-verify path must never see a
blanked hash). users.redact/organizations.masked deleted; every read handler +
compat call .Mask(). This CLOSES latent leaks (red review):
applications/providers returned clientSecret raw on get/list; Application's
enriched joins (OrganizationObj, Providers[].Provider, CertObj) carried a linked
entity's own secret past the top-level mask; User.Mask now also blanks the live
VerificationCode.
Guard read-authorization understands the Casdoor ?id=<owner>/<name> shape
(authz.ReadTarget, exported) so non-super org-admins aren't 403'd on id-reads;
explicit owner/name still win and the handler re-scopes every query owner through
authz.Scope, so a ?owner=x&id=y/z split cannot cross tenants.
Tests: schema/mask_test (per-entity no-leak + no-mutation, enriched joins),
compat/aliases_test (v1 envelope, pagination, no-secret-leak through the REAL
router, owner-scoping, cross-tenant + regular-user denial, ?id= fallback).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The portal session the native front-door sets on a bare (type=login) sign-in and
that get-account resolves the caller from — the connective piece so get-account
serves the portal + gateway-admin-guard SESSION path, not just the bearer/API
path. Signed base64url(payload).base64url(HMAC-SHA256) carrying {owner, name,
application, sid, exp}; the signature is what makes `owner` (the admin-guard's
global-admin input) unforgeable. HMAC key derived from the platform signing
cert (domain-separated) — no new secret to provision, survives restarts. SID is
a 256-bit random id for Session-row revocation once wired into login SET.
Tests: round-trip, FORGED owner=admin rejected (the security property), wrong
key, expiry, malformed, SID uniqueness/size. Pure — no db, unit-testable.
The portal session the native front-door sets on a bare (type=login) sign-in and
that get-account resolves the caller from — the connective piece so get-account
serves the portal + gateway-admin-guard SESSION path, not just the bearer/API
path. Signed base64url(payload).base64url(HMAC-SHA256) carrying {owner, name,
application, sid, exp}; the signature is what makes `owner` (the admin-guard's
global-admin input) unforgeable. HMAC key derived from the platform signing
cert (domain-separated) — no new secret to provision, survives restarts. SID is
a 256-bit random id for Session-row revocation once wired into login SET.
Tests: round-trip, FORGED owner=admin rejected (the security property), wrong
key, expiry, malformed, SID uniqueness/size. Pure — no db, unit-testable.
get-account is a SECURITY contract: the gateway admin-guard derives the
global-admin (SuperAdmin) predicate from the `owner` it returns. Implemented
to match v1's envelope exactly — {status, sub, name, data:<user>, data2:<org>} —
resolving the caller from the bearer access token (shared with userinfo's
verifyToken), redacting every secret, and returning {status:"error"} for
anonymous/invalid callers (200, casibase convention → admin-guard reads
error → not-admin, fail-closed).
DRY: promotes the secret-strip to canonical schema.User.Redact() +
schema.Organization.Redact(); the existing users.redact / organizations.masked
now delegate to them (one definition each). Wired into MountFrontDoor.
Tests: bearer → redacted account (owner correct, 6 secret fields stripped);
anonymous/garbage → error + no data leak; Redact keeps owner+isAdmin, strips
secrets in place. Full iam2 suite green.
The portal session-cookie resolution path plugs into the same handler with no
shape change once the session layer lands (remaining §4 residual: session
issuance, signup, send-verification-code).
get-account is a SECURITY contract: the gateway admin-guard derives the
global-admin (SuperAdmin) predicate from the `owner` it returns. Implemented
to match v1's envelope exactly — {status, sub, name, data:<user>, data2:<org>} —
resolving the caller from the bearer access token (shared with userinfo's
verifyToken), redacting every secret, and returning {status:"error"} for
anonymous/invalid callers (200, casibase convention → admin-guard reads
error → not-admin, fail-closed).
DRY: promotes the secret-strip to canonical schema.User.Redact() +
schema.Organization.Redact(); the existing users.redact / organizations.masked
now delegate to them (one definition each). Wired into MountFrontDoor.
Tests: bearer → redacted account (owner correct, 6 secret fields stripped);
anonymous/garbage → error + no data leak; Redact keeps owner+isAdmin, strips
secrets in place. Full iam2 suite green.
The portal session-cookie resolution path plugs into the same handler with no
shape change once the session layer lands (remaining §4 residual: session
issuance, signup, send-verification-code).
Corrects stale scaffold framing: storage is orm/hanzoai/sqlite (not base),
inter-service is zap-proto (not luxfi/zap), the drift gate is DROPPED (parity =
tests + golden vectors + parity audit + shadow deploy), and the argon2id
credential landmine is marked RESOLVED with the golden-vector proof. Preserves
the front-door residual + the three v1 security contracts. Adds the native
git.hanzo.ai+GitOps build/deploy section.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Corrects stale scaffold framing: storage is orm/hanzoai/sqlite (not base),
inter-service is zap-proto (not luxfi/zap), the drift gate is DROPPED (parity =
tests + golden vectors + parity audit + shadow deploy), and the argon2id
credential landmine is marked RESOLVED with the golden-vector proof. Preserves
the front-door residual + the three v1 security contracts. Adds the native
git.hanzo.ai+GitOps build/deploy section.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The parity proof that matters: a PHC digest produced by hanzoai/iam's actual
Argon2idCredManager (GetHashedPassword, DefaultParams), captured verbatim and
asserted to verify under iam2's cred.Verify. Not a digest iam2 generated itself —
the exact bytes v1 writes.
Pins a REAL cross-version risk found while doing this: v1 resolves
argon2id v0.0.0-20211130144151 while iam2 pins v1.0.0. The PHC string is
self-describing (m=65536,t=1,p=N + salt + key), so either version verifies the
other's digest — this test proves it and fails loudly if a future bump breaks it.
Also asserts the shape (a v1 param change is caught here, not in prod), the full
row→algorithm path (user type wins over org fallback), and that the shipped bug
stays dead: the v1 argon2id digest must NOT verify under bcrypt.
Test passwords only — no live user's digest is ever committed (a real hash is an
offline-attackable secret).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The parity proof that matters: a PHC digest produced by hanzoai/iam's actual
Argon2idCredManager (GetHashedPassword, DefaultParams), captured verbatim and
asserted to verify under iam2's cred.Verify. Not a digest iam2 generated itself —
the exact bytes v1 writes.
Pins a REAL cross-version risk found while doing this: v1 resolves
argon2id v0.0.0-20211130144151 while iam2 pins v1.0.0. The PHC string is
self-describing (m=65536,t=1,p=N + salt + key), so either version verifies the
other's digest — this test proves it and fails loudly if a future bump breaks it.
Also asserts the shape (a v1 param change is caught here, not in prod), the full
row→algorithm path (user type wins over org fallback), and that the shipped bug
stays dead: the v1 argon2id digest must NOT verify under bcrypt.
Test passwords only — no live user's digest is ever committed (a real hash is an
offline-attackable secret).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the cutover blocker recorded in 60b69d1: users.VerifyPassword called
bcrypt unconditionally and is the only path credential login takes, but EVERY
live v1 row is argon2id (org PasswordType is rewritten to argon2id on
create/update; UpdateUserPassword stamps it per user). bcrypt handed an argon2id
PHC digest returns ErrHashTooShort — so at cutover 100% of credential logins
would fail, for every existing user, immediately.
Ports v1's contract (object/check.go): the hash algorithm is a property of the
STORED ROW — user.PasswordType, falling back to organization.PasswordType — never
a constant.
- internal/cred: pure Verify(passwordType, plaintext, hashed) dispatching
argon2id (github.com/alexedwards/argon2id — the same lib + PHC format v1's
Argon2idCredManager writes) and bcrypt. Resolve(userType, orgType) implements
the row→org fallback. Verify-ONLY (never hashes; upgrade-on-login stays a
separate deliberate decision). Fails CLOSED on unknown/empty/malformed —
including the legacy salt schemes and `plain` — because a silent pass on an
unrecognized scheme is an auth bypass.
- users.VerifyPassword(u, plaintext, orgPasswordType) is now algorithm-aware.
- oidc/login.go resolves the org's PasswordType (store.GetOrganizationByName)
and passes it, so the fallback is live on the real login path.
Tests (6/6): a REAL argon2id PHC digest verifies + wrong password rejected;
bcrypt unregressed; cross-scheme (argon2id digest under bcrypt and vice versa)
fails closed — the actual bypass shape; garbage/unknown/empty fail closed;
per-row-then-org resolution. Full suite green (authz, cred, oidc, seed).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Closes the cutover blocker recorded in beb808b: users.VerifyPassword called
bcrypt unconditionally and is the only path credential login takes, but EVERY
live v1 row is argon2id (org PasswordType is rewritten to argon2id on
create/update; UpdateUserPassword stamps it per user). bcrypt handed an argon2id
PHC digest returns ErrHashTooShort — so at cutover 100% of credential logins
would fail, for every existing user, immediately.
Ports v1's contract (object/check.go): the hash algorithm is a property of the
STORED ROW — user.PasswordType, falling back to organization.PasswordType — never
a constant.
- internal/cred: pure Verify(passwordType, plaintext, hashed) dispatching
argon2id (github.com/alexedwards/argon2id — the same lib + PHC format v1's
Argon2idCredManager writes) and bcrypt. Resolve(userType, orgType) implements
the row→org fallback. Verify-ONLY (never hashes; upgrade-on-login stays a
separate deliberate decision). Fails CLOSED on unknown/empty/malformed —
including the legacy salt schemes and `plain` — because a silent pass on an
unrecognized scheme is an auth bypass.
- users.VerifyPassword(u, plaintext, orgPasswordType) is now algorithm-aware.
- oidc/login.go resolves the org's PasswordType (store.GetOrganizationByName)
and passes it, so the fallback is live on the real login path.
Tests (6/6): a REAL argon2id PHC digest verifies + wrong password rejected;
bcrypt unregressed; cross-scheme (argon2id digest under bcrypt and vice versa)
fails closed — the actual bypass shape; garbage/unknown/empty fail closed;
per-row-then-org resolution. Full suite green (authz, cred, oidc, seed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
users.VerifyPassword calls bcrypt unconditionally and is the only path login
takes. Every live v1 row is argon2id (sanitizeOrgPasswordType rewrites
""/bcrypt/plain -> argon2id on create AND update; CreatePersonalOrganization
inserts it; init_data.json declares it). bcrypt handed an argon2id PHC string
returns ErrHashTooShort, so on cutover every existing user's login fails.
v1 resolves the algorithm per row (check.go: user.PasswordType, else
organization.PasswordType, then cred.GetCredManager). The algorithm is a
property of the stored row, never a constant.
Also records what the front-door port must honour: get-account backs the
gateway's SuperAdmin + waitlist predicates, send-verification-code is
multipart, and native userinfo/logout are aliases of the oauth handlers.
The OIDC surface is complete; the portal's native front door is not. signup,
send-verification-code, get-account, userinfo, logout are absent — a backend
swap without them takes hanzo.id's signup, verification, account and sign-out
with it. Gated on them regardless of drift.
GET /v1/iam/certs?owner=<own-org> returned every tenant's certs with
privateKey serialized: a GET binds no query, so certs.List saw in.Owner==""
and listed all, and the response carried the admin signing key — any org admin
could forge tokens for any account. The read twin of the write divergence:
authorize one value, execute another.
One way for each concern: authz.Scope resolves a listing's owner from the
VERIFIED bearer (never a request parameter), and schema.Cert.Mask is the one
place a cert sheds secrets before it crosses the API — the signing key lives in
the store and signs in process; relying parties read the public half from the
JWKS (RFC 7517).
Also serve the JWKS at the root well-known path (RFC 8414) alongside the
/v1/iam one, matching live v1 — the gateway defaults to root.
Tests assert the response BODY, and are proven non-vacuous: neutering Mask or
Scope each fails a distinct case.
The Phase-3 guard re-parsed the authorization target from the raw request
body in middleware, divergently from where each handler binds it. For the
users entity — the one input that nests its record under `user` — the guard
read the top-level owner while the handler bound user.owner, so an org admin
could mask {owner:<own-org>, user:{owner:admin, isAdmin:true}} and write a
platform SuperAdmin (owner=="admin" IS the predicate). The same divergence let
an MCP tools/call mask the target in params.arguments.
Decomplect into two orthogonal seams:
- Guard (app.Use) authenticates every request and authorizes reads, whose
target rides in the query string.
- Authorize (app.Authorize) authorizes writes at the framework's op-invoke
seam, on the DECODED typed input — the exact value the handler binds — so
REST and MCP authorize what actually executes, by construction. There is no
second parse of the body to diverge from.
The one nested-owner input declares its target via an owned interface
(users.CreateInput/UpdateInput.AuthzTarget); the handler binds through the
same method, so the value authorized is the value written in one code path.
Every other entity files its owner at the top level, read reflectively, so an
attacker-supplied nested sub-struct is never mistaken for the target.
Requires zap-proto/zip v1.8.3 (the op-invoke Authorizer hook + App.Prepare).
Tests: the deferred /mcp and /openapi routes are installed for real, the real
op ids are used (post_v1_iam_certs, post_v1_iam_users), and both mask envelopes
(REST users owner-mask + MCP arguments-mask) assert 403/isError AND zero
admin-owned rows persisted — querying the store, not just the status code.
One authz middleware (internal/authz), mounted first via app.Use in
routes.Mount, verifies the bearer and enforces tenant scoping before any CRUD
handler runs. Closes the Phase-1 gap where the users/certs/applications/... CRUD
was unauthenticated: an unverified caller could overwrite an admin-owned signing
cert and forge any token.
Policy — three scopes, never conflated:
- SuperAdmin (owner == "admin", a live admin-org user): the only cross-tenant
scope; required to write any admin/built-in-owned resource. This one rule is
the signing-cert poisoning gate, admin app/provider registration, and the
built-in-org gap at once.
- Org admin (IsAdmin): manages only its own org's resources.
- Regular user: reads only its own user record.
The principal's org is taken from the token SUBJECT (the user's own owner/name),
never the owner/organization claim (the app's org, which diverges for a shared
app) — so a tenant user signing in through a shared admin-org app is not
SuperAdmin. Bearer verification reuses oidc.VerifyToken (exported): the same
algorithm allowlist, trusted signing-cert kid resolution, and expiry checks
every OIDC route uses. The reserved-owner set reuses store.IsSigningCertOwner.
Fail closed: only the OIDC/OAuth + front-door allowlist is public; the framework
/mcp and /openapi projections are gated too, and MCP is disabled on the
standalone binary.
Tests (internal/authz): the full policy matrix as a unit table, plus end-to-end
through the real mounted router — unauthenticated 401, cross-org 403, the
poisoning gate 403 across every cert-write verb, SuperAdmin cert rotation +
cross-org 2xx, org-admin own-org 2xx / foreign 403, regular self-read admitted /
writes and others 403, public routes open, bad bearers
(expired/HMAC/none/forged-kid/wrong-key/revoked) 401, owner-claim non-escalation,
phantom-admin subject no authority, /mcp + /openapi gated.
GOWORK=off go build ./... && go vet ./... && go test -race ./... all green.
Per the directive: canonical build/deploy is git.hanzo.ai (Gitea) + Hanzo GitOps;
GitHub is mirror-only. Replace the GitHub-specific reusable-workflow caller
(uses: hanzoai/.github/...@main — which caused startup_failure on this new
private repo, and is a GitHub-ism Gitea can't resolve) with a SELF-CONTAINED
docker buildx build+push that runs on a Gitea act_runner natively — and equally
on any standard runner. Same portable file at .gitea/workflows/build.yaml (native)
and .github/workflows/build.yml (mirror).
Registry + creds come from Actions secrets (REGISTRY_USER/REGISTRY_TOKEN,
KMS-provisioned), ghcr.io fallback during the mirror transition. Tags: v* → the
tag; branch → sha-<7>. amd64, pure-Go, GOEXPERIMENT=jsonv2.
Still ops-gated to actually run: native act_runner registered to git.hanzo.ai +
Hanzo GitOps deployed/pointed at it + git.hanzo.ai push creds (all cluster/gitea
admin). This makes iam2 READY for the native pipeline the moment those exist.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The extra platforms input was the only delta from the working visor caller;
drop it to match exactly. Also correct README status (orm not base, OAuth core
live not Phase-0, zap-proto not luxfi/zap) — the rebase had pulled in the old
scaffold docs.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
iam2 can now build on the Hanzo self-hosted ARC runners (hanzo-build-linux-amd64),
the same pipeline every hanzo service uses — the path to a real
ghcr.io/hanzoai/iam2 image and production deploy.
- go.mod: dropped the local replace directives (=> ../orm, ../../zap-proto/zip)
and pinned the PUBLISHED versions (orm v0.6.1, zip v1.6.0). The local replaces
were dev-only during the migration and don't exist in a CI container; verified
iam2 builds + all tests pass against the published deps (no luxfi rot).
- Dockerfile: multi-stage golang:1.26.4 → alpine, CGO_ENABLED=0 (pure-Go;
hanzoai/sqlite's modernc engine needs no cgo), GOEXPERIMENT=jsonv2 per
SCALE_STANDARD, version via ldflags, non-root uid 1000, /data volume, serves
ZAP :9653 + HTTP :8080; CMD bootstraps from --init-data.
- .github/workflows/build.yml: calls the shared hanzoai/.github docker-build.yml,
image ghcr.io/hanzoai/iam2, linux/amd64, hanzo runners only (never cross-org).
- .dockerignore: clean build context.
Verified: the exact Dockerfile go build (CGO_ENABLED=0 GOEXPERIMENT=jsonv2
-trimpath -ldflags) compiles a working binary; oidc+seed suites green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
POST /v1/iam/login authenticated the user in the caller-supplied organization but
resolved the app by an independent clientId, with no check that the user's org may
use that app. Because a token's owner/organization claim is set to the app's org,
a user with valid credentials in org B could obtain a token whose organization
claim names org A — cross-tenant issuance any relying party that authorizes on the
organization claim would honor.
Require the authenticated user's org to match the application's org, unless the app
is shared (IsShared) or lets users choose their org (OrgChoiceMode) — the standard
org-gated sign-in rule. Shadow-first + the drift-gated cutover make an
over-restriction observable in parity testing before it can reach production.
Tests: a valid org-B user is refused a code for a single-tenant org-A app; a shared
app still accepts a cross-org user. Full suite green + race-clean.
Signing-cert resolution (JWKS publish, token signing, and bearer verification) now
trusts a cert only when it is owned by a reserved platform org (admin/built-in),
not any cert that happens to carry the kid's name.
Without this, because a cert is resolved by name across all owners, a caller who
can create a cert row (the entity CRUD is not yet authz-gated — Phase 3) could
register a cert named e.g. cert-hanzo under their own org with their own key,
then mint a JWT with kid=cert-hanzo and forged claims; verification (and the
published JWKS) could resolve THEIR key and accept the forgery platform-wide.
- store.GetSigningCert resolves a kid only among signingCertOwners; FindCertByName
(global-by-name) removed.
- JWKS excludes any cert not owned by a platform signing owner.
- signerFor resolves the app's cert through the same trusted path, so signing,
JWKS, and verification stay consistent.
Tests: a tenant cert with a colliding name neither verifies a forged token nor
appears in the JWKS; a non-platform cert is never trusted even as the sole
name-holder. Full suite green + race-clean.
Covers the end-session endpoint's redirect guard: no redirect without a
post_logout_redirect_uri; refuse to redirect when the id_token_hint is
absent/unverified or the URI is not registered by the hint's client; honor +
echo state only for a signature-verified hint whose client registered the URI.
Complete the OIDC/OAuth2 identity core at the canonical /v1/iam/* paths, matching
the live hanzo.id surface so existing clients verify tokens and run the flows
unchanged. Additive to Phase 0-1; v1 stays authoritative until cutover.
- Discovery at /.well-known and /v1/iam/.well-known/openid-configuration; issuer
host-relative and consistent with the tokens' iss.
- JWKS publishes every signing cert's public key — RSA/RS256 (the interop path
every existing verifier reads), EC ES256/384/512, and post-quantum ML-DSA-65
behind the same seam — keyed by kid with x5c, ETag + 60s cache. Fixes the empty
JWKS that left RS256 verifiers unable to resolve a key.
- authorize validates client_id + EXACT redirect_uri before any redirect
(open-redirect defense), normalizes/enforces S256 PKCE, then delegates to the
hosted login which mints the PKCE-bound code.
- token: authorization_code (single-use, redirect+nonce bound, PKCE-for-public
enforced), refresh_token (opaque, rotation + reuse detection + family
revocation), client_credentials; client_secret_basic/post; RFC 6749 error
taxonomy (invalid_client 401 + WWW-Authenticate, else 400); no-store.
- userinfo authenticates the bearer by hash lookup (revocation) AND signature,
returns scope-gated claims. id_token minted on openid, nonce echoed.
- Tokens persisted as SHA-256 hashes only; ML-DSA-65 is a real circl-backed
jwt.SigningMethod, inert unless a cert selects it.
TDD: 85 tests/subtests green — discovery shape, JWKS (RSA/ML-DSA/ETag/dedup/TLS
exclusion), ES256 + full ML-DSA-65 round-trip, authorize validation, code/refresh/
client_credentials flows, PKCE tamper, reuse detection, error taxonomy, tenant
isolation. go vet clean.
CRITICAL: orm serializes every entity to its JSON data column via json.Marshal,
so a field tagged json:"-" is never STORED (not just hidden from responses). The
Casdoor-ported schema used json:"-" on PasswordHash/PasswordSalt/AccessSecretHash
to keep them off API responses (xorm stored via DB columns) — but under orm's
JSON storage that meant they were never persisted. Every retrieved user had an
empty hash → VerifyPassword always failed → login could NEVER succeed.
Fix: give the persisted-credential fields real json tags (passwordHash,
passwordSalt, accessSecretHash) so orm stores them; the users API redact()
already strips every secret from responses (defense the security relies on now).
Verified: login by email → status ok; wrong password → rejected; user CRUD
response still carries NO hash.
Regression test TestPasswordHashPersists proves the hash survives a store
round-trip AND verifies (and wrong-pw fails). 26 oidc tests green.
Follow-up: the nested MFA Secret/SecretKey fields (inside sub-structs) have the
same json:"-" issue — persist them when MFA lands. Root cause is orm conflating
storage-serialization with wire-serialization; orm should honor a storage tag so
this class can't recur (tracked separately).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The one call a host binary makes to embed iam2: server.Mount(app, db) registers
the full IAM v2 surface (OIDC discovery/JWKS, get-app-login, auth/methods, token,
login, entity CRUD) onto the host's zip app over its orm.DB. Plus server.OpenSQLite
+ server.Seed(initDataPath) for boot bootstrap.
This is how iam2 goes live embedded in cloud (the deployed multi-mode zip binary
that already embeds Casdoor via iamserver.Run) — zip-native, lean, no separate
pod. SHADOW-FIRST by contract: the host chooses the mount prefix; mounted under a
shadow prefix iam2 runs ALONGSIDE the live Casdoor /v1/iam/* with zero impact,
and is flipped onto the canonical paths only after verification. Never
blind-replace live auth.
Build + full suite green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The interactive-flow counterpart to the token endpoint: login verifies the
password (bcrypt, constant-time via users.VerifyPassword) and mints a PKCE-bound
authorization code the SDK exchanges at /token. Login by EMAIL or USERNAME,
org-scoped (tenant isolation). 25/25 oidc tests.
- internal/oidc/login.go: POST /v1/iam/login. Resolves user by email (contains
"@") or username within the org; one opaque "username or password is incorrect"
for both no-user and bad-password (no account-existence oracle); type=code →
MintCode (PKCE, S256-only) → persist → return the code in the Response
envelope; type=login → success + userId (session issuance is the next layer).
The password hash never crosses a response.
- internal/store: GetUserByName + GetUserByEmail (org-scoped).
Tests: TestLoginToTokenFlow proves login(by email)→bcrypt-verify→mint code→
redeem at /token→correct user binding; tenant-isolation (wrong org → not found)
+ by-username + by-email lookup. Live: login/token error paths correct
(opaque failure, invalid_grant, single-use replay rejected).
Known follow-up: the happy-path over HTTP needs the users-CRUD create shape and
the login email-lookup reconciled (unit tests seed the user via orm directly and
pass — the logic is proven; the CRUD-seeding integration is a data-shape detail).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Ports old-iam's InitFromFile: iam2 self-seeds orgs/apps/providers/certs from
the SAME init_data.json Casdoor uses, so a fresh store (embedded in cloud or
standalone) comes up with the real config instead of empty. New-only +
idempotent; ${VAR} substituted from env (KMS-synced secret injection, same as
Casdoor).
- internal/seed: FromInitData(ctx, db, path) + Apply — upsert via orm.GetOrCreate
(Get→skip-if-exists, else create), generic-safe field copy through a JSON
round-trip that preserves the wired Model.
- main.go serve --init-data <path>: seed on boot, log the counts.
Verified against the REAL universe init_data.json: seeds 9 orgs, 79 apps, 7
providers, 6 certs; then get-app-login(hanzo-console) → status ok, org hanzo,
4 providers; auth/methods → github+google oauth; lux-id → org lux. So the
config layer "just works" — the path to embedding iam2 in cloud.
Tests: seed round-trip + env-substitution + new-only idempotency, all green.
Remaining for full prod parity: users/password hashes (sensitive — import
separately), the authorize/login endpoints, ML-DSA JWT + JWKS.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Wires the authorization_code grant end to end. 23/23 oidc tests (PKCE 7,
code 10, JWT 5, e2e 1); token error paths verified live.
- internal/oidc/jwt.go — RS256 signer from the Cert entity PEM (PKCS#1/#8);
Claims = registered + scope + owner + email; kid header from cert name;
aud = clientId (validators fail closed on aud). ML-DSA-65 rides the same
Signer later via luxfi/crypto.
- internal/oidc/token.go — POST /v1/iam/oauth/token: authorization_code only
(refresh/client_credentials → unsupported; implicit permanently off). Reads
params query→form→basic; client_id match + confidential-secret check (both
constant-time; public/PKCE client allowed no secret); RedeemCode guard
(replay/expiry/client/PKCE) → IssueAccessToken → RS256 JWT → SaveToken.
Unknown code answers a generic invalid_grant (no oracle).
- internal/store — GetTokenByCode, GetCert, PersistToken, SaveToken.
E2E test proves mint→persist→get-by-code→redeem→sign→save→verify-claims and
that replay after persist fails with ErrCodeUsed. Served locally: token error
paths match RFC 6749 §5.2; discovery advertises the endpoint.
iam2 not in prod — verified by the adversarial suite + live smoke, the exact
logic a red pass scrutinizes. Next: authorize + login/session feed the mint
side; ML-DSA-65 method; real JWKS from the Cert public keys.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The security-critical heart of the OAuth2 code flow, built with an adversarial
test suite (17/17 pass). iam2 is not in prod, so this pre-prod code is verified
by tests + constant-time construction, the exact logic a red pass scrutinizes.
- internal/oidc/pkce.go — RFC 7636 S256 only. ComputeS256Challenge (matches the
RFC Appendix B vector) + VerifyPKCE: constant-time (subtle.ConstantTimeCompare),
"plain" permanently refused (incl. empty method), verifier-with-no-challenge
fails closed, missing-verifier rejected.
- internal/oidc/code.go — the authorization-code lifecycle over the Token entity:
MintCode (256-bit crypto/rand code, binds app+user+PKCE+scope+resource, 5-min
TTL, refuses to store plain), RedeemCode (fail-closed: unknown → used(replay) →
expired → client-mismatch(constant-time) → PKCE), IssueAccessToken (mints the
access token + marks the code one-shot used).
Threat surface proven: RFC vector, replay (ErrCodeUsed), expiry, client mismatch,
wrong verifier, plain downgrade, public-client-must-present-verifier, verifier-
without-challenge. 17/17 tests green; go build clean.
Next: wire RedeemCode into POST /v1/iam/oauth/token inside a store transaction
(mark-used atomic with issue), + JWT signing (RS256/ML-DSA-65 from the Cert
entity) for the access-token body. authorize/login/session feed the mint side.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The read-only front door the @hanzo/iam <Login> calls to self-configure —
pure orm reads, no crypto.
- internal/store: the object layer over orm.TypedQuery (replaces v1 xorm
ormer.Engine). GetApplicationByClientId / GetApplicationByName / GetProvider
/ EnrichProviders. Uses the PascalCase-no-space filter convention
(Filter("ClientId=", v)) matching the Phase-1 CRUD.
- internal/oidc/frontdoor.go:
- GET /v1/iam/get-app-login — resolve app by clientId, MASK client secrets
(browser-facing), enrich each provider link with its shared record.
- GET /v1/iam/auth/methods — the SDK self-config endpoint (does NOT exist in
v1): {password, code, webauthn, web3, signup, oauth[]}. A provider shows
only when configured (real clientId; web3 is native so always on) — the
guard that keeps an unconfigured button from dead-ending.
Verified end-to-end (served locally, seeded app+providers): get-app-login
status=ok with clientSecret masked to ""; auth/methods returns
oauth=[{provider-github,GitHub,logo}] + web3=true + password/code/webauthn/signup.
Found+fixed an orm filter bug (trailing space "clientId =" → never matched;
correct is "ClientId=").
Next (auth-critical, blue/red): session (cookie↔sessions), token endpoint
(PKCE single-use+replay), ML-DSA-65 JWT signing, real JWKS, login/signup,
social hop, web3 SIWx via hanzoai/wallet over ZAP.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Replace the Phase-0 13-struct stubs with the field-complete v2 forms of
every Casdoor-fork identity entity, re-expressed on hanzoai/orm, and add
one typed zip CRUD package per entity wired through routes.Mount(app, db).
Schema (internal/schema): full fields for users, organizations,
applications, providers, roles, permissions, certs, keys,
webauthn_credentials, sessions, tokens, audit_logs, invitations. Shared
value types (ThemeData, MfaItem) are declared once. orm.Register stays
centralized in schema.go (one place; a second call panics on duplicate
kind) alongside Kinds(). Permission's Casbin-model column is carried as
AuthzModel (json:"model") because the embedded orm.Model[Permission]
mixin owns the Go identifier Model.
Handlers (internal/<entity>): one owner-scoped CRUD package per entity,
each exposing a uniform Mount(app, db). Reads project to REST GET + MCP,
writes to POST/PUT/DELETE. Users hashes the plaintext password with
bcrypt exactly once and redacts all secret material on read; every
handler resolves rows by the (owner, name) natural key.
x/crypto promoted to a direct require for bcrypt. orm+zip foundation
(relative replaces, no base) is unchanged.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Clean-room greenfield identity service replacing the Casdoor fork
(hanzoai/iam, Apache-2.0). Founded on hanzoai/orm over SQLite +
zap-proto/zip, deliberately NOT on hanzoai/base — sheds the
luxfi/consensus braid (168 pkgs), minio, and pgx-in-serving-path.
Compile surface 347 pkgs (vs 698 on base); only luxfi/log remains.
- Phase 0: 13 identity entities registered on orm; /v1/iam/v2/health
on zip; ctx-first cobra (serve / compare / version).
- Storage backend-pluggable via one orm.DB: sqlite (default) | sql | datastore.
- Drift-gate `compare` reads v1 Casdoor read-only; the v1 Postgres/MySQL
driver links only under `-tags migration`, keeping the serving binary
SQLite/ZAP-only.
See MIGRATION.md for the phased, drift-gated cutover plan.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-13 03:40:46 -07:00
312 changed files with 48701 additions and 1834 deletions
social federation. The server behind the `@hanzo/iam` SDK. A clean-room
rewrite on the Hanzo stack (`zip` over `hanzoai/orm`) — no Casdoor,
Beego, or xorm. The retired Casdoor fork is `hanzoai/iam-v1` (archived, do not use);
its versions are retracted here — see `TestCasdoorLineageRetracted`.
## License — `MIT OR Apache-2.0`
Dual-licensed at the user's option: `LICENSE-MIT` + `LICENSE-APACHE` (canonical
texts, never edited), `LICENSE` declares the pair. HIP-0130 puts `iam` in the OSS
core tier, so the previous "confidential and proprietary / All rights reserved"
LICENSE contradicted both the HIP and the repo's own public visibility. Every Go
file carries `// SPDX-License-Identifier: MIT OR Apache-2.0` instead of the old
`All rights reserved` header; `go.mod` has no license field, and this repo ships
no Cargo/npm/PyPI manifest, so the SPDX headers plus the three files are the
whole declaration.
Relicensing was Hanzo's alone to do: the tree is original work, not a fork
(`fork: false`, its root commit is its own, and no `v1.*` Casdoor tag is an
ancestor of `main`). Note the Casdoor-lineage tags `v1.0.0`–`v1.31.37` are still
present on this remote even though `go.mod` says they "now live at
`hanzoai/iam-v1`" — anyone checking out one of those tags gets Apache-2.0
Casdoor code under this repo's name. The retraction covers module resolution,
not `git checkout`.
## Role in the model
This is a `hanzoai/<product>` service (impl lives here, DRY — one place). It is
NOT a language SDK. Clients authenticate via the `@hanzo/iam` SDK, never by
hand-rolling OAuth. Full SDK model: `~/work/hanzo/SDK-ARCHITECTURE.md`.
## Build & run
-`go build ./...`
-`go run . serve --init-data init_data.json` (SQLite default; `--store sqlite|sql|datastore`)
-`go run . compare --legacy postgres://…/iam` (needs `-tags migration`)
- Image: `ghcr.io/hanzoai/iam`. Go 1.26.
## Embedding — a host GRAFTS the app, it does not adapt a handler
Two entry points, and they are different verbs for different situations:
| call | what it does | when |
|---|---|---|
| `server.NewApp(db) *zip.App` | the whole IAM surface as a self-contained app | a host composing IAM in process: `app.Graft(iamserver.NewApp(db))` |
| `server.Route(app, db)` | registers IAM's routes ONTO the host's app | only when the host genuinely wants IAM's routes co-mingled with its own. It also brings IAM's root-level routes onto the host, which is what shadowed a host console once |
`server.Handler(db) http.Handler` is **deleted** (was: `adaptor.FiberApp(NewApp(db).Fiber())`).
It existed so a host could hang the whole surface on one wildcard —
`app.All("/v1/iam/*", zip.AdaptNetHTTP(iamserver.Handler(db)))` — and that
adapter is where IAM's knowledge died. `AdaptNetHTTP` takes an `http.Handler`
and returns a closure, so the App went in and a bare function came out, and
IAM's **94 typed ops** went with it. hanzoai/cloud published five wildcard path
keys and 35 placeholder operations where 78 real paths and 94 typed operations
were — no schema, no MCP tool, no CLI command, no SDK method for any of them.
`zip.Graft` (zip v1.18.16) is the composition that keeps them: the host's router
learns IAM's route patterns AND its op registry, while IAM's own router keeps
IAM's behaviour — its `Use(authz.Guard)` seam, its error handler, its config.
Serving is unchanged and strictly cheaper (no net/http round trip). IAM's
`Authorizer` still runs on IAM's ops; the host never re-authorizes them under
its own rules. Named types are published as `iam.<Type>`, because a composed
document carries more than one app's `Application`.
**Liveness is not IAM's.**`/healthz`, `/readyz` and `/metrics` are zip's ops
surface (HIP-0119 §1) — a SECOND listener the DEPLOYMENT brings up when it names
`OPS_PORT`, never the public one. IAM used to register `/healthz` on its public
group; that was hand-rolling a path the framework owns, on the wrong listener,
and it is also what made IAM un-composable: a host registers `/healthz` as the
HOST's, because it must answer while every subsystem is still cold. Two
claimants on one liveness address is what once served `{"binary":"iam2"}` out of
a shared binary.
## Endpoints (HIP-0111 — /v1 only, no /api, no vendor verbs)
# The test gate. One command, run identically by a human and by CI.
#
# Not a bare `go test ./...`: that reuses cached PASS results, so a stale build
# can report green for code you just changed, and it runs without the race
# detector, which is where this repo's store and session defects actually show
# up. -count=1 defeats the cache; -race is the point.
.PHONY:testbuildfmtvetgenerate
# Prose reaches the document ONLY through this step. Go drops comments at compile
# time, so an operation's description cannot be read off the running binary: the
# doc comment on each typed handler is lifted here into the package's
# zipdoc_gen.go, which registers it with zip.Describe at init. That file is
# COMMITTED, because a consumer building this module does not run go generate.
#
# Skipping it does not fail loudly — it publishes an operationId and silence, in
# the OpenAPI document, the MCP tool list and every generated client and CLI. So
# `test` runs zipdoc -check first: a doc comment edited without regenerating is a
# red build, not a quietly stale artifact.
generate:## Lift every typed handler's doc comment into its zipdoc_gen.go.
go generate -run zipdoc ./...
test:## Run the full suite — the gate. Everything must be green to ship.
@set -e;for d in $$(grep -rl '^//go:generate go run github.com/zap-proto/zip/cmd/zipdoc' --include='*.go' . | xargs -n1 dirname | sort -u);do(cd$$d && go run github.com/zap-proto/zip/cmd/zipdoc -check)||{echo"$$d/zipdoc_gen.go is stale — run: make generate";exit 1;};done
provisioning, MFA / WebAuthn, service accounts, and social federation
(Google, GitHub).
It is a **clean-room, native rewrite** on the Hanzo stack — `zip` over
`hanzoai/orm`, **no the legacy surface, no Beego, no xorm**. The identity binary owns its
source outright and collapses to one way of doing each thing. The retired
the legacy surface/Beego fork lives at
[`hanzoai/iam-v1`](https://github.com/hanzoai/iam-v1) and is out of every graph.
Clients never hand-roll OAuth. They authenticate through the **`@hanzo/iam`
SDK** against the endpoints below — one way, no legacy paths (HIP-0111).
## Stack
| Concern | Component | Notes |
|----------------|-----------|-------|
| HTTP | [`zap-proto/zip`](https://github.com/zap-proto/zip) | Typed handlers (`zip.Get[In,Out]`) on the `zap-proto/fiber/v3` engine; specificity routing; OpenAPI 3.1 |
| Storage | [`hanzoai/orm`](https://github.com/hanzoai/orm) (embedded SQLite via hanzoai/sqlite) | Typed Go records + KV cache; typed Go records + KV cache; embedded SQLite (no Postgres), ZAP backends pluggable |
| Authorization | [`hanzoai/authz`](https://github.com/hanzoai/authz) | One canonical policy engine, called over ZAP RPC |
| Inter-service | `zap-proto` | Binary RPC. HTTPS is the external surface only |
| Concern | Component | Notes |
|---|---|---|
| HTTP | [`zap-proto/zip`](https://github.com/zap-proto/zip) | Typed `zip.Get[In,Out]` handlers on the `zap-proto/fiber/v3` engine; specificity routing; OpenAPI 3.1 at the edge |
| Storage | [`hanzoai/orm`](https://github.com/hanzoai/orm) | Typed Go records + KV cache over one `orm.DB` abstraction. Embedded SQLite by default (`hanzoai/sqlite`, pure-Go, WAL) — never Postgres |
| OIDC / OAuth2 | in-tree | RS256 today; ML-DSA-65 hybrid JWT + real JWKS from the Cert entity. No external OIDC library |
| Password verify | `internal/cred` | Algorithm resolved from the stored row — argon2id + bcrypt, verify-only, fail-closed |
| Authorization | [`hanzoai/authz`](https://github.com/hanzoai/authz) | One canonical policy engine, called over ZAP RPC |
| Inter-service | [`zap-proto`](https://github.com/zap-proto) | Binary RPC service↔service. HTTPS is the external edge only |
## Status
## Endpoints — RFC / OIDC standard (no `/api/`, no vendor verbs)
OAuth2/OIDC core is live and tested (login → PKCE code → token → JWT): OIDC
Description:"Removes an application. Anyone mid-sign-in through it is\nturned away and its client credentials stop working, so retire the integration\nbefore deleting it.",
})
zip.Describe("GET /v1/iam/application",zip.Doc{
Description:"Returns one application: its sign-in methods, its allowed\nredirect URIs and the client credentials your integration authenticates with.",
Fields:map[string]string{
"Application.clientId":"ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
"Organization.failedSigninLimit":"Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder":"Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance":"Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
},
})
zip.Describe("GET /v1/iam/applications",zip.Doc{
Description:"Returns the applications in one organization, newest first —\neach product or site your people sign in to, with the sign-in methods and\nredirect URIs it allows.",
Fields:map[string]string{
"Application.clientId":"ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
"Organization.failedSigninLimit":"Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder":"Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance":"Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
Description:"Returns one application: its sign-in methods, its allowed\nredirect URIs and the client credentials your integration authenticates with.",
Fields:map[string]string{
"Application.clientId":"ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
"Organization.failedSigninLimit":"Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder":"Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance":"Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
},
})
zip.Describe("POST /v1/iam/application",zip.Doc{
Description:"Registers an application in your organization — one product or site\nyour people sign in to, with its own client credentials, sign-in methods and\nallowed redirect URIs. A name already used in the organization is refused\nrather than overwritten.\n\nExported so the legacy add-application alias reuses this exact path — one\ncreate, two spellings.",
Fields:map[string]string{
"Application.clientId":"ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
"Organization.failedSigninLimit":"Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder":"Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance":"Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
},
})
zip.Describe("POST /v1/iam/applications",zip.Doc{
Description:"Registers an application in your organization — one product or site\nyour people sign in to, with its own client credentials, sign-in methods and\nallowed redirect URIs. A name already used in the organization is refused\nrather than overwritten.\n\nExported so the legacy add-application alias reuses this exact path — one\ncreate, two spellings.",
Fields:map[string]string{
"Application.clientId":"ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
"Organization.failedSigninLimit":"Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder":"Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance":"Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
Description:"Removes an application. Anyone mid-sign-in through it is\nturned away and its client credentials stop working, so retire the integration\nbefore deleting it.",
Description:"Changes an application's display, its sign-in methods and the redirect\nURIs it may return to — the call that makes login work from a new host. Which\norganization it belongs to and what it is named are fixed when it is created\nand are not editable here.\n\nExported so the legacy update-application alias reuses this exact path — one\nupdate, two spellings.",
Fields:map[string]string{
"Application.clientId":"ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
"Organization.failedSigninLimit":"Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder":"Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance":"Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
},
})
zip.Describe("PUT /v1/iam/application",zip.Doc{
Description:"Changes an application's display, its sign-in methods and the redirect\nURIs it may return to — the call that makes login work from a new host. Which\norganization it belongs to and what it is named are fixed when it is created\nand are not editable here.\n\nExported so the legacy update-application alias reuses this exact path — one\nupdate, two spellings.",
Fields:map[string]string{
"Application.clientId":"ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
"Organization.failedSigninLimit":"Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder":"Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance":"Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
Description:"Returns your organization's audit trail, newest first — who did\nwhat, when, and from where. It is the record you reach for during a security\nreview or an incident.\n\nYou see your own organization's audit trail and no one else's; which organization that\nis comes from your credentials, not from the request.",
Description:"Corrects an audit entry. The trail is append-only in normal operation\nand nothing in the Hanzo Cloud rewrites it — this exists for an administrator\nto correct an entry their own systems recorded wrongly.",
t.Fatalf("capFor(\"keys\") = %+v, want CapKeyMint — without it the ONE key list is SuperAdmin-only and a user cannot see their own keys",capFor("keys"))
}
// Still fail-secure: holding it requires being ON the allow-list, under a reserved
// signing owner. The capability is a grant to a named platform app, not to apps.
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.",
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.",
Description:"Returns your organization's signing certificates, newest first — the keys\nthe tokens your applications verify are signed with. Private key material is\nmasked.\n\nYou see your own organization's certificates and no one else's; which\norganization that is comes from your credentials, not from the request, so a\nquery parameter can never widen the listing.",
Description:"Adds a signing certificate your applications can verify tokens against\n— the call you make to bring your own key, or to stage the next one before a\nrotation. A name already used in your organization is refused.",
Description:"Removes a signing certificate. Tokens signed with it can no longer be\nverified, so retire it only once nothing is still presenting them.",
})
zip.Describe("POST /v1/iam/certs/get",zip.Doc{
Description:"Returns one signing certificate — its algorithm, its validity window and\nits public half. The private key is masked.",
Description:"Reads one record — the older spelling of the single reads on the\nREST surface, over the same data and the same permissions.\n\nSecrets are stripped. Naming a record in another organization does not reach\nit, however the request spells it.",
Description:"Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
})
zip.Describe("GET /v1/iam/get-cert",zip.Doc{
Description:"Reads one record — the older spelling of the single reads on the\nREST surface, over the same data and the same permissions.\n\nSecrets are stripped. Naming a record in another organization does not reach\nit, however the request spells it.",
})
zip.Describe("GET /v1/iam/get-certs",zip.Doc{
Description:"Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
Description:"Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
Description:"Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
Description:"Reads one record — the older spelling of the single reads on the\nREST surface, over the same data and the same permissions.\n\nSecrets are stripped. Naming a record in another organization does not reach\nit, however the request spells it.",
Description:"Returns one organization's projects — what a scope switcher\nlists so somebody can move between them.\n\nYou see your own organization and no other, whatever the request asks for.",
Description:"Returns one organization's workspaces — what a scope\nswitcher lists so somebody can move between them.\n\nYou see your own organization and no other, whatever the request asks for.",
Description:"Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
Description:"Reads one record — the older spelling of the single reads on the\nREST surface, over the same data and the same permissions.\n\nSecrets are stripped. Naming a record in another organization does not reach\nit, however the request spells it.",
Description:"Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
})
zip.Describe("GET /v1/iam/get-provider",zip.Doc{
Description:"Reads one record — the older spelling of the single reads on the\nREST surface, over the same data and the same permissions.\n\nSecrets are stripped. Naming a record in another organization does not reach\nit, however the request spells it.",
})
zip.Describe("GET /v1/iam/get-providers",zip.Doc{
Description:"Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
})
zip.Describe("GET /v1/iam/get-records",zip.Doc{
Description:"Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
})
zip.Describe("GET /v1/iam/get-role",zip.Doc{
Description:"Reads one record — the older spelling of the single reads on the\nREST surface, over the same data and the same permissions.\n\nSecrets are stripped. Naming a record in another organization does not reach\nit, however the request spells it.",
})
zip.Describe("GET /v1/iam/get-roles",zip.Doc{
Description:"Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
})
zip.Describe("GET /v1/iam/get-user",zip.Doc{
Description:"Reads one person, two ways.\n\nName them and it is an ordinary read, with secrets stripped. Or hand it a\nSECRET API key and it answers with the person that key belongs to — how a\nservice of yours turns a credential on an incoming request into an identity.\n\nA publishable key resolves to nobody here, deliberately: it is safe to ship in\na browser precisely because it names an organization and never a person.\n\nget-user is handler-authorized (authz.handlerAuthorizedExact) because the key\nvariant carries no owner/name for the Guard to authorize; so the owner/name\nvariant reinstates the SAME read authorization the Guard applies, through the ONE\npolicy function (authz.Can) — identical behavior, a cross-tenant or non-self read\nstill refused 403 — then reuses the generic getHandler verbatim for resolution and\nredaction. No authz and no CRUD is reimplemented.",
})
zip.Describe("GET /v1/iam/get-users",zip.Doc{
Description:"Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
})
zip.Describe("GET /v1/iam/resolve-key",zip.Doc{
Description:"Answers which organization a PUBLISHABLE key belongs to —\nwhat a service of yours calls to attribute a request that arrived carrying a\nkey shipped in a browser.\n\nIt names an organization and never a person: no path through it can load or\nreturn a user, so a key you put in client code cannot become a way to learn\nwho anyone is. A key that is expired, secret rather than publishable, or\nsimply unknown all answer with the same sentence, and with a `code` saying\nwhich of those it was. Only a confidential service that already proved it may\nresolve keys at all ever reads that code — there is no anonymous caller here\nto probe for which keys exist — and telling it apart is what lets the holder\nbe told to re-mint an expired key instead of hunting a configuration error.",
Description:"Registers an application in your organization — one product or site your\npeople sign in to, with its own client credentials, sign-in methods and\nallowed redirect URIs.\n\nThe older spelling of POST /v1/iam/application. A name already used in the\norganization is refused rather than overwritten.",
Fields:map[string]string{
"Application.clientId":"ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
"Organization.failedSigninLimit":"Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder":"Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance":"Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
"Response.code":"Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
Description:"Creates an organization — the account everything else in your directory\nhangs from. Users, applications, roles, projects and workspaces are all\nnamed inside one organization, so this is the first write in a new tenant.\n\nThe older spelling of POST /v1/iam/organizations. Both reach the same\ncreate, so a name already taken is refused here too.",
"Organization.failedSigninLimit":"Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder":"Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance":"Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
"Response.code":"Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/add-project",zip.Doc{
Description:"Creates a project inside your organization — the scope people pick between\nwhen their work is separated by product or client rather than by team.\n\nThe older spelling of POST /v1/iam/projects. Creating one takes an\nadministrator of the owning organization.",
Fields:map[string]string{
"Response.code":"Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/add-provider",zip.Doc{
Description:"Adds an identity provider your people can sign in with, or a service your\napplications send through — a social or enterprise login, an email or SMS\nsender, a storage or payment connector.\n\nA provider is configured once here and then switched on per application, so\nseveral applications can share one set of credentials.\n\nThe older spelling of POST /v1/iam/providers.",
"Response.code":"Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/add-role",zip.Doc{
Description:"Creates a role — a named group of people that permissions are granted to.\nGranting to a role rather than to each person is what keeps access correct\nas your team changes: add someone to the role and they inherit everything\nit can do.\n\nThe older spelling of POST /v1/iam/roles.",
Fields:map[string]string{
"Response.code":"Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/add-user",zip.Doc{
Description:"Adds a person to your organization and, if you send a password, sets the\none they will sign in with. The password is hashed before it is stored and\nis never returned to you or to anyone else.\n\nUsernames are checked against one rule wherever an account is created —\nthis verb, password signup, a social sign-in, or SCIM — so a name accepted\nhere is a name accepted everywhere.\n\nThe older spelling of POST /v1/iam/users, and it posts the user's fields at\nthe top level rather than wrapped in {user, password}.",
"Permission.model":"Authorization model, targets, and decision. AuthzModel carries the v1\n`model` column (the named authz model); it is not the Go identifier\n`Model` because that name is taken by the embedded orm.Model[Permission]\nmixin. The HTTP contract is unchanged — json:\"model\".",
"Permission.owner":"Identity — the (owner, name) natural key.",
"Permission.users":"Subjects the grant is evaluated for.",
"Response.code":"Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
"User.accessKey":"API credentials. AccessSecret / AccessSecretHash / the OAuth tokens are\nbearer material. AccessSecretHash MUST persist (orm stores via JSON; a\njson:\"-\" field is never saved), so it carries a real json tag and the\nhandler's redact() strips it (and AccessSecret + the token fields) before\nresponding.",
"User.balance":"Balance mirrors v1 for lossless migration but is authoritative in\nCommerce (billing.hanzo.ai), not here — do not write it from IAM.",
"User.createdIp":"Sign-in provenance.",
"User.displayName":"Profile.",
"User.github":"Linked federated-identity subjects, one column per connector (v1 parity).",
"User.id":"Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
"User.isDefaultAvatar":"State flags.",
"User.owner":"Identity / tenancy. (Owner, Name) is the natural key.",
"User.passwordHash":"Credential material. PasswordHash is a one-way bcrypt digest and is\nverify-only. It MUST be persisted (orm serializes the entity to its JSON\ndata column, so a json:\"-\" field would never be stored — that silently\nbroke login), so it carries a real json tag; the users API redact() strips\nit (and every other secret) from every response. PasswordType and\nPasswordSalt describe the digest scheme so rows hashed under the legacy\nargon2id scheme can still be verified and lazily re-hashed to bcrypt.",
"User.roles":"Authorization attachments. Roles and Permissions are computed on read\nfrom the authz store and carried here for API parity with v1.",
"User.webauthnCredentials":"Multi-factor authentication. TotpSecret and RecoveryCodes are secret\nverify-only material — the handler strips them from every response.\nWebauthnCredentials is carried as raw JSON here for lossless migration;\nthe typed passkey model is the sibling WebauthnCredential entity.",
Description:"Creates a workspace inside your organization — the scope a team works in,\nalongside projects rather than instead of them.\n\nThe older spelling of POST /v1/iam/workspaces. Creating one takes an\nadministrator of the owning organization.",
Fields:map[string]string{
"Response.code":"Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
Description:"Deletes an application. Anyone mid-sign-in through it is turned away and\nits client credentials stop working, so retire the integration first.\n\nThe older spelling of DELETE /v1/iam/application.",
Fields:map[string]string{
"Application.clientId":"ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
"Organization.failedSigninLimit":"Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder":"Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance":"Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
"Response.code":"Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
Description:"Deletes an organization and everything named inside it — its users,\napplications, roles, projects and workspaces. There is no undo, and every\nsession issued under it stops working.\n\nThe older spelling of POST /v1/iam/organizations/delete.",
Fields:map[string]string{
"Response.code":"Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
Description:"Deletes a project. The people and roles in your organization are unchanged;\nwhat goes is the scope itself, so anything addressed by it must move first.\n\nThe older spelling of POST /v1/iam/projects/delete.",
Fields:map[string]string{
"Response.code":"Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
Description:"Removes a provider. Sign-in through it stops for every application that\nused it, so detach those applications first if they have no other method.\n\nThe older spelling of POST /v1/iam/providers/delete.",
"Response.code":"Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/delete-role",zip.Doc{
Description:"Deletes a role. Everyone in it loses the access it carried; their accounts\nand any other roles they hold are untouched.\n\nThe older spelling of POST /v1/iam/roles/delete.",
Fields:map[string]string{
"Response.code":"Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/delete-user",zip.Doc{
Description:"Removes a person from your organization. Their sessions stop working and\nthe account is gone, not suspended — to keep the record and only stop\nsign-in, update the user instead.\n\nThe older spelling of POST /v1/iam/users/delete.",
"Permission.model":"Authorization model, targets, and decision. AuthzModel carries the v1\n`model` column (the named authz model); it is not the Go identifier\n`Model` because that name is taken by the embedded orm.Model[Permission]\nmixin. The HTTP contract is unchanged — json:\"model\".",
"Permission.owner":"Identity — the (owner, name) natural key.",
"Permission.users":"Subjects the grant is evaluated for.",
"Response.code":"Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
"User.accessKey":"API credentials. AccessSecret / AccessSecretHash / the OAuth tokens are\nbearer material. AccessSecretHash MUST persist (orm stores via JSON; a\njson:\"-\" field is never saved), so it carries a real json tag and the\nhandler's redact() strips it (and AccessSecret + the token fields) before\nresponding.",
"User.balance":"Balance mirrors v1 for lossless migration but is authoritative in\nCommerce (billing.hanzo.ai), not here — do not write it from IAM.",
"User.createdIp":"Sign-in provenance.",
"User.displayName":"Profile.",
"User.github":"Linked federated-identity subjects, one column per connector (v1 parity).",
"User.id":"Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
"User.isDefaultAvatar":"State flags.",
"User.owner":"Identity / tenancy. (Owner, Name) is the natural key.",
"User.passwordHash":"Credential material. PasswordHash is a one-way bcrypt digest and is\nverify-only. It MUST be persisted (orm serializes the entity to its JSON\ndata column, so a json:\"-\" field would never be stored — that silently\nbroke login), so it carries a real json tag; the users API redact() strips\nit (and every other secret) from every response. PasswordType and\nPasswordSalt describe the digest scheme so rows hashed under the legacy\nargon2id scheme can still be verified and lazily re-hashed to bcrypt.",
"User.roles":"Authorization attachments. Roles and Permissions are computed on read\nfrom the authz store and carried here for API parity with v1.",
"User.webauthnCredentials":"Multi-factor authentication. TotpSecret and RecoveryCodes are secret\nverify-only material — the handler strips them from every response.\nWebauthnCredentials is carried as raw JSON here for lossless migration;\nthe typed passkey model is the sibling WebauthnCredential entity.",
Description:"Deletes a workspace. The people and roles in your organization are\nunchanged; what goes is the scope itself.\n\nThe older spelling of POST /v1/iam/workspaces/delete.",
Fields:map[string]string{
"Response.code":"Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
Description:"Updates one of your applications — its display, its sign-in methods and the\nredirect URIs it is allowed to return to. Which organization and name the\napplication has are fixed when it is created and are not editable here.\n\nA redirect URI you add becomes an allowed sign-in origin, so this is the\ncall that makes login work from a new host.\n\nThe older spelling of PUT /v1/iam/application.",
Fields:map[string]string{
"Application.clientId":"ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
"Organization.failedSigninLimit":"Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder":"Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance":"Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
"Response.code":"Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
Description:"Updates your organization — its display, its default settings and the\nsign-in rules everyone in it inherits.\n\nThe older spelling of POST /v1/iam/organizations/update.",
"Organization.failedSigninLimit":"Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
"Organization.founder":"Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
"Organization.orgBalance":"Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
"Response.code":"Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
Description:"Updates a provider's settings or rotates the credentials it holds. The\nchange takes effect on the next sign-in through it — sessions already\nissued are unaffected.\n\nThe older spelling of POST /v1/iam/providers/update.",
"Response.code":"Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/update-role",zip.Doc{
Description:"Updates a role's members or the roles it includes. Access changes for\neveryone in it as soon as the write lands.\n\nThe older spelling of POST /v1/iam/roles/update.",
Fields:map[string]string{
"Response.code":"Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
},
})
zip.Describe("POST /v1/iam/update-user",zip.Doc{
Description:"Updates one of your users' profile, roles or credentials. Send a password\nto reset it; leave it out and the current one stands.\n\nThe older spelling of POST /v1/iam/users/update, with the user's fields at\nthe top level rather than wrapped in {user, password}.",
"Permission.model":"Authorization model, targets, and decision. AuthzModel carries the v1\n`model` column (the named authz model); it is not the Go identifier\n`Model` because that name is taken by the embedded orm.Model[Permission]\nmixin. The HTTP contract is unchanged — json:\"model\".",
"Permission.owner":"Identity — the (owner, name) natural key.",
"Permission.users":"Subjects the grant is evaluated for.",
"Response.code":"Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
"User.accessKey":"API credentials. AccessSecret / AccessSecretHash / the OAuth tokens are\nbearer material. AccessSecretHash MUST persist (orm stores via JSON; a\njson:\"-\" field is never saved), so it carries a real json tag and the\nhandler's redact() strips it (and AccessSecret + the token fields) before\nresponding.",
"User.balance":"Balance mirrors v1 for lossless migration but is authoritative in\nCommerce (billing.hanzo.ai), not here — do not write it from IAM.",
"User.createdIp":"Sign-in provenance.",
"User.displayName":"Profile.",
"User.github":"Linked federated-identity subjects, one column per connector (v1 parity).",
"User.id":"Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
"User.isDefaultAvatar":"State flags.",
"User.owner":"Identity / tenancy. (Owner, Name) is the natural key.",
"User.passwordHash":"Credential material. PasswordHash is a one-way bcrypt digest and is\nverify-only. It MUST be persisted (orm serializes the entity to its JSON\ndata column, so a json:\"-\" field would never be stored — that silently\nbroke login), so it carries a real json tag; the users API redact() strips\nit (and every other secret) from every response. PasswordType and\nPasswordSalt describe the digest scheme so rows hashed under the legacy\nargon2id scheme can still be verified and lazily re-hashed to bcrypt.",
"User.roles":"Authorization attachments. Roles and Permissions are computed on read\nfrom the authz store and carried here for API parity with v1.",
"User.webauthnCredentials":"Multi-factor authentication. TotpSecret and RecoveryCodes are secret\nverify-only material — the handler strips them from every response.\nWebauthnCredentials is carried as raw JSON here for lossless migration;\nthe typed passkey model is the sibling WebauthnCredential entity.",
Description:"Returns your organization's invitations, newest first — who has\nbeen asked to join, on what terms, and how many seats each invitation still\nhas left.\n\nYou see your own organization's invitations and no one else's; which organization that\nis comes from your credentials, not from the request.",
Description:"Issues an invitation to join your organization — the code or link a new\nmember redeems, with the role they arrive holding and the date it stops\nworking. A name already used in the organization is refused.",
Description:"Returns your organization's API keys, newest first — what each is called,\nwhat it may reach, and its publishable half. Secret halves are never listed.",
Fields:map[string]string{
"Key.accessKey":"AccessKey (pk-*) is the publishable identifier and lookup index;\nAccessSecret (sk-*) is the confidential secret.",
"Key.createdTime":"CreatedTime and UpdatedTime are RFC3339 audit stamps carried as strings\nfor byte-parity with the v1 row (orm.Model separately tracks CreatedAt /\nUpdatedAt as time.Time for the store's own lifecycle).",
"Key.displayName":"DisplayName is the human-facing label.",
"Key.expireTime":"ExpireTime is when the key stops being honored (empty = never). State is\nthe lifecycle flag (\"Active\", \"test\", …); \"test\" mints test-env\ncredentials instead of live ones.",
"Key.owner":"Owner is the tenant that holds the key; Name is unique within Owner.",
"Key.scope":"Scope is the key's ACCESS CLASS, orthogonal to Type (which names the bound\nprincipal). Empty (the default, \"secret\") is a full key: a pk- publishable\nhalf AND a confidential sk- half, the sk- authenticating a server-side reader.\nKeyScopePublish is a WRITE-ONLY publishable key — a pk- half only, no secret —\nthat resolves to just an ORG (never a principal) at the ingest door and is safe\nto ship in client JS. A missing value on an existing row reads as the default,\nso every pre-Scope key is a secret key unchanged.",
"Key.type":"Type is the scope the key is bound to — \"Organization\", \"Application\",\n\"User\", or \"General\" — and Organization / Application / User name the\nconcrete principal for whichever scope Type selects.",
Description:"Returns one API key: what it is called, what it may reach, and when it was\nissued.",
Fields:map[string]string{
"Key.accessKey":"AccessKey (pk-*) is the publishable identifier and lookup index;\nAccessSecret (sk-*) is the confidential secret.",
"Key.createdTime":"CreatedTime and UpdatedTime are RFC3339 audit stamps carried as strings\nfor byte-parity with the v1 row (orm.Model separately tracks CreatedAt /\nUpdatedAt as time.Time for the store's own lifecycle).",
"Key.displayName":"DisplayName is the human-facing label.",
"Key.expireTime":"ExpireTime is when the key stops being honored (empty = never). State is\nthe lifecycle flag (\"Active\", \"test\", …); \"test\" mints test-env\ncredentials instead of live ones.",
"Key.owner":"Owner is the tenant that holds the key; Name is unique within Owner.",
"Key.scope":"Scope is the key's ACCESS CLASS, orthogonal to Type (which names the bound\nprincipal). Empty (the default, \"secret\") is a full key: a pk- publishable\nhalf AND a confidential sk- half, the sk- authenticating a server-side reader.\nKeyScopePublish is a WRITE-ONLY publishable key — a pk- half only, no secret —\nthat resolves to just an ORG (never a principal) at the ingest door and is safe\nto ship in client JS. A missing value on an existing row reads as the default,\nso every pre-Scope key is a secret key unchanged.",
"Key.type":"Type is the scope the key is bound to — \"Organization\", \"Application\",\n\"User\", or \"General\" — and Organization / Application / User name the\nconcrete principal for whichever scope Type selects.",
Description:"Issues an API key. A standard key comes back as a publishable half you\nmay ship in client code and a secret half you must not — the secret is shown\nonce, at creation, and cannot be retrieved afterwards. A publish-scoped key is\nissued with the publishable half only, so there is no secret to leak.\n\nA name already used in your organization is refused rather than reissued, so\ncreating twice never silently invalidates a key that is in production.",
Fields:map[string]string{
"Key.accessKey":"AccessKey (pk-*) is the publishable identifier and lookup index;\nAccessSecret (sk-*) is the confidential secret.",
"Key.createdTime":"CreatedTime and UpdatedTime are RFC3339 audit stamps carried as strings\nfor byte-parity with the v1 row (orm.Model separately tracks CreatedAt /\nUpdatedAt as time.Time for the store's own lifecycle).",
"Key.displayName":"DisplayName is the human-facing label.",
"Key.expireTime":"ExpireTime is when the key stops being honored (empty = never). State is\nthe lifecycle flag (\"Active\", \"test\", …); \"test\" mints test-env\ncredentials instead of live ones.",
"Key.owner":"Owner is the tenant that holds the key; Name is unique within Owner.",
"Key.scope":"Scope is the key's ACCESS CLASS, orthogonal to Type (which names the bound\nprincipal). Empty (the default, \"secret\") is a full key: a pk- publishable\nhalf AND a confidential sk- half, the sk- authenticating a server-side reader.\nKeyScopePublish is a WRITE-ONLY publishable key — a pk- half only, no secret —\nthat resolves to just an ORG (never a principal) at the ingest door and is safe\nto ship in client JS. A missing value on an existing row reads as the default,\nso every pre-Scope key is a secret key unchanged.",
"Key.type":"Type is the scope the key is bound to — \"Organization\", \"Application\",\n\"User\", or \"General\" — and Organization / Application / User name the\nconcrete principal for whichever scope Type selects.",
Description:"Revokes an API key. Anything still presenting it stops being authorized at\nonce, so roll the replacement out before you revoke.",
})
zip.Describe("POST /v1/iam/keys/update",zip.Doc{
Description:"Changes what a key is called or what it may reach. The credential\nitself is not reissued — the key in your deployment keeps working.",
Fields:map[string]string{
"Key.accessKey":"AccessKey (pk-*) is the publishable identifier and lookup index;\nAccessSecret (sk-*) is the confidential secret.",
"Key.createdTime":"CreatedTime and UpdatedTime are RFC3339 audit stamps carried as strings\nfor byte-parity with the v1 row (orm.Model separately tracks CreatedAt /\nUpdatedAt as time.Time for the store's own lifecycle).",
"Key.displayName":"DisplayName is the human-facing label.",
"Key.expireTime":"ExpireTime is when the key stops being honored (empty = never). State is\nthe lifecycle flag (\"Active\", \"test\", …); \"test\" mints test-env\ncredentials instead of live ones.",
"Key.owner":"Owner is the tenant that holds the key; Name is unique within Owner.",
"Key.scope":"Scope is the key's ACCESS CLASS, orthogonal to Type (which names the bound\nprincipal). Empty (the default, \"secret\") is a full key: a pk- publishable\nhalf AND a confidential sk- half, the sk- authenticating a server-side reader.\nKeyScopePublish is a WRITE-ONLY publishable key — a pk- half only, no secret —\nthat resolves to just an ORG (never a principal) at the ingest door and is safe\nto ship in client JS. A missing value on an existing row reads as the default,\nso every pre-Scope key is a secret key unchanged.",
"Key.type":"Type is the scope the key is bound to — \"Organization\", \"Application\",\n\"User\", or \"General\" — and Organization / Application / User name the\nconcrete principal for whichever scope Type selects.",
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.",
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).",
Description:"Takes away a person's or an application's right to act in an\norganization. Their account survives; what ends is their access to that\norganization. Revoking a membership that is already gone reports that nothing\nwas removed rather than failing, so a retry is safe. It is the mirror of ensure and takes the SAME gate:\nrevoking membership is the org's authority to give or take, so a SuperAdmin, an\nadmin of the org itself, or an org-admin-capable confidential client. Idempotent\nthrough the store — deleting an absent membership reports removed=false, never an\nerror — so a retried revoke is safe.",
})
zip.Describe("POST /v1/iam/memberships",zip.Doc{
Description:"Lets a person or an application act in an organization. It is the grant\nbehind \"add someone to the team\", and it is safe to repeat — granting a\nmembership that already exists changes nothing. Granting membership IS the org's authority to give, so it takes the\nsame gate a write to that org's own registry row takes: a SuperAdmin, an admin\nof the org itself, or an org-admin-capable confidential client. One rule, one\nplace (internal/authz).",
Description:"Turns off the authenticator app for an account, so sign-in stops\nasking for a code. People may do this for themselves; doing it for somebody\nelse takes an administrator, which is what makes it the reset path when a\nphone is lost.",
})
zip.Describe("POST /v1/iam/mfa/disable",zip.Doc{
Description:"Turns off the authenticator app for an account, so sign-in stops\nasking for a code. People may do this for themselves; doing it for somebody\nelse takes an administrator, which is what makes it the reset path when a\nphone is lost.",
Description:"Finishes the enrolment: from here the account's sign-ins ask for a code\nfrom the authenticator app. Repeating it re-enrols rather than failing.",
Description:"Starts enrolling an authenticator app: it returns a fresh secret, a\nURL to render as a QR code, and one recovery code to keep somewhere safe.\n\nNothing is switched on yet. The enrolment counts only once it is confirmed with\na code from the app, so abandoning this step leaves the account exactly as it\nwas. Response:\n{status:\"ok\", data:{secret, url, recoveryCodes:[code]}}.",
Description:"Checks a six-digit code against an enrolment in progress, so somebody\ncan confirm their authenticator app is set up correctly before it starts being\nrequired. Clocks a step out either way are accepted.\nA valid code → {status:\"ok\"}; an invalid one → 200 {status:\"error\"} (the\ncasibase convention: clients branch on status, not the HTTP code).",
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)
}
}
}
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.