Compare commits

...
1 Commits
Author SHA1 Message Date
hanzo-dev 6af184afcb feat(org): tenancy — verb face, memberships, service accounts, client capabilities
The organization/tenancy layer: the verb face live consumers call, the
membership relation a token carries, agent identities, and the capability model
that closes v1's "every client credential is a global admin" hole.

- confidential-client principals (authz.app): client_secret_basic resolves to a
  Principal that is never Admin/Super — its whole authority is its capability
  allowlist (cap.go), keyed on the admin-owned application NAME, so a leaked
  client credential can cross no tenant and touch no signing material. httpx.Basic
  is the one RFC 7617 parser.
- authz.Can: the pure policy exported for the verb face; selfPaths carry their own
  authorization (id=<owner>/<name> or an org-scoped listing) — one policy, two
  faces. The reserved-owner rule gains the tenant-registry exception: an org row
  is the tenant's OWN record (v1 CapOrgAdmin), keyed on entity so certs/apps/
  providers/users under a reserved owner stay refused.
- internal/cred: the ONE home for credential digests — Verify dispatches on the
  row's own scheme (argon2id/bcrypt) and never re-hashes; hard-coding one algo
  would silently lock out every account minted under another.
- Subject (oidc/subject.go): every principal claim resolved from the user record
  in one place; Sign/SignID take a Subject so a request cannot forge isAdmin.
  Claims gain the subject block (orgs/roles/permissions/groups).
- Membership entity + store ops + HTTP face: the (User x Org x Role) relation and
  the `orgs` claim the edge authorizes an org-switch against (X-Org-Id in orgs);
  the home org is an implicit membership.
- service accounts: agent/bot identities as Type=service-account user rows — no
  password, key secret stored only as an argon2id digest, mint/list/rotate/revoke
  under v1's capability authorization.
- organization record policy (normalize/validate) applied at both write entry
  points; the verb face (verbs.go) shares the one OrganizationAPI core.
