Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6af184afcb |
+135
-20
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user