- keys.Mint exported as the single credential-minting primitive.
2026-07-19 19:44:47 -07:00
19 changed files with 1747 additions and 79 deletions
+135 -20
View File
@@ -46,6 +46,7 @@ package authz
import (
"context"
"crypto/subtle"
"errors"
"reflect"
"strings"
@@ -65,12 +66,20 @@ import (
const adminOrg = "admin"
// Principal is the identity a gated request acts as, resolved from a verified
// bearer. Org is the tenant (the authenticated principal's own org, from the
// subject); User is its name within that org (empty for a machine token); Admin
// is the org-admin flag; Super is the SuperAdmin predicate (Org == adminOrg).
// bearer or from a confidential client's Basic credential. Org is the tenant
// (the authenticated principal's own org, from the subject); User is its name
// within that org (empty for a machine token); Admin is the org-admin flag;
// Super is the SuperAdmin predicate (Org == adminOrg).
//
// App is the application NAME when the request authenticated as a confidential
// client (client_secret_basic), and "" for every human. An app principal is
// never Admin and never Super — its whole authority is its capability allowlist
// (cap.go), so a leaked client credential can neither read another tenant nor
// touch signing material.
type Principal struct {
Org string
User string
App string
Admin bool
Super bool
}
@@ -131,16 +140,47 @@ var publicPaths = map[string]bool{
"/v1/iam/auth/methods": true, // pre-login method list
}
// selfPaths is the CLOSED set of GATED routes that carry their OWN
// authorization — the verb face (HIP-0111 §6) the live consumers call. Their
// read target is an `id=<owner>/<name>` or an org-scoped listing, not the
// ?owner=&name= pair the generic rule reads, so the Guard has nothing to
// authorize here and would refuse every one of them on an empty owner.
//
// The Guard still AUTHENTICATES them (no credential ⇒ 401); it skips ONLY the
// target rule. Each handler then authorizes through the SAME pure policy — Can
// for a single target, Scope for a listing — before it touches the store. One
// policy, two faces. Listing a path here MOVES its check into that handler; it
// never removes it, and each is pinned by a test.
var selfPaths = map[string]bool{
"/v1/iam/get-organization": true, // ?id=admin/<slug> → Can(GET, organizations)
"/v1/iam/get-organizations": true, // owner-scoped listing → Scope
"/v1/iam/service-accounts": true, // ?organization=<org> → the service-account read gate
"/v1/iam/memberships": true, // ?user=|?org= → Scope
}
// isPublic reports whether path is in the public allowlist. A trailing slash is
// trimmed first so /v1/iam/login/ resolves like /v1/iam/login — the same route
// fiber serves. It can only ever widen matches to the fixed public set, never
// turn a gated path into a public one (no gated path equals a public path plus a
// slash), so the fail-closed default holds.
func isPublic(path string) bool {
return publicPaths[trimmed(path)]
}
// isSelf reports whether path authorizes itself (selfPaths), normalized the same
// way as isPublic.
func isSelf(path string) bool {
return selfPaths[trimmed(path)]
}
// trimmed normalizes a request path for allowlist lookup by dropping a trailing
// slash, so /v1/iam/login/ resolves like /v1/iam/login — the same route fiber
// serves.
func trimmed(path string) string {
if len(path) > 1 {
path = strings.TrimRight(path, "/")
return strings.TrimRight(path, "/")
}
return publicPaths[path]
return path
}
// isRead reports whether a method addresses its target through the query string
@@ -167,7 +207,8 @@ func Guard(db orm.DB) zip.Handler {
if err != nil {
return zip.ErrUnauthorized("authentication required")
}
if isRead(c.Method()) && !authorize(p, c.Method(), entityOf(c.Path()), c.Query("owner"), c.Query("name")) {
if isRead(c.Method()) && !isSelf(c.Path()) &&
!authorize(p, c.Method(), entityOf(c.Path()), c.Query("owner"), c.Query("name")) {
return zip.ErrForbidden("forbidden")
}
c.SetContext(context.WithValue(c.Context(), ctxKey{}, p))
@@ -204,32 +245,72 @@ func Authorize(ctx context.Context, op zip.Op, in any) error {
return nil
}
// Can reports whether the ctx principal may act on (entity, owner, name) with
// method. It is the SAME pure policy the Guard applies to the entity face,
// exported for the verb face, whose target rides in `id=<owner>/<name>` rather
// than in ?owner=&name=. One policy, two faces — a verb handler never restates
// the rule, so the faces cannot drift apart. No principal (an unauthenticated
// request that reached a gated handler) is refused.
func Can(ctx context.Context, method, entity, owner, name string) bool {
p, ok := From(ctx)
if !ok {
return false
}
return authorize(p, method, entity, owner, name)
}
// authorize is the pure authorization decision: may p act on a resource owned by
// `owner` (named `name`) on the given entity? The order IS the policy:
//
// 1. SuperAdmin may do anything — the only cross-tenant scope.
// 2. A platform-owned resource (admin/built-in — the reserved owners the token
// verifier trusts to sign) is writable only by a SuperAdmin. This single
// rule is the signing-cert poisoning gate, the admin-scoped app/provider
// registration gate, AND the built-in-org gap, all at once: a built-in-org
// principal is not SuperAdmin (that is admin only), so it cannot write a
// built-in-owned signing cert either.
// 3. Tenant isolation: a normal principal may act only within its OWN org. An
// empty or foreign owner is refused — the target org is bound to the
// principal, never trusted from the request.
// 4. Inside its own org, an org admin manages everything; a regular user may
//
// 2. A resource under a reserved owner (admin/built-in — the owners the token
// verifier trusts to sign) is SuperAdmin-only. This single rule is the
// signing-cert poisoning gate, the admin-scoped app/provider registration
// gate, AND the built-in-org gap at once: a built-in-org principal is not
// SuperAdmin (that is admin only), so it cannot write a built-in-owned
// signing cert either. It is also what keeps a user OUT of the reserved
// admin org: no capability moves a user under owner=="admin", because a
// user in the admin org IS a SuperAdmin — provision, never promote.
//
// The ONE exception is the tenant registry. Every organization row is filed
// under the admin owner (`admin/<slug>`), but an org row is the TENANT'S own
// record, not platform trust material: a tenant reads its own org, its admin
// edits its own org's branding, and an org-admin-capable confidential client
// manages orgs during onboarding. That is exactly v1's rule
// (controllers/organization.go:113-123, requireAppCapability(CapOrgAdmin)).
// The exception is keyed on the entity, so certs, applications, providers,
// and users under a reserved owner stay refused.
//
// 3. A confidential client's authority is its capability allowlist and nothing
// else. It is checked here, once, for every entity and both faces — never
// Super, never Admin, and an unmapped entity or unset allowlist denies.
//
// 4. Tenant isolation: a human may act only within its OWN org. An empty or
// foreign owner is refused — the target org is bound to the principal, never
// trusted from the request.
//
// 5. Inside its own org, an org admin manages everything; a regular user may
// only READ its own user record (self-service). The users entity serves
// reads as GET and writes as POST, so gating the self clause to GET keeps a
// regular user from writing its own record — a raw entity write would
// otherwise let it carry isAdmin and self-promote. Privileged self-mutation
// is the Phase-5 provision-don't-promote concern; here it is closed by
// denial.
// otherwise let it carry isAdmin and self-promote.
func authorize(p *Principal, method, entity, owner, name string) bool {
if p.Super {
return true
}
if store.IsSigningCertOwner(owner) {
return false
if entity != "organizations" {
return false
}
if p.App != "" {
return Allowed(p, CapOrgAdmin)
}
// The tenant's OWN org row: anyone in it reads it, its admin writes it.
return name == p.Org && (isRead(method) || p.Admin)
}
if p.App != "" {
return Allowed(p, capFor(entity))
}
if owner == "" || owner != p.Org {
return false
@@ -302,6 +383,9 @@ func stringField(v reflect.Value, name string) string {
// identities explicit scope. This closes the phantom-admin subject: a token for
// "admin/<nobody>" resolves to no authority, not SuperAdmin.
func principal(c *zip.Ctx, db orm.DB) (*Principal, error) {
if p, ok := app(c, db); ok {
return p, nil
}
bearer := httpx.Bearer(c)
if bearer == "" {
return nil, errNoBearer
@@ -330,6 +414,37 @@ func principal(c *zip.Ctx, db orm.DB) (*Principal, error) {
return &Principal{Org: owner}, nil
}
// app resolves an `Authorization: Basic <clientId>:<clientSecret>` credential
// into a confidential-client Principal — the transport every live server-side
// consumer authenticates with (RFC 6749 §2.3.1 client_secret_basic; cloud reads
// IAM_MINT_CLIENT_ID/SECRET and sends exactly this). The application NAME is the
// identity, because the capability allowlists key on the name.
//
// It is deliberately NOT an authority: the returned Principal is never Admin and
// never Super, so the ONLY thing it can do is what its name is allowlisted for
// (authorize → Allowed). This is what keeps the v1 "every confidential client is
// a global admin" hole closed as the transport is re-added.
//
// Fail-closed: an unparseable header, an unknown clientId, an application with no
// registered secret, an empty presented secret (a public client must never
// authenticate as an app), or a mismatch all report false — the caller then
// finds no bearer either and answers 401. The comparison is constant-time, so a
// prober cannot recover a secret byte by byte.
func app(c *zip.Ctx, db orm.DB) (*Principal, bool) {
id, secret, ok := httpx.Basic(c)
if !ok || id == "" || secret == "" {
return nil, false
}
a, err := store.GetApplicationByClientId(c.Context(), db, id)
if err != nil || a == nil || a.ClientSecret == "" {
return nil, false
}
if subtle.ConstantTimeCompare([]byte(a.ClientSecret), []byte(secret)) != 1 {
return nil, false
}
return &Principal{App: a.Name, Org: a.Organization}, true
}
// entityOf returns the resource segment of an /v1/iam/<entity>[/verb] path, or
// "" for anything else (e.g. /mcp). Only the users entity needs distinguishing —
// its regular-user self-service rule — so every other segment is treated
+113
View File
@@ -0,0 +1,113 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package authz
import (
"os"
"strings"
)
// Confidential-client capabilities — the port of the v1 gate (object/app_authz.go
// + controllers/app_mutation_guard.go requireAppCapability) that revoked the
// "every client credential is a global admin" privilege.
//
// A Cap is a named authority an app principal holds ONLY when its application
// name is listed in the allowlist Env names. It is the ONLY thing an app
// principal's authority is made of: an app is never a SuperAdmin and never an
// org admin (see Principal), so a leaked client credential grants exactly the
// capabilities its NAME was allowlisted for and nothing more.
//
// The key is the application NAME, not its (owner, name) row — a name in an
// allowlist is thereby reserved to the platform's admin-owned app, so a tenant
// cannot register <theirOrg>/hanzo-console and inherit its grants.
// Cap is one capability: a Name for diagnostics and the Env var holding its
// comma-separated allowlist of application names.
type Cap struct {
Name string
Env string
}
// The capability set, matching the live allowlists byte-for-byte
// (universe infra/k8s/operator/crs/iam.yaml). Every one is fail-secure: an unset
// or empty allowlist denies EVERY app.
var (
// CapKeyMint gates minting, rotating, or revoking a credential on another
// principal's behalf — the service-account administration boundary, since a
// minted key is an org-billing credential.
CapKeyMint = Cap{Name: "key-mint", Env: "IAM_KEY_MINT_ALLOWED_APPS"}
// CapUserAdmin gates cross-user account mutation (owner, isAdmin, email,
// type, credentials) — cloud moves an onboarding user into the org it just
// created through this.
CapUserAdmin = Cap{Name: "user", Env: "IAM_USER_ADMIN_APPS"}
// CapOrgAdmin gates organization create/read/update/delete. Unlike the
// signing-material capabilities this one is populated in every environment:
// the brand consoles legitimately create customer orgs during onboarding.
CapOrgAdmin = Cap{Name: "organization", Env: "IAM_ORG_ADMIN_APPS"}
// CapServiceAccountRead gates LISTING an org's service accounts — names and
// metadata only, never secrets. It is the read-only counterpart to
// CapKeyMint (a read cap can never mint, rotate, or delete a credential) and
// is additionally tenant-bound by BoundToOrg.
CapServiceAccountRead = Cap{Name: "service-account-read", Env: "IAM_SA_LIST_ALLOWED_APPS"}
)
// Allowed reports whether p holds c.
//
// A non-app principal holds every capability vacuously: this gate concerns
// confidential clients ONLY, and a human's authority is decided by the org
// policy in authorize(). Conflating the two would either lock every human out or
// hand every app a human's scope.
//
// Fail-secure, exactly as v1: an app whose allowlist is unset, empty, or does
// not name it holds nothing.
func Allowed(p *Principal, c Cap) bool {
if p == nil {
return false
}
if p.App == "" {
return true // not an app; the org policy decides
}
if c.Env == "" {
return false
}
for _, item := range strings.Split(os.Getenv(c.Env), ",") {
if strings.TrimSpace(item) == p.App {
return true
}
}
return false
}
// BoundToOrg reports whether an app principal is bound to org by the
// <org>-<app> naming convention — app/hanzo-team may act on organization=hanzo
// and on no other tenant's. The org is derived from the (allowlist-reserved)
// application NAME, so the binding holds regardless of the app row's owner, and
// it is the same prefix rule the service-account names it reads obey.
func BoundToOrg(p *Principal, org string) bool {
if p == nil || org == "" {
return false
}
prefix := org + "-"
return len(p.App) > len(prefix) && strings.HasPrefix(p.App, prefix)
}
// capFor maps an entity to the capability a confidential client needs to act on
// it. An entity with NO mapping grants an app nothing: unmapped denies exactly
// as an unset allowlist does, which IS v1's live behaviour for every capability
// the deployment leaves empty — certs, providers, tokens, syncers, webhooks are
// all deny-all by design, because no client credential should ever reach signing
// material. Only the two entities a live confidential client touches are mapped:
// the brand consoles create customer orgs, and cloud moves the onboarding user
// into the org it just created.
func capFor(entity string) Cap {
switch entity {
case "organizations":
return CapOrgAdmin
case "users":
return CapUserAdmin
}
return Cap{}
}
+132
View File
@@ -0,0 +1,132 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package cred is the ONE home for credential digests: how a secret is stored,
// and how a presented secret is checked against a stored one.
//
// The digest scheme is a property of the STORED ROW, never a constant. Live v1
// rows carry argon2id (the platform default, and the scheme every
// service-account secret was minted under); rows written by iam2's own user
// create/update carry bcrypt; the scheme's name travels with the row
// (schema.User.PasswordType, falling back to its organization's PasswordType —
// v1 object/check.go:244-249). Verify dispatches on that name, so an imported row
// verifies under the scheme it was actually written with. Hard-coding one
// algorithm silently locks out every account minted under another — the whole
// point of this package (MIGRATION.md, the argon2id blocker).
//
// Verify NEVER re-hashes. An upgrade-on-login is a deliberate write on the write
// path, not a side effect of a read.
package cred
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"
)
// The digest schemes a stored row can name. Argon2id is the platform default —
// what Hash writes and what every live v1 row carries.
const (
Argon2id = "argon2id"
Bcrypt = "bcrypt"
)
// argon2id parameters, matching the live v1 mint (github.com/alexedwards/argon2id
// DefaultParams, used by object/service_account.go MintServiceAccountKey). They
// govern only what a NEW digest is written with: every stored PHC string carries
// the parameters it was created under, and Verify reads them from the hash, so a
// row minted under different parameters still verifies.
const (
argonTime = 1
argonMemory = 64 * 1024 // KiB
argonThreads = 2
argonSaltLen = 16
argonKeyLen = 32
argonVersion = argon2.Version // 19
)
// errFormat is the single opaque parse failure. A caller only ever learns
// "this did not verify", never which part of a stored digest was malformed.
var errFormat = errors.New("cred: hash is not in the expected format")
// Hash derives the platform-default digest — argon2id, encoded as the standard
// PHC string `$argon2id$v=19$m=...,t=...,p=...$<salt>$<key>` that the Argon2
// reference implementation and every live v1 row use. The salt is fresh random
// per call, so two identical secrets never share a digest.
func Hash(secret string) (string, error) {
salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(secret), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argonVersion, argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(key)), nil
}
// Verify reports whether presented matches the stored digest under the scheme
// `kind` — the scheme the ROW says it was written with. An empty kind means the
// row never recorded one, which in v1 inherits the organization default and
// finally the platform default (argon2id): resolve that at the caller, which is
// the only layer holding the org, and pass the resolved name here.
//
// Fail-closed on every axis: an unknown scheme, an empty digest, an empty
// presented secret, or a malformed stored digest all report false. Both schemes
// compare in constant time (bcrypt inherently; argon2id via subtle).
func Verify(kind, stored, presented string) bool {
if stored == "" || presented == "" {
return false
}
switch kind {
case Argon2id:
return verifyArgon2id(stored, presented)
case Bcrypt:
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(presented)) == nil
}
return false
}
// verifyArgon2id recomputes the key from the presented secret under the
// parameters and salt the stored PHC string carries, and compares the two keys
// in constant time.
func verifyArgon2id(stored, presented string) bool {
time, memory, threads, salt, key, err := decode(stored)
if err != nil {
return false
}
other := argon2.IDKey([]byte(presented), salt, time, memory, threads, uint32(len(key)))
return subtle.ConstantTimeCompare(key, other) == 1
}
// decode parses a PHC argon2id string into the parameters, salt, and key it
// carries. It accepts exactly what the Argon2 reference format (and every live
// v1 row) emits, and refuses another variant or version rather than guessing.
func decode(hash string) (time, memory uint32, threads uint8, salt, key []byte, err error) {
parts := strings.Split(hash, "$")
if len(parts) != 6 || parts[0] != "" || parts[1] != Argon2id {
return 0, 0, 0, nil, nil, errFormat
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argonVersion {
return 0, 0, 0, nil, nil, errFormat
}
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
return 0, 0, 0, nil, nil, errFormat
}
if salt, err = base64.RawStdEncoding.Strict().DecodeString(parts[4]); err != nil {
return 0, 0, 0, nil, nil, errFormat
}
if key, err = base64.RawStdEncoding.Strict().DecodeString(parts[5]); err != nil {
return 0, 0, 0, nil, nil, errFormat
}
if len(salt) == 0 || len(key) == 0 {
return 0, 0, 0, nil, nil, errFormat
}
return time, memory, threads, salt, key, nil
}
+133
View File
@@ -0,0 +1,133 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package cred_test
// The credential-digest contract. The load-bearing case is interop: iam2 must
// verify a digest LIVE v1 wrote, so the fixtures below are not iam2's own output
// — they were minted by the exact library and parameters v1 mints with
// (github.com/alexedwards/argon2id, DefaultParams; iam/object/service_account.go
// MintServiceAccountKey). A verifier tested only against its own encoder proves
// nothing about cutover.
import (
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
"github.com/hanzoai/iam2/internal/cred"
)
// v1Hash / v1Secret are a REAL argon2id PHC digest minted by v1's own library at
// v1's own DefaultParams (m=65536,t=1,p=2), frozen here as the cutover contract.
const (
v1Secret = "hk-abc123secret"
v1Hash = "$argon2id$v=19$m=65536,t=1,p=2$lw7pOIR6REN0aO6PxubXHQ$Sh93/A7jca0IIF//9/DYGyEc/TgpObpZiy3xP1x3wlY"
v1Secret2 = "correct horse battery staple"
v1Hash2 = "$argon2id$v=19$m=65536,t=1,p=2$h0899dypBb7YDHPZFVWxVA$oS3XKYK+y+C6qG4dGc7T5CbtU1aMR6bsHlAcNSPJd88"
)
// TestVerifiesRealV1Argon2idHash is the cutover gate: every live service-account
// secret is an argon2id PHC string written by v1. If this fails, every imported
// credential stops authenticating the moment iam2 serves.
func TestVerifiesRealV1Argon2idHash(t *testing.T) {
for _, tc := range []struct{ secret, hash string }{
{v1Secret, v1Hash},
{v1Secret2, v1Hash2},
} {
if !cred.Verify(cred.Argon2id, tc.hash, tc.secret) {
t.Fatalf("v1-minted argon2id hash did not verify for %q", tc.secret)
}
if cred.Verify(cred.Argon2id, tc.hash, tc.secret+"x") {
t.Fatalf("a wrong secret verified against the v1 hash for %q", tc.secret)
}
}
// A digest never verifies a secret it was not minted from — the two fixtures
// are independent, so a cross-match would mean the salt is being ignored.
if cred.Verify(cred.Argon2id, v1Hash, v1Secret2) {
t.Fatal("a v1 hash verified a foreign secret")
}
}
// TestHashIsV1CompatiblePHC pins the format iam2 WRITES: the same variant,
// version, and parameters v1 reads, so a row iam2 mints stays verifiable by v1
// during a rollback.
func TestHashIsV1CompatiblePHC(t *testing.T) {
h, err := cred.Hash("s3cret")
if err != nil {
t.Fatalf("hash: %v", err)
}
const want = "$argon2id$v=19$m=65536,t=1,p=2$"
if !strings.HasPrefix(h, want) {
t.Fatalf("digest %q does not carry v1's parameters (want prefix %q)", h, want)
}
if parts := strings.Split(h, "$"); len(parts) != 6 {
t.Fatalf("digest %q is not a 6-part PHC string", h)
}
if !cred.Verify(cred.Argon2id, h, "s3cret") {
t.Fatal("iam2's own digest did not verify")
}
if strings.Contains(h, "s3cret") {
t.Fatal("the digest carries the plaintext")
}
// Fresh salt per call: the same secret never yields the same digest.
h2, _ := cred.Hash("s3cret")
if h == h2 {
t.Fatal("two digests of the same secret are identical — the salt is not random")
}
}
// TestVerifyDispatchesOnTheStoredScheme is the decomplected property: the
// algorithm is a property of the ROW, so a bcrypt row verifies under bcrypt and
// an argon2id row under argon2id, through ONE entry point. Verifying with the
// wrong scheme name never succeeds — which is exactly the bug a hard-coded
// algorithm has.
func TestVerifyDispatchesOnTheStoredScheme(t *testing.T) {
b, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
if err != nil {
t.Fatalf("bcrypt: %v", err)
}
bcryptHash := string(b)
if !cred.Verify(cred.Bcrypt, bcryptHash, "pw") {
t.Fatal("a bcrypt row did not verify under bcrypt")
}
if !cred.Verify(cred.Argon2id, v1Hash, v1Secret) {
t.Fatal("an argon2id row did not verify under argon2id")
}
// Crossed schemes: this is precisely the live blocker — calling bcrypt on an
// argon2id row (and vice versa) refuses a CORRECT secret.
if cred.Verify(cred.Bcrypt, v1Hash, v1Secret) {
t.Fatal("an argon2id row verified under bcrypt")
}
if cred.Verify(cred.Argon2id, bcryptHash, "pw") {
t.Fatal("a bcrypt row verified under argon2id")
}
}
// TestVerifyFailsClosed pins every refusal axis: an unknown or empty scheme, an
// absent digest, an empty presented secret, and a malformed stored digest.
func TestVerifyFailsClosed(t *testing.T) {
for _, tc := range []struct {
name string
kind, stored, in string
}{
{"unknown scheme", "sha256-salt", v1Hash, v1Secret},
{"empty scheme", "", v1Hash, v1Secret},
{"no digest stored", cred.Argon2id, "", v1Secret},
{"empty presented secret", cred.Argon2id, v1Hash, ""},
{"not a PHC string", cred.Argon2id, "garbage", v1Secret},
{"wrong variant", cred.Argon2id, strings.Replace(v1Hash, "argon2id", "argon2i", 1), v1Secret},
{"wrong version", cred.Argon2id, strings.Replace(v1Hash, "v=19", "v=16", 1), v1Secret},
{"truncated", cred.Argon2id, "$argon2id$v=19$m=65536,t=1,p=2$", v1Secret},
{"empty key", cred.Argon2id, "$argon2id$v=19$m=65536,t=1,p=2$lw7pOIR6REN0aO6PxubXHQ$", v1Secret},
{"non-base64 salt", cred.Argon2id, "$argon2id$v=19$m=65536,t=1,p=2$!!!$Sh93", v1Secret},
} {
t.Run(tc.name, func(t *testing.T) {
if cred.Verify(tc.kind, tc.stored, tc.in) {
t.Fatalf("verified: kind=%q stored=%q", tc.kind, tc.stored)
}
})
}
}
+30 -1
View File
@@ -7,7 +7,12 @@
// endpoints (token/authorize/userinfo) use their own RFC 6749 shapes.
package httpx
import "github.com/zap-proto/zip"
import (
"encoding/base64"
"strings"
"github.com/zap-proto/zip"
)
// Response is the Casdoor-compatible envelope. status is "ok" or "error"; a
// non-ok status rides on a 200 (every SDK branches on status, not the HTTP
@@ -43,6 +48,30 @@ func Bearer(c *zip.Ctx) string {
return ""
}
// Basic returns the (id, secret) an `Authorization: Basic <base64>` header
// carries, and whether it carried one — RFC 7617, verbatim: base64 of
// "<id>:<secret>", split on the FIRST colon so a secret may contain one. This is
// the ONE Basic parser; a caller bound by RFC 6749 §2.3.1 (client_secret_basic,
// whose halves are form-urlencoded before the base64) form-decodes the two
// values afterwards. Both are returned raw here, so nothing re-interprets bytes
// the sender did not encode.
func Basic(c *zip.Ctx) (id, secret string, ok bool) {
const p = "Basic "
h := c.Header("Authorization")
if len(h) <= len(p) || !strings.EqualFold(h[:len(p)], p) {
return "", "", false
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(h[len(p):]))
if err != nil {
return "", "", false
}
id, secret, found := strings.Cut(string(raw), ":")
if !found {
return "", "", false
}
return id, secret, true
}
// EffectiveHost is the request host used to build a host-relative issuer, so
// discovery/JWKS never split-origin (HIP-0111). Honors X-Forwarded-Host when
// the request came through the ingress/gateway.
+9 -5
View File
@@ -120,10 +120,10 @@ func create(db orm.DB) zip.TypedHandler[schema.Key, schema.Key] {
k.Owner, k.Name = in.Owner, in.Name
apply(k, in)
if k.AccessKey == "" {
k.AccessKey = mint("pk", k.State)
k.AccessKey = Mint("pk", k.State)
}
if k.AccessSecret == "" {
k.AccessSecret = mint("sk", k.State)
k.AccessSecret = Mint("sk", k.State)
}
now := time.Now().UTC().Format(time.RFC3339)
k.CreatedTime, k.UpdatedTime = now, now
@@ -192,9 +192,13 @@ func apply(dst, src *schema.Key) {
dst.State = src.State
}
// mint generates a prefixed credential half — "{pk|sk}-{live|test}-{random}"
// mirroring the v1 key format. State == "test" selects the test env.
func mint(prefix, state string) string {
// Mint generates a prefixed credential half — "{prefix}-{live|test}-{random}"
// mirroring the v1 key format. State == "test" selects the test env. It is the
// ONE credential minting primitive: the key entity mints its pk-/sk- halves with
// it, and service accounts mint their hk- key and secret with it, so every
// credential in the system carries the same 128 bits of entropy from the same
// source.
func Mint(prefix, state string) string {
env := "live"
if state == "test" {
env = "test"
+133
View File
@@ -0,0 +1,133 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package memberships serves the (User × Org × Role) tenancy relation — which
// orgs an identity may act in, and with what coarse role. It is the set a token
// carries as the `orgs` claim, which is what lets the edge authorize an
// org-switch statelessly (X-Org-Id ∈ orgs).
//
// A user's HOME org (User.Owner) is always an implicit membership — the token
// consumer treats it as one — so an explicit row is only ever needed for a TEAM
// org the identity was invited into. The boot backfill seeds the home row anyway,
// so an org's roster is complete from one query.
//
// This is the transport face. The relation's operations are store's
// (EnsureMembership, MembershipsByUser/ByOrg), because the token mint needs them
// too and it sits below the authorization seam this face sits above.
package memberships
import (
"context"
"strings"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/authz"
"github.com/hanzoai/iam2/internal/httpx"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/internal/store"
)
// Path is the verb face: GET lists by ?user= or ?org=, POST ensures one.
const Path = "/v1/iam/memberships"
// unauthorized is v1's refusal message, verbatim.
const unauthorized = "auth:Unauthorized operation"
// Mount registers the membership surface on app, backed by db.
func Mount(app *zip.App, db orm.DB) {
app.Get(Path, list(db))
app.Post(Path, ensure(db))
}
// request is the ensure body.
type request struct {
User string `json:"user"` // "<homeOrg>/<username>"
Org string `json:"org"`
Role string `json:"role"`
}
// list serves GET /v1/iam/memberships?user=<owner/name> or ?org=<slug> — one
// identity's orgs, or one org's roster.
//
// Both are org-scoped: a non-SuperAdmin may ask about ITS OWN org's roster, or
// about a user whose home org is its own, and nothing else. The bound comes from
// the verified credential via authz.Scope, so a request parameter can never
// widen it — a membership row names who may act and spend in an org, so a
// cross-tenant read is a customer roster leak.
func list(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
user, org := c.Query("user"), c.Query("org")
if (user == "") == (org == "") {
return httpx.Err(c, "exactly one of user or org is required")
}
if org != "" {
if !scoped(ctx, org) {
return httpx.Err(c, unauthorized)
}
rows, err := store.MembershipsByOrg(ctx, db, org)
return listed(c, rows, err)
}
// A user id is "<homeOrg>/<name>": its home org is the tenant bound here.
home, _, found := strings.Cut(user, "/")
if !found || home == "" {
return httpx.Err(c, "user must be <owner>/<name>")
}
if !scoped(ctx, home) {
return httpx.Err(c, unauthorized)
}
rows, err := store.MembershipsByUser(ctx, db, user)
return listed(c, rows, err)
}
}
// ensure serves POST /v1/iam/memberships — grant an identity the right to act in
// an org. Granting membership IS the org's authority to give, so it takes the
// same gate a write to that org's own registry row takes: a SuperAdmin, an admin
// of the org itself, or an org-admin-capable confidential client. One rule, one
// place (internal/authz).
func ensure(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
var in request
if err := c.Bind(&in); err != nil {
return httpx.Err(c, err.Error())
}
if in.User == "" || in.Org == "" {
return httpx.Err(c, "user and org are required")
}
switch in.Role {
case store.RoleOwner, store.RoleAdmin, store.RoleMember:
case "":
in.Role = store.RoleMember
default:
return httpx.Err(c, "role must be owner, admin, or member")
}
if !authz.Can(ctx, "POST", "organizations", store.MembershipOwner, in.Org) {
return httpx.Err(c, unauthorized)
}
added, err := store.EnsureMembership(ctx, db, in.User, in.Org, in.Role)
if err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, added)
}
}
// scoped reports whether the caller may read the membership rows of org — i.e.
// whether resolving the scope from its own verified credential yields exactly
// the org it asked for. A SuperAdmin gets what it asks for; anyone else gets its
// own org, so any other request fails the equality and is refused.
func scoped(ctx context.Context, org string) bool {
got, err := authz.Scope(ctx, org)
return err == nil && got == org
}
// listed writes a membership listing, or the error envelope on failure.
func listed(c *zip.Ctx, rows []*schema.Membership, err error) error {
if err != nil {
return httpx.Err(c, err.Error())
}
return c.JSON(200, httpx.Response{Status: "ok", Data: rows, Data2: len(rows)})
}
+47 -13
View File
@@ -26,12 +26,36 @@ import (
// inert until an ML-DSA Cert is configured. Keys come from the Cert entity
// (KMS-backed); tests inject an ephemeral in-memory key through the same path.
// OrgRef is one org the subject may act in, plus its coarse role there — the
// unit of the `orgs` membership set. The edge authorizes an org-switch against
// that set statelessly (X-Org-Id ∈ orgs), so this shape is a wire contract: it
// is an array of OBJECTS, matching gateway/iamauth.Membership field for field.
// It stays slug-sized for the same reason RoleRef does.
type OrgRef struct {
Org string `json:"org"`
Role string `json:"role,omitempty"`
}
// RoleRef names a role or permission by its (owner, name) identity. A token
// carries refs, never whole rows: the bearer rides in a request header, and an
// oversized header is answered with HTTP 431 — which locks the tenant out of
// every API. A consumer that needs more than the name reads the catalog.
type RoleRef struct {
Owner string `json:"owner,omitempty"`
Name string `json:"name,omitempty"`
}
// Claims is the iam2 token claim set: the standard registered claims plus the
// Hanzo first-class claims the SDK and downstream validators read. owner and
// organization are the tenant (both the org slug); scope carries the granted
// scopes; nonce is echoed into the id_token; tokenType distinguishes an
// access-token from an id-token. A field is emitted only when populated, so one
// struct serves both token shapes without leaking empty claims.
// access-token from an id-token.
//
// The subject block below describes the PRINCIPAL, and every one of its fields
// is resolved from the user record at mint (see Subject) — never from a request,
// which could otherwise assert its own isAdmin. Each is omitempty, so a machine
// token carries none of them and a single-org user's token is byte-identical to
// one minted before the set grew.
type Claims struct {
jwt.RegisteredClaims
Scope string `json:"scope,omitempty"`
@@ -42,6 +66,19 @@ type Claims struct {
Nonce string `json:"nonce,omitempty"`
Azp string `json:"azp,omitempty"`
TokenType string `json:"tokenType,omitempty"`
// The subject block — what downstream reads to authorize.
Id string `json:"id,omitempty"`
PreferredUsername string `json:"preferred_username,omitempty"`
Type string `json:"type,omitempty"`
Tag string `json:"tag,omitempty"`
Phone string `json:"phone,omitempty"` // IAM's own name
PhoneNumber string `json:"phone_number,omitempty"` // the OIDC standard name
IsAdmin bool `json:"isAdmin,omitempty"` // ORG admin — never platform sudo
Orgs []OrgRef `json:"orgs,omitempty"`
Roles []RoleRef `json:"roles,omitempty"`
Permissions []RoleRef `json:"permissions,omitempty"`
Groups []string `json:"groups,omitempty"`
}
// Signer signs tokens with one key under one algorithm. Immutable after
@@ -107,10 +144,11 @@ func NewRSASigner(key *rsa.PrivateKey, kid, issuer string) *Signer {
return &Signer{method: jwt.SigningMethodRS256, key: key, kid: kid, alg: "RS256", issuer: issuer}
}
// Sign issues a signed access token for (app, user) with the given scope. now is
// Sign issues a signed access token for (app, sub) with the given scope. now is
// injected for testability; ttl is the token lifetime. The audience is the app's
// clientId (validators fail closed when aud != clientId).
func (s *Signer) Sign(app *schema.Application, userID, email, name, scope string, ttl time.Duration, now time.Time) (string, error) {
// clientId (validators fail closed when aud != clientId). Every principal claim
// comes from sub, which was resolved from the user record — see Subject.
func (s *Signer) Sign(app *schema.Application, sub Subject, scope string, ttl time.Duration, now time.Time) (string, error) {
if s == nil {
return "", errors.New("jwt: nil signer")
}
@@ -121,7 +159,6 @@ func (s *Signer) Sign(app *schema.Application, userID, email, name, scope string
claims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: s.issuer,
Subject: userID,
Audience: audienceFor(app, ""),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
NotBefore: jwt.NewNumericDate(now),
@@ -131,19 +168,18 @@ func (s *Signer) Sign(app *schema.Application, userID, email, name, scope string
Scope: scope,
Owner: app.Organization,
Organization: app.Organization,
Email: email,
Name: name,
Azp: app.ClientId,
TokenType: "access-token",
}
sub.claims(&claims)
return s.signClaims(claims)
}
// SignID issues an OIDC id_token for (app, user). It differs from the access
// SignID issues an OIDC id_token for (app, sub). It differs from the access
// token by carrying the echoed nonce and by declaring tokenType "id-token"; the
// audience is the client the token was minted for (the RP), and iss matches the
// discovery issuer so a standard OIDC client validates it.
func (s *Signer) SignID(app *schema.Application, userID, email, name, scope, nonce string, ttl time.Duration, now time.Time) (string, error) {
func (s *Signer) SignID(app *schema.Application, sub Subject, scope, nonce string, ttl time.Duration, now time.Time) (string, error) {
if s == nil {
return "", errors.New("jwt: nil signer")
}
@@ -154,7 +190,6 @@ func (s *Signer) SignID(app *schema.Application, userID, email, name, scope, non
claims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: s.issuer,
Subject: userID,
Audience: jwt.ClaimStrings{app.ClientId},
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
IssuedAt: jwt.NewNumericDate(now),
@@ -164,12 +199,11 @@ func (s *Signer) SignID(app *schema.Application, userID, email, name, scope, non
Scope: scope,
Owner: app.Organization,
Organization: app.Organization,
Email: email,
Name: name,
Nonce: nonce,
Azp: app.ClientId,
TokenType: "id-token",
}
sub.claims(&claims)
return s.signClaims(claims)
}
+4 -4
View File
@@ -30,7 +30,7 @@ func TestSign_RoundTripAndClaims(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
app := testApp()
tokenStr, err := s.Sign(app, "hanzo/alice", "alice@hanzo.ai", "Alice", "openid profile", time.Hour, now)
tokenStr, err := s.Sign(app, Subject{Id: "hanzo/alice", Email: "alice@hanzo.ai", Display: "Alice"}, "openid profile", time.Hour, now)
if err != nil {
t.Fatal(err)
}
@@ -73,7 +73,7 @@ func TestSign_ExpiredTokenRejected(t *testing.T) {
key := testKey(t)
s := NewRSASigner(key, "cert-hanzo", "https://iam.hanzo.ai")
now := time.Unix(1_800_000_000, 0)
tokenStr, err := s.Sign(testApp(), "u", "", "", "openid", time.Minute, now)
tokenStr, err := s.Sign(testApp(), Subject{Id: "u"}, "openid", time.Minute, now)
if err != nil {
t.Fatal(err)
}
@@ -90,7 +90,7 @@ func TestSign_WrongKeyRejected(t *testing.T) {
s := NewRSASigner(testKey(t), "cert-hanzo", "https://iam.hanzo.ai")
other := testKey(t)
now := time.Unix(1_800_000_000, 0)
tokenStr, _ := s.Sign(testApp(), "u", "", "", "openid", time.Hour, now)
tokenStr, _ := s.Sign(testApp(), Subject{Id: "u"}, "openid", time.Hour, now)
var claims Claims
_, err := jwt.ParseWithClaims(tokenStr, &claims, func(*jwt.Token) (any, error) { return &other.PublicKey, nil },
jwt.WithValidMethods([]string{"RS256"}))
@@ -119,7 +119,7 @@ func TestNewRSASignerFromCert_PEMRoundTrip(t *testing.T) {
}
// Sign+verify to prove the parsed key works.
now := time.Unix(1_800_000_000, 0)
str, err := s.Sign(testApp(), "u", "", "", "openid", time.Hour, now)
str, err := s.Sign(testApp(), Subject{Id: "u"}, "openid", time.Hour, now)
if err != nil {
t.Fatal(err)
}
+6 -6
View File
@@ -57,7 +57,7 @@ func TestSigner_ES256RoundTrip(t *testing.T) {
t.Fatal(err)
}
now := time.Unix(1_800_000_000, 0)
tok, err := s.Sign(testApp(), "hanzo/alice", "alice@hanzo.ai", "Alice", "openid", time.Hour, now)
tok, err := s.Sign(testApp(), Subject{Id: "hanzo/alice", Email: "alice@hanzo.ai", Display: "Alice"}, "openid", time.Hour, now)
if err != nil {
t.Fatal(err)
}
@@ -94,7 +94,7 @@ func TestSigner_MLDSA65RoundTripThroughVerify(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
nowFuncSet(t, now.Add(time.Minute))
tok, err := s.SignID(testApp(), "hanzo/alice", "alice@hanzo.ai", "Alice", "openid", "nonce-xyz", time.Hour, now)
tok, err := s.SignID(testApp(), Subject{Id: "hanzo/alice", Email: "alice@hanzo.ai", Display: "Alice"}, "openid", "nonce-xyz", time.Hour, now)
if err != nil {
t.Fatal(err)
}
@@ -112,7 +112,7 @@ func TestSignID_EchoesNonce(t *testing.T) {
key := sharedKey(t)
s := NewRSASigner(key, "cert-hanzo", "https://hanzo.id")
now := time.Unix(1_800_000_000, 0)
tok, err := s.SignID(testApp(), "hanzo/alice", "a@h.ai", "Alice", "openid", "n-123", time.Hour, now)
tok, err := s.SignID(testApp(), Subject{Id: "hanzo/alice", Email: "a@h.ai", Display: "Alice"}, "openid", "n-123", time.Hour, now)
if err != nil {
t.Fatal(err)
}
@@ -151,7 +151,7 @@ func TestVerifyToken_RejectsUnknownKid(t *testing.T) {
s, _ := NewSignerFromCert(other, testApp(), "https://hanzo.id")
now := time.Unix(1_800_000_000, 0)
nowFuncSet(t, now.Add(time.Minute))
tok, _ := s.Sign(testApp(), "hanzo/alice", "", "", "openid", time.Hour, now)
tok, _ := s.Sign(testApp(), Subject{Id: "hanzo/alice"}, "openid", time.Hour, now)
if _, err := verifyToken(context.Background(), db, tok); err == nil {
t.Fatal("token with an unknown kid was accepted")
}
@@ -179,7 +179,7 @@ func TestVerify_TenantCannotShadowSigningKey(t *testing.T) {
// Attacker forges a token signed with THEIR key, kid=cert-hanzo, claiming admin.
forger := NewRSASigner(attackerKey, "cert-hanzo", "https://hanzo.id")
forged, err := forger.Sign(&schema.Application{ClientId: "victim"}, "admin/superadmin", "", "", "openid", time.Hour, base)
forged, err := forger.Sign(&schema.Application{ClientId: "victim"}, Subject{Id: "admin/superadmin"}, "openid", time.Hour, base)
if err != nil {
t.Fatal(err)
}
@@ -204,7 +204,7 @@ func TestVerify_NonPlatformCertNeverTrusted(t *testing.T) {
persistCert(t, db, ac)
forger := NewRSASigner(attackerKey, "cert-evil", "https://hanzo.id")
forged, _ := forger.Sign(&schema.Application{ClientId: "victim"}, "admin/superadmin", "", "", "openid", time.Hour, base)
forged, _ := forger.Sign(&schema.Application{ClientId: "victim"}, Subject{Id: "admin/superadmin"}, "openid", time.Hour, base)
if _, err := verifyToken(context.Background(), db, forged); err == nil {
t.Fatal("a non-platform cert must never verify a token")
}
+146
View File
@@ -0,0 +1,146 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"strings"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/internal/store"
)
// Subject is the identity a token is minted FOR — every claim that describes the
// principal rather than the grant. It exists so those values are resolved from
// the USER RECORD in exactly one place: a claim a request could influence is a
// claim an attacker can forge, so nothing here is ever echoed from an input.
//
// The zero value is a machine subject (client_credentials), which has no user
// row: it carries an Id and a display name and no authority claims at all.
type Subject struct {
Id string // the token `sub` — the principal's own "<owner>/<name>"
Email string
Display string // display name, falling back to the username
User *schema.User
Orgs []OrgRef
}
// SubjectOf resolves the subject `id` ("<owner>/<name>") into the claims a token
// carries for it. A missing or unresolvable user yields a subject with just the
// id — a token still mints, and simply asserts no authority, which is the right
// answer for a since-deleted user and for a machine token alike.
func SubjectOf(ctx context.Context, db orm.DB, id string) Subject {
s := Subject{Id: id}
owner, name, found := strings.Cut(id, "/")
if !found || owner == "" || name == "" {
return s
}
u, err := orm.TypedQuery[schema.User](db).Filter("Owner=", owner).Filter("Name=", name).First()
if err != nil || u == nil {
return s
}
s.User = u
s.Email = u.Email
s.Display = u.DisplayName
if s.Display == "" {
s.Display = u.Name
}
s.Orgs = orgRefs(ctx, db, u)
return s
}
// claims folds the subject's user record into the claim set, at the ONE place
// every token format passes through. A machine subject (no user row) adds
// nothing, so its token stays exactly the bare grant assertion it is today.
func (s Subject) claims(c *Claims) {
c.Subject = s.Id
c.Email = s.Email
c.Name = s.Display
c.Orgs = s.Orgs
u := s.User
if u == nil {
return
}
// v1 emits `id` alongside `sub` and both name the same principal; iam2's
// storage id for a user IS its "<owner>/<name>", so the pair stays consistent.
c.Id = u.Owner + "/" + u.Name
c.PreferredUsername = u.Name
c.Type = u.Type
c.Tag = u.Tag
c.Phone = u.Phone
c.PhoneNumber = u.Phone
c.IsAdmin = u.IsAdmin
c.Groups = u.Groups
c.Roles = roleRefs(u.Roles)
c.Permissions = permissionRefs(u.Permissions)
}
// orgRefs resolves the org-membership SET a token carries: the user's HOME org
// first, then every org an explicit Membership grants, deduplicated with home
// winning.
//
// It returns NIL when the user belongs only to its home org — the overwhelmingly
// common case. The consumer already treats the home org (the `owner` claim) as
// an implicit member, so an `orgs` claim naming only home is pure token weight,
// and weight is not free: an oversized claim set pushes the bearer past the
// edge's request-header budget, which answers HTTP 431 and locks the tenant out
// of every API. Omitting it also keeps a single-org token byte-identical to
// today's.
//
// A lookup failure emits the home-only answer rather than failing the mint:
// authentication availability wins, and the fallback is the LEAST authority, not
// the most.
func orgRefs(ctx context.Context, db orm.DB, u *schema.User) []OrgRef {
if u == nil || u.Owner == "" {
return nil
}
rows, err := store.MembershipsByUser(ctx, db, u.Owner+"/"+u.Name)
if err != nil {
return nil
}
teams := make([]OrgRef, 0, len(rows))
seen := map[string]bool{u.Owner: true}
for _, m := range rows {
if m == nil || m.Org == "" || seen[m.Org] {
continue
}
seen[m.Org] = true
teams = append(teams, OrgRef{Org: m.Org, Role: m.Role})
}
if len(teams) == 0 {
return nil
}
return append([]OrgRef{{Org: u.Owner, Role: store.HomeRole(u)}}, teams...)
}
// roleRefs projects the user's roles to the slug-sized refs a token carries.
// The full rows would blow the header budget; a consumer that needs more reads
// the role catalog.
func roleRefs(roles []*schema.Role) []RoleRef {
out := make([]RoleRef, 0, len(roles))
for _, r := range roles {
if r != nil {
out = append(out, RoleRef{Owner: r.Owner, Name: r.Name})
}
}
if len(out) == 0 {
return nil
}
return out
}
// permissionRefs projects the user's permissions to the same slug-sized refs.
func permissionRefs(perms []*schema.Permission) []RoleRef {
out := make([]RoleRef, 0, len(perms))
for _, p := range perms {
if p != nil {
out = append(out, RoleRef{Owner: p.Owner, Name: p.Name})
}
}
if len(out) == 0 {
return nil
}
return out
}
+9 -21
View File
@@ -194,7 +194,9 @@ func clientCredentialsGrant(c *zip.Ctx, db orm.DB) error {
return tokenError(c, 500, "server_error", "")
}
sub := app.GetId() // <appOwner>/<appName>, per v1
access, err := signer.Sign(app, sub, "", app.Name, scope, ttl, now)
// A machine grant has no user record, so it asserts no principal claims —
// just the grant itself, named by the app.
access, err := signer.Sign(app, Subject{Id: sub, Display: app.Name}, scope, ttl, now)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
@@ -232,9 +234,11 @@ func issueTokens(ctx context.Context, db orm.DB, c *zip.Ctx, app *schema.Applica
if err != nil {
return tokenResponse{}, err
}
email, name := userProfile(ctx, db, row.User)
// Resolve the principal ONCE: the access token and the id_token then assert
// the same identity by construction, and the record is read a single time.
sub := SubjectOf(ctx, db, row.User)
access, err := signer.Sign(app, row.User, email, name, row.Scope, ttl, now)
access, err := signer.Sign(app, sub, row.Scope, ttl, now)
if err != nil {
return tokenResponse{}, err
}
@@ -264,7 +268,7 @@ func issueTokens(ctx context.Context, db orm.DB, c *zip.Ctx, app *schema.Applica
Scope: row.Scope,
}
if hasScope(row.Scope, "openid") {
idt, err := signer.SignID(app, row.User, email, name, row.Scope, row.Nonce, ttl, now)
idt, err := signer.SignID(app, sub, row.Scope, row.Nonce, ttl, now)
if err != nil {
return tokenResponse{}, err
}
@@ -359,7 +363,7 @@ func signAccessToken(ctx context.Context, db orm.DB, app *schema.Application, to
if err != nil {
return "", err
}
return signer.Sign(app, tok.User, "", "", tok.Scope, ttl, now)
return signer.Sign(app, SubjectOf(ctx, db, tok.User), tok.Scope, ttl, now)
}
// tokenIssuer is the canonical OIDC issuer for this request (https://<host>),
@@ -371,22 +375,6 @@ func tokenIssuer(c *zip.Ctx) string {
return "https://hanzo.id"
}
// userProfile loads a user's email and display name for the token claims.
func userProfile(ctx context.Context, db orm.DB, userID string) (email, name string) {
owner, uname := splitSub(userID)
if owner == "" || uname == "" {
return "", ""
}
u, err := store.GetUserByName(ctx, db, owner, uname)
if err != nil || u == nil {
return "", ""
}
name = u.DisplayName
if name == "" {
name = u.Name
}
return u.Email, name
}
// splitSub splits a subject "owner/name" into its two parts.
func splitSub(sub string) (owner, name string) {
+84 -6
View File
@@ -9,25 +9,34 @@ package organizations
import (
"context"
"errors"
"net"
"strings"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/cred"
"github.com/hanzoai/iam2/internal/schema"
)
const orgBase = "/v1/iam/organizations"
// Mount registers the organization CRUD surface on app, backed by db.
// Mount registers the organization surface on app, backed by db: the typed
// entity routes and the verb face (verbs.go) the live consumers call, both bound
// to ONE OrganizationAPI so they share the store operations, the record policy,
// and the masker.
func Mount(app *zip.App, db orm.DB) {
NewOrganizationAPI(db).mount(app)
h := NewOrganizationAPI(db)
h.mount(app)
h.mountVerbs(app)
}
// OrganizationAPI serves CRUD for the organization entity over a single
// orm.DB. It is transport-only: credential hashing, password-type
// sanitisation, and signin-throttle clamping are policy concerns applied by the
// caller before Create/Update — never braided into persistence here.
// OrganizationAPI serves CRUD for the organization entity over a single orm.DB.
// It is the ONE core under both faces — the typed entity routes mounted here and
// the verb face in verbs.go — so the record policy every write must obey
// (normalize + validate) is applied HERE, at the two write entry points, and
// cannot be side-stepped by choosing a face.
type OrganizationAPI struct {
DB orm.DB
}
@@ -101,6 +110,10 @@ func (h *OrganizationAPI) Create(ctx context.Context, in *CreateOrganizationInpu
if org.Owner == "" || org.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
if err := validate(&org); err != nil {
return nil, err
}
normalize(&org)
switch _, err := h.find(org.Owner, org.Name); {
case err == nil:
return nil, zip.ErrConflict("organization already exists")
@@ -167,6 +180,10 @@ func (h *OrganizationAPI) Update(ctx context.Context, in *UpdateOrganizationInpu
if desired.Owner == "" || desired.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
if err := validate(&desired); err != nil {
return nil, err
}
normalize(&desired)
existing, err := h.find(desired.Owner, desired.Name)
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("organization not found")
@@ -209,6 +226,67 @@ func (h *OrganizationAPI) Delete(ctx context.Context, in *DeleteOrganizationInpu
return &DeleteOrganizationOutput{Affected: true}, nil
}
// normalize applies the organization record policy every write obeys, porting
// v1's object/organization.go Add/UpdateOrganization preamble. It is pure and
// total: it only ever fills or clamps, never rejects (validate does that), so
// both write paths can call it unconditionally.
func normalize(o *schema.Organization) {
if o.BalanceCurrency == "" {
o.BalanceCurrency = "USD" // v1 organization.go:170-172, 213-215
}
o.PasswordType = passwordType(o.PasswordType)
clamp(&o.FailedSigninLimit, 3, 100) // v1 clampSigninRateLimits
clamp(&o.FailedSigninFrozenTime, 1, 1440) //
}
// passwordType resolves the digest scheme an org's members inherit when their
// own row names none (internal/cred: the scheme is a property of the row, and
// the org is its fallback — v1 object/check.go:244-249). Plaintext is refused
// and both it and the empty/bcrypt defaults resolve to the platform default, so
// an org can never be configured to store its members' passwords in the clear
// (v1 sanitizeOrgPasswordType). An explicitly-chosen other scheme is preserved
// verbatim, so an imported org keeps verifying its existing rows.
func passwordType(t string) string {
switch t {
case "", "plain", cred.Bcrypt:
return cred.Argon2id
}
return t
}
// clamp bounds a per-org signin-throttle field to a safe range. Zero means
// "inherit the application default" and is left alone (v1 clampSigninRateLimits).
func clamp(v *int, lo, hi int) {
switch {
case *v == 0:
case *v < lo:
*v = lo
case *v > hi:
*v = hi
}
}
// validate rejects a record no write may persist. IpWhitelist is the one field
// whose value can be malformed rather than merely unset: it is a comma-separated
// CIDR list enforced on every signin, so an unparseable entry would either lock
// an org out or silently admit everyone (v1 object.CheckIpWhitelist, called from
// controllers/organization.go:163 and :208).
func validate(o *schema.Organization) error {
if o.IpWhitelist == "" {
return nil
}
for _, item := range strings.Split(o.IpWhitelist, ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if _, _, err := net.ParseCIDR(item); err != nil {
return zip.ErrBadRequest(item + " does not meet the CIDR format")
}
}
return nil
}
// find resolves an organization by its (owner, name) natural key. The error is
// orm.ErrNotFound when no row matches.
func (h *OrganizationAPI) find(owner, name string) (*schema.Organization, error) {
+226
View File
@@ -0,0 +1,226 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package organizations
import (
"encoding/json"
"errors"
"strings"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/authz"
"github.com/hanzoai/iam2/internal/httpx"
"github.com/hanzoai/iam2/internal/schema"
)
// The organization VERB face (HIP-0111 §6) — the surface every live consumer
// actually calls: cloud's onboarding (clients/account/iam.go), the console's
// admin and org-settings panels, the @hanzo/iam SDK. It speaks the Casdoor
// grammar those clients were written against: a verb path, a target addressed as
// `id=<owner>/<name>`, and the httpx.Response envelope whose `status` (never the
// HTTP code) every SDK branches on.
//
// It is TRANSPORT ONLY. The store operations are OrganizationAPI's — the same
// Create/Get/List/Update/Delete the typed entity routes bind, with the same
// record policy and the same masker. One core, two faces, zero duplicated logic:
// the faces differ in how a request names its target, never in what it may do.
//
// Authorization likewise comes from ONE place: authz.Can, the same pure policy
// the Guard applies to the entity face. These paths are declared in authz's
// selfPaths, which means the Guard AUTHENTICATES them and leaves the target
// decision to the handler — because the target rides in `id`, or in the caller's
// own scope for a listing, neither of which the Guard's generic rule can read.
const (
PathGetOrganizations = "/v1/iam/get-organizations"
PathGetOrganization = "/v1/iam/get-organization"
PathAddOrganization = "/v1/iam/add-organization"
PathUpdateOrganization = "/v1/iam/update-organization"
PathDeleteOrganization = "/v1/iam/delete-organization"
)
// entity is the authorization subject these routes act on — the same entity name
// the Guard derives from the typed /v1/iam/organizations path, so ONE rule in
// authorize() governs both faces.
const entity = "organizations"
// mountVerbs registers the verb face on the same OrganizationAPI the typed
// routes bind.
func (h *OrganizationAPI) mountVerbs(app *zip.App) {
app.Get(PathGetOrganizations, h.list)
app.Get(PathGetOrganization, h.get)
app.Post(PathAddOrganization, h.add)
app.Post(PathUpdateOrganization, h.update)
app.Post(PathDeleteOrganization, h.del)
}
// list serves GET /v1/iam/get-organizations — the owner-scoped listing, with the
// count in `data2` (what cloud's envTotal reads).
//
// The scope comes from the VERIFIED credential via authz.Scope, never from the
// request: a SuperAdmin lists the owner it asks for, anyone else lists its own
// org and nothing else. The extra Name filter is v1's second scoping filter
// (organization.go:59 passes callerOwner alongside owner) and is what makes this
// safe to serve at all — in the per-user-org model an org's name IS the
// customer's email, so an unscoped list is a cross-tenant customer roster.
func (h *OrganizationAPI) list(c *zip.Ctx) error {
ctx := c.Context()
owner, err := authz.Scope(ctx, c.Query("owner"))
if err != nil {
return httpx.Err(c, unauthorized)
}
out, err := h.List(ctx, &ListOrganizationsInput{Owner: owner})
if err != nil {
return httpx.Err(c, err.Error())
}
orgs := out.Organizations
if p, ok := authz.From(ctx); ok && !p.Super {
orgs = onlyNamed(orgs, p.Org)
}
return c.JSON(200, httpx.Response{Status: "ok", Data: orgs, Data2: len(orgs)})
}
// get serves GET /v1/iam/get-organization?id=<owner>/<name>. A missing org is
// {status:"ok", data:null} — v1's shape, and the one cloud's onboarding reads as
// "this slug is free" (clients/account/iam.go:211). Returning an error envelope
// instead would make an EXISTING org look available and duplicate it.
func (h *OrganizationAPI) get(c *zip.Ctx) error {
ctx := c.Context()
owner, name, err := splitId(c.Query("id"))
if err != nil {
return httpx.Err(c, err.Error())
}
if !authz.Can(ctx, "GET", entity, owner, name) {
return httpx.Err(c, unauthorized)
}
org, err := h.Get(ctx, &GetOrganizationInput{Owner: owner, Name: name})
if err != nil {
if notFound(err) {
return httpx.Ok(c, nil)
}
return httpx.Err(c, err.Error())
}
// v1 organization.go:131-133 — an org that never set the MFA remember window
// reads back as the 12-hour default rather than "0 hours" (which the portal
// would honour as "re-challenge every request").
if org.MfaRememberInHours == 0 {
org.MfaRememberInHours = 12
}
return httpx.Ok(c, org)
}
// add serves POST /v1/iam/add-organization — cloud's onboarding create. The
// authorized target is the DECODED body's own (owner, name): the value bound
// here is the value written, so there is no second parse to diverge from.
func (h *OrganizationAPI) add(c *zip.Ctx) error {
ctx := c.Context()
org, err := decode(c)
if err != nil {
return httpx.Err(c, err.Error())
}
if !authz.Can(ctx, "POST", entity, org.Owner, org.Name) {
return httpx.Err(c, unauthorized)
}
out, err := h.Create(ctx, &CreateOrganizationInput{Organization: *org})
if err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, out)
}
// update serves POST /v1/iam/update-organization?id=<owner>/<name> — the
// console's org branding/settings save.
//
// The row to overwrite is the one `id` names, and the authorized target is that
// same pair: the body's own owner/name are NOT trusted, so a caller authorized
// for its own org cannot rename or re-own the row by sending a different pair in
// the body (v1 passes isSuperAdmin into the store for the same reason).
func (h *OrganizationAPI) update(c *zip.Ctx) error {
ctx := c.Context()
owner, name, err := splitId(c.Query("id"))
if err != nil {
return httpx.Err(c, err.Error())
}
org, err := decode(c)
if err != nil {
return httpx.Err(c, err.Error())
}
if !authz.Can(ctx, "POST", entity, owner, name) {
return httpx.Err(c, unauthorized)
}
org.Owner, org.Name = owner, name
out, err := h.Update(ctx, &UpdateOrganizationInput{Organization: *org})
if err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, out)
}
// del serves POST /v1/iam/delete-organization. v1 takes the target in the body;
// `id` is honoured when present so the three write verbs address a row the same
// way. The reserved admin organization is refused by the core.
func (h *OrganizationAPI) del(c *zip.Ctx) error {
ctx := c.Context()
org, err := decode(c)
if err != nil {
return httpx.Err(c, err.Error())
}
owner, name := org.Owner, org.Name
if id := c.Query("id"); id != "" {
if owner, name, err = splitId(id); err != nil {
return httpx.Err(c, err.Error())
}
}
if !authz.Can(ctx, "POST", entity, owner, name) {
return httpx.Err(c, unauthorized)
}
if _, err := h.Delete(ctx, &DeleteOrganizationInput{Owner: owner, Name: name}); err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, true)
}
// unauthorized is v1's refusal message, verbatim — the console renders it.
const unauthorized = "auth:Unauthorized operation"
// notFound reports whether err is the core's "no such row". The verb face turns
// that into {status:"ok", data:null} rather than an error, which is the
// distinction cloud's onboarding keys on: a null org means the slug is free,
// while an error means IAM could not answer and onboarding must NOT proceed.
func notFound(err error) bool {
var e *zip.HTTPError
return errors.As(err, &e) && e.Status == 404
}
// splitId parses the `<owner>/<name>` target the verb face addresses a row by,
// splitting on the FIRST separator exactly as v1 does. An id with no separator
// is REFUSED rather than defaulted to some owner: guessing one would authorize a
// target the caller never named.
func splitId(id string) (owner, name string, err error) {
owner, name, found := strings.Cut(id, "/")
if !found || owner == "" || name == "" {
return "", "", zip.ErrBadRequest("id must be <owner>/<name>")
}
return owner, name, nil
}
// decode binds the request body ONCE into the organization the handler acts on.
func decode(c *zip.Ctx) (*schema.Organization, error) {
var org schema.Organization
if err := json.Unmarshal(c.Body(), &org); err != nil {
return nil, zip.ErrBadRequest(err.Error())
}
return &org, nil
}
// onlyNamed keeps just the org whose name is the caller's own — the second of
// v1's two listing filters.
func onlyNamed(orgs []*schema.Organization, name string) []*schema.Organization {
out := make([]*schema.Organization, 0, 1)
for _, o := range orgs {
if o != nil && o.Name == name {
out = append(out, o)
}
}
return out
}
+4
View File
@@ -24,11 +24,13 @@ import (
"github.com/hanzoai/iam2/internal/certs"
"github.com/hanzoai/iam2/internal/invitations"
"github.com/hanzoai/iam2/internal/keys"
"github.com/hanzoai/iam2/internal/memberships"
"github.com/hanzoai/iam2/internal/oidc"
"github.com/hanzoai/iam2/internal/organizations"
"github.com/hanzoai/iam2/internal/permission"
"github.com/hanzoai/iam2/internal/providers"
"github.com/hanzoai/iam2/internal/roles"
"github.com/hanzoai/iam2/internal/serviceaccounts"
"github.com/hanzoai/iam2/internal/sessions"
"github.com/hanzoai/iam2/internal/tokens"
"github.com/hanzoai/iam2/internal/users"
@@ -60,6 +62,8 @@ func Mount(app *zip.App, db orm.DB) {
users.Mount(app, db)
organizations.Mount(app, db)
serviceaccounts.Mount(app, db)
memberships.Mount(app, db)
applications.Mount(app, db)
providers.Mount(app, db)
roles.Mount(app, db)
+32
View File
@@ -0,0 +1,32 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package schema
import "github.com/hanzoai/orm"
// Membership records that a user may ACT IN an org — the (User × Org × Role)
// relation that lets ONE identity belong to its personal org AND to team orgs.
// It is the tenancy set a token emits as the `orgs` claim, against which the
// edge authorizes an org-switch (X-Org-Id ∈ orgs) with no round-trip.
//
// It is orthogonal to the Role/Permission catalog: Membership answers "which
// orgs may this identity act in, and with what COARSE role", while
// Role/Permission answer fine-grained authorization WITHIN an org. A user's HOME
// org (User.Owner) is always an implicit membership; explicit rows add the team
// orgs the user was invited into.
//
// Like every tenant-registry row it is owned by the platform ("admin"); the
// natural key (Owner, Name) uses Name = "<userId>|<org>", so a (user, org) pair
// is unique by construction and Ensure is idempotent without a read-modify-write
// race. The User and Org columns are the hot lookup paths and stay indexed.
type Membership struct {
orm.Model[Membership]
Owner string `json:"owner"`
Name string `json:"name"`
CreatedTime string `json:"createdTime"`
User string `json:"user" orm:"index"` // the user id, "<homeOrg>/<username>"
Org string `json:"org" orm:"index"` // the org slug the user may act in
Role string `json:"role"` // coarse org role: owner | admin | member
}
+9 -3
View File
@@ -1,7 +1,12 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package schema declares the thirteen IAM v2 identity entities on
// hanzoai/orm.
// Package schema declares the IAM v2 identity entities on hanzoai/orm.
//
// Thirteen mirror a v1 Casdoor table one-for-one (MIGRATION.md §4) and are the
// ones the drift-compare tool reconciles. Membership is the fourteenth and has
// no v1 counterpart to compare against: the tenancy relation that lets one
// identity act in several orgs is new here, so it is absent from compare's
// mapping by design, not by omission.
//
// Each entity embeds orm.Model[T]; every kind is registered exactly once in
// this file's init(). orm stores every entity as one row in a single
@@ -30,7 +35,7 @@ func Kinds() []string {
"users", "organizations", "applications", "providers",
"roles", "permissions", "certs", "keys",
"webauthn_credentials", "sessions", "tokens", "audit_logs",
"invitations",
"invitations", "memberships",
}
}
@@ -48,4 +53,5 @@ func init() {
orm.Register[Token]("tokens")
orm.Register[AuditLog]("audit_logs")
orm.Register[Invitation]("invitations")
orm.Register[Membership]("memberships")
}
+368
View File
@@ -0,0 +1,368 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package serviceaccounts serves the agent/bot identity surface at
// /v1/iam/service-accounts — create an identity and mint its first key, list an
// org's identities, rotate a key, revoke an identity.
//
// A service account IS a user row (schema.User, Type "service-account"), not a
// new entity: it reuses the whole identity surface — the store, the redaction
// contract, the org-scoped claims a token carries — so an agent is a principal
// like any other. What makes it a service account rather than a human:
//
// - Type is serviceAccount, and its Name is the canonical <org>-<agent>
// handle, so it maps 1:1 to a chat/team bot member and is unambiguous.
// - It has NO password and can never sign in interactively. Its only
// credential is an API key whose secret is stored ONLY as an argon2id digest
// (internal/cred) — the raw secret is returned exactly once, at mint, and is
// unrecoverable after.
//
// Authorization is v1's, verbatim (controllers/service_account.go): minting a
// key is the same trust boundary as minting a user's key, so create/rotate/
// delete need the mint capability (an app) or org-admin authority over the
// target org (a human). Listing — names and metadata only — takes the read-only
// capability instead, and that grant is additionally tenant-bound: app/hanzo-team
// may list organization=hanzo and no other tenant's. A read capability can never
// mint, rotate, or delete.
package serviceaccounts
import (
"context"
"strings"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/authz"
"github.com/hanzoai/iam2/internal/cred"
"github.com/hanzoai/iam2/internal/httpx"
"github.com/hanzoai/iam2/internal/keys"
"github.com/hanzoai/iam2/internal/schema"
)
// Paths — the verb face the live consumers call (team's bot member sync reads
// the list; the console provisions).
const (
Path = "/v1/iam/service-accounts"
PathKeys = Path + "/:name/keys"
PathOne = Path + "/:name"
)
// serviceAccount is the User.Type discriminator. Readers ask `is()`, never the
// literal, so the value lives in exactly one place.
const serviceAccount = "service-account"
// unauthorized is v1's refusal message, verbatim.
const unauthorized = "auth:Unauthorized operation"
// Mount registers the service-account surface on app, backed by db.
func Mount(app *zip.App, db orm.DB) {
app.Get(Path, list(db))
app.Post(Path, create(db))
app.Post(PathKeys, rotate(db))
app.Delete(PathOne, revoke(db))
}
// request is the create body: the owning org, the agent handle (bare or already
// canonical), and an optional back-reference to the agent record this identity
// embodies.
type request struct {
Organization string `json:"organization"`
Name string `json:"name"`
AgentRef string `json:"agentRef"`
}
// minted is the ONE shape a freshly minted credential leaves in — the only
// response that ever carries a raw secret, and only at the moment it is created.
type minted struct {
Owner string `json:"owner"`
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"`
AgentRef string `json:"agentRef,omitempty"`
AccessKey string `json:"accessKey"`
AccessSecret string `json:"accessSecret"`
}
// create serves POST /v1/iam/service-accounts: provision the identity and mint
// its first key, returning the raw secret exactly once.
func create(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
var in request
if err := c.Bind(&in); err != nil {
return httpx.Err(c, err.Error())
}
if in.Organization == "" {
return httpx.Err(c, "organization is required")
}
p, ok := authz.From(c.Context())
if !ok || !admin(p, in.Organization) {
return httpx.Err(c, unauthorized)
}
name := canonical(in.Organization, in.Name)
if name == "" {
return httpx.Err(c, "a valid name is required")
}
ctx := c.Context()
if u, err := find(ctx, db, in.Organization, name); err != nil {
return httpx.Err(c, err.Error())
} else if u != nil {
// Refuse a collision with ANY principal, human or bot, so a service
// account can never hijack or be hijacked by a username.
return httpx.Err(c, "a user named "+name+" already exists in organization "+in.Organization)
}
sa := orm.New[schema.User](db)
sa.Owner, sa.Name = in.Organization, name
sa.Type, sa.DisplayName, sa.Tag = serviceAccount, name, in.AgentRef
sa.SetId(in.Organization + "/" + name)
key, secret, err := mint(sa)
if err != nil {
return httpx.Err(c, err.Error())
}
if err := sa.CreateCtx(ctx); err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, &minted{
Owner: sa.Owner, Name: sa.Name, Type: sa.Type, AgentRef: sa.Tag,
AccessKey: key, AccessSecret: secret,
})
}
}
// list serves GET /v1/iam/service-accounts?organization=<org>: names and
// metadata, never secrets. Paginated in memory over the already org-scoped
// slice — the set per org is small, so a dedicated count query is overkill
// (v1 service_account.go:296-307).
func list(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
org := c.Query("organization")
if org == "" {
return httpx.Err(c, "organization is required")
}
p, ok := authz.From(c.Context())
if !ok || !read(p, org) {
return httpx.Err(c, unauthorized)
}
all, err := orm.TypedQuery[schema.User](db).
Filter("Owner=", org).Filter("Type=", serviceAccount).Order("Name").GetAll(c.Context())
if err != nil {
return httpx.Err(c, err.Error())
}
for _, sa := range all {
redact(sa)
}
page := paginate(all, atoi(c.Query("p")), atoi(c.Query("pageSize")))
return c.JSON(200, httpx.Response{Status: "ok", Data: page, Data2: len(all)})
}
}
// rotate serves POST /v1/iam/service-accounts/:name/keys: mint a fresh key,
// invalidating the prior one, and return the new raw secret exactly once.
func rotate(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
sa, err := load(c, db)
if err != nil {
return httpx.Err(c, err.Error())
}
key, secret, err := mint(sa)
if err != nil {
return httpx.Err(c, err.Error())
}
if err := sa.UpdateCtx(c.Context()); err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, &minted{Owner: sa.Owner, Name: sa.Name, AccessKey: key, AccessSecret: secret})
}
}
// revoke serves DELETE /v1/iam/service-accounts/:name.
func revoke(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
sa, err := load(c, db)
if err != nil {
return httpx.Err(c, err.Error())
}
if err := sa.DeleteCtx(c.Context()); err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, true)
}
}
// load resolves the :name service account within the ?organization= org and
// authorizes the caller for MUTATION — the shared preamble of rotate and delete,
// so neither can reach a row the admin gate did not clear.
func load(c *zip.Ctx, db orm.DB) (*schema.User, error) {
org, name := c.Query("organization"), c.Param("name")
if org == "" || name == "" {
return nil, zip.ErrBadRequest("organization and name are required")
}
p, ok := authz.From(c.Context())
if !ok || !admin(p, org) {
return nil, zip.ErrForbidden(unauthorized)
}
sa, err := find(c.Context(), db, org, name)
if err != nil {
return nil, err
}
if sa == nil || !is(sa) {
return nil, zip.ErrNotFound("the service account " + name + " does not exist in organization " + org)
}
return sa, nil
}
// admin is the gate for every credential MUTATION — create, rotate, revoke
// (v1 authorizeServiceAccountAdmin + serviceAccountHumanAdminAllowed). A
// confidential client must hold the mint capability; a human must be a
// SuperAdmin or an admin of the target org itself, so a tenant admin can never
// provision an identity in another tenant.
func admin(p *authz.Principal, org string) bool {
if p == nil || org == "" {
return false
}
if p.App != "" {
return authz.Allowed(p, authz.CapKeyMint)
}
return p.Super || (p.Admin && p.Org == org)
}
// read is the gate for the LIST surface, which returns names and metadata only
// (v1 authorizeServiceAccountRead). It is deliberately weaker than admin for
// apps and IDENTICAL for humans:
//
// - the mint capability is a superset of read: an orchestrator that already
// provisions in any org may enumerate any org;
// - otherwise the read-only capability suffices, but ONLY within the org the
// app's own <org>-<app> name binds it to. Both conditions are required, so a
// leaked reader credential can enumerate one tenant's bot names and nothing
// else — and can never mint, rotate, or delete, which stay on admin.
func read(p *authz.Principal, org string) bool {
if p == nil || org == "" {
return false
}
if p.App != "" {
return authz.Allowed(p, authz.CapKeyMint) ||
(authz.Allowed(p, authz.CapServiceAccountRead) && authz.BoundToOrg(p, org))
}
return p.Super || (p.Admin && p.Org == org)
}
// mint (re)generates the identity's credential: a fresh access key (the
// plaintext lookup handle) and a fresh secret whose argon2id DIGEST — never the
// secret — is stored. Any prior key stops authenticating the moment this
// persists. The raw secret is returned to the caller and never written.
func mint(sa *schema.User) (key, secret string, err error) {
key, secret = keys.Mint("hk", ""), keys.Mint("hk", "")
hash, err := cred.Hash(secret)
if err != nil {
return "", "", zip.ErrInternal("hash service account secret: " + err.Error())
}
// The access key is a public lookup handle, not a secret, so it is stored
// verbatim; only its secret sibling is digested.
sa.AccessKey = key
sa.AccessSecretHash = hash
sa.AccessSecret = "" // never persist the plaintext, and clear any legacy one
return key, secret, nil
}
// canonical maps a (org, agent) request pair to the <org>-<agent> handle: a bare
// agent segment is prefixed, an already-canonical name is kept. Returns "" when
// the result is not a well-formed handle, so a malformed name is refused at the
// boundary rather than persisted.
func canonical(org, name string) string {
if name == "" {
return ""
}
if !boundTo(org, name) {
name = org + "-" + name
}
if !valid(name) {
return ""
}
return name
}
// boundTo reports whether name already carries the "<org>-" prefix with a
// non-empty agent segment.
func boundTo(org, name string) bool {
prefix := org + "-"
return len(name) > len(prefix) && strings.HasPrefix(name, prefix)
}
// valid reports whether name is a well-formed handle: alphanumerics with single
// -/_/. separators between segments, never leading, trailing, or doubled. A
// handle is an identity, so anything else is refused rather than normalized.
func valid(name string) bool {
if name == "" || sep(rune(name[0])) || sep(rune(name[len(name)-1])) {
return false
}
for i, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
case sep(r):
if i > 0 && sep(rune(name[i-1])) {
return false // doubled separator
}
default:
return false
}
}
return true
}
func sep(r rune) bool { return r == '-' || r == '_' || r == '.' }
// is reports whether u is a service-account principal.
func is(u *schema.User) bool { return u != nil && u.Type == serviceAccount }
// find resolves one user by (owner, name); (nil, nil) when absent.
func find(ctx context.Context, db orm.DB, owner, name string) (*schema.User, error) {
u, err := orm.TypedQuery[schema.User](db).Filter("Owner=", owner).Filter("Name=", name).First()
if err != nil {
if err == orm.ErrNotFound {
return nil, nil
}
return nil, zip.ErrInternal(err.Error())
}
return u, nil
}
// redact strips every credential field before an identity is listed. The secret
// exists only as a digest, and even that never leaves: a list is names and
// metadata (v1 masks the same three columns).
func redact(sa *schema.User) {
sa.AccessKey = ""
sa.AccessSecret = ""
sa.AccessSecretHash = ""
sa.PasswordHash = ""
sa.PasswordSalt = ""
}
// paginate returns the 1-indexed page of size n, clamped to the slice. Absent
// paging (either value unset) returns everything, matching v1.
func paginate(all []*schema.User, page, size int) []*schema.User {
if page <= 0 || size <= 0 {
return all
}
start := (page - 1) * size
if start > len(all) {
start = len(all)
}
end := start + size
if end > len(all) {
end = len(all)
}
return all[start:end]
}
// atoi parses a non-negative paging parameter; anything else is 0 ("unset").
func atoi(s string) int {
n := 0
for _, r := range s {
if r < '0' || r > '9' {
return 0
}
n = n*10 + int(r-'0')
}
return n
}
+127
View File
@@ -0,0 +1,127 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package store
import (
"context"
"time"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam2/internal/schema"
)
// The membership relation's named operations. They live here, with the other
// reads two layers share, because BOTH the token mint (internal/oidc resolves the
// `orgs` claim from them) and the membership HTTP face (internal/memberships) need
// them — and the face sits above internal/authz while the mint sits below it, so
// neither package can own the data without a cycle.
// MembershipOwner is the platform owner of every membership row, matching how
// every tenant-registry row (organizations included) is filed under the reserved
// admin org. It is a namespace, not an authority.
const MembershipOwner = "admin"
// Coarse membership roles — who administers an org, NOT the fine-grained authz
// roles in the Role catalog.
const (
RoleOwner = "owner"
RoleAdmin = "admin"
RoleMember = "member"
)
// membershipName builds the (user, org) natural-key Name. It is deterministic,
// which is what makes EnsureMembership idempotent on the pair. The value is never
// parsed back — the User and Org columns are queried directly — so the "/" inside
// a user id is harmless.
func membershipName(user, org string) string { return user + "|" + org }
// EnsureMembership records that user may act in org with role. It is the ONE way
// a membership is created. Idempotent: it adds a row only when the (user, org)
// pair is absent, and it NEVER downgrades an existing role — an owner re-ensured
// as a member stays an owner, so a routine backfill can never quietly strip
// someone's authority. Reports whether it created a row.
func EnsureMembership(ctx context.Context, db orm.DB, user, org, role string) (bool, error) {
if user == "" || org == "" {
return false, nil
}
existing, err := GetMembership(ctx, db, user, org)
if err != nil || existing != nil {
return false, err
}
m := orm.New[schema.Membership](db)
m.Owner, m.Name = MembershipOwner, membershipName(user, org)
m.User, m.Org, m.Role = user, org, role
m.CreatedTime = time.Now().UTC().Format(time.RFC3339)
m.SetId(MembershipOwner + "/" + m.Name)
if err := m.CreateCtx(ctx); err != nil {
return false, err
}
return true, nil
}
// GetMembership returns one (user, org) membership, or (nil, nil) when absent.
func GetMembership(_ context.Context, db orm.DB, user, org string) (*schema.Membership, error) {
if user == "" || org == "" {
return nil, nil
}
m, err := orm.TypedQuery[schema.Membership](db).Filter("User=", user).Filter("Org=", org).First()
if err == orm.ErrNotFound {
return nil, nil
}
return m, err
}
// MembershipsByUser returns every org a user may explicitly act in. A caller
// unions the user's HOME org itself (the token resolver does), so the set is
// complete even before any team is joined.
func MembershipsByUser(ctx context.Context, db orm.DB, user string) ([]*schema.Membership, error) {
if user == "" {
return nil, nil
}
return orm.TypedQuery[schema.Membership](db).Filter("User=", user).Order("Org").GetAll(ctx)
}
// MembershipsByOrg returns every user who may act in an org — the org's roster.
func MembershipsByOrg(ctx context.Context, db orm.DB, org string) ([]*schema.Membership, error) {
if org == "" {
return nil, nil
}
return orm.TypedQuery[schema.Membership](db).Filter("Org=", org).Order("User").GetAll(ctx)
}
// BackfillMemberships records the HOME-org membership for every user that lacks
// one. One-shot, idempotent, and live-safe — safe on every boot: a user already
// carrying its home row is skipped and no existing row is touched. It seeds ONLY
// the home org; team memberships are added explicitly. Reports how many rows it
// created.
func BackfillMemberships(ctx context.Context, db orm.DB) (int, error) {
users, err := orm.TypedQuery[schema.User](db).GetAll(ctx)
if err != nil {
return 0, err
}
created := 0
for _, u := range users {
if u.Owner == "" || u.Name == "" {
continue
}
added, err := EnsureMembership(ctx, db, u.Owner+"/"+u.Name, u.Owner, HomeRole(u))
if err != nil {
return created, err
}
if added {
created++
}
}
return created, nil
}
// HomeRole is a user's coarse role in its OWN home org: an org admin administers
// it, anyone else is a plain member. (Platform SuperAdmins live in the reserved
// admin org and are admins of it.)
func HomeRole(u *schema.User) string {
if u != nil && u.IsAdmin {
return RoleAdmin
}
return RoleMember
}