store: one store package, not two
image / test (push) Failing after 1m14s
image / build (push) Skipped

There were two. pkg/store held the project lifecycle; internal/store held
applications, users, tokens, api keys and memberships — 56 exported functions
against the same orm.DB, doing the same job, split only by which one a caller
was allowed to import.

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

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

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
2026-08-01 15:07:56 -07:00
parent 99e8bdfeb8
commit fa8afb4494
95 changed files with 815 additions and 815 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ import (
"github.com/hanzoai/iam/internal/authz"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// authorizeOrganization gates the Organization an application will SERVE (the
+1 -1
View File
@@ -60,7 +60,7 @@ import (
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/oidc"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// adminOrg is the reserved organization whose membership IS SuperAdmin — the one
+1 -1
View File
@@ -34,7 +34,7 @@ import (
"github.com/hanzoai/iam/internal/routes"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/testhttp"
)
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"os"
"strings"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// Confidential-client capabilities — the port of the v1 gate (object/app_authz.go
+1 -1
View File
@@ -30,7 +30,7 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// Route registers the bootstrap upsert endpoints on the PUBLIC group r (they
+1 -1
View File
@@ -17,7 +17,7 @@ import (
"github.com/hanzoai/iam/internal/routes"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/testhttp"
)
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"context"
"testing"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// isShared is the honest declaration that an application serves EVERY organization,
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"testing"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// Token lifetimes are DECLARABLE through the upsert, and an omitted lifetime
+1 -1
View File
@@ -31,7 +31,7 @@ import (
"github.com/hanzoai/iam/internal/authz"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// unauthorized is v1's refusal message, verbatim — the envelope a denied caller
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/hanzoai/iam/internal/authz"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// resolve-key is the WRITE-ONLY ingest door and the exact DUAL of get-user?accessKey:
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"github.com/hanzoai/iam/feature"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/users"
"github.com/hanzoai/iam/pkg/model"
)
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/google/uuid"
"github.com/hanzoai/iam/feature"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/pkg/model"
)
+1 -1
View File
@@ -25,7 +25,7 @@ import (
"github.com/hanzoai/iam/internal/authz"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// Path is the REST verb face: GET lists by ?user= or ?org=, POST ensures one.
+1 -1
View File
@@ -33,7 +33,7 @@ import (
"github.com/hanzoai/iam/internal/routes"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/testhttp"
)
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"golang.org/x/crypto/bcrypt"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// Package factor is the pure multi-factor DOMAIN — what a factor IS, whether a
+1 -1
View File
@@ -33,7 +33,7 @@ import (
"github.com/hanzoai/iam/internal/authz"
"github.com/hanzoai/iam/internal/mfa/factor"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// The TOTP factor type ("app") and the domain helpers are factor.App et al (internal/mfa/factor).
+1 -1
View File
@@ -32,7 +32,7 @@ import (
"github.com/hanzoai/iam/internal/routes"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/testhttp"
)
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// The authorization endpoint: GET/POST /v1/iam/oauth/authorize — the front door
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"time"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// ITEM 4: TakeChallenge burns a login challenge exactly once. A captured MFA passcode
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// GET/PUT /v1/iam/consent — the account-canonical data-sharing consent: the ONE
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// The RFC 8628 device authorization grant: how a machine with no browser and no
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// The RFC 8628 device grant, driven through the real router exactly as the two
+1 -1
View File
@@ -18,7 +18,7 @@ import (
"github.com/hanzoai/iam/internal/mfa/factor"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/users"
)
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/mfa/factor"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// The second-factor RESUME for a federated login. When the federation callback
+1 -1
View File
@@ -21,7 +21,7 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// Federation is driven through the REAL registered routes (authorize → IdP → callback
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// PathUnlink removes a federated link from an account: POST /v1/iam/unlink. It is
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"testing"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/users"
)
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// Front-door JSON endpoints the @hanzo/iam SDK + hanzo.id portal call: the login
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// sessionCookieFor drives a bare (type=login) portal sign-in for hanzo/alice and
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/sessions"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// PathAccount (canonical.go) is the native front-door account endpoint — what
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// RFC 7662 Token Introspection + RFC 7009 Token Revocation — the two standard
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/keys"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// The confidential-client on-behalf-of primitives. A trusted, allow-listed backend
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/users"
)
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// The JSON Web Key Set: the public half of every active signing Cert, so relying
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// GET /v1/iam/linked-accounts — the caller's linked social/OAuth identities.
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/sessions"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/users"
)
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"context"
"testing"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// SINGLE SIGN-ON off a live IAM session. A signed-in person asking for a grant to
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// seedUser creates a user with a bcrypt password in org "hanzo".
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// logoutHandler ends a sign-in session and sends the browser back to your
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/mfa/factor"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// The login-time second-factor gate. A verified password proves ONE factor;
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// The shared tail of every interactive authentication: given a user who has
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/sessions"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// POST /v1/iam/onboard — first-run org onboarding. A signed-in user with no org of
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// SELF-SERVICE ORG CREATION — the hanzo.id /onboarding flow.
+1 -1
View File
@@ -16,7 +16,7 @@ import (
"testing"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// orgsOf indexes a decoded token's `orgs` claim by org slug → role, and reports the
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/users"
)
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// POST /v1/iam/update-preferences — the ONE account-backed store for cross-product,
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/hanzoai/iam/internal/cred"
"github.com/hanzoai/iam/internal/keys"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// credentialType is the User.Type discriminator for the tenant's default API
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// seedUser inserts a user (owner, name) so provision has a caller to move. Mirrors
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// Refresh-token rotation with reuse detection. A refresh token is an opaque,
+1 -1
View File
@@ -16,7 +16,7 @@ import (
"github.com/hanzoai/iam/internal/cred"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// The native front-door OTP send: POST /v1/iam/send-verification-code. It mirrors
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// multipartReq builds a real multipart/form-data POST — the serialized format v1's
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/sessions"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// The code→session exchange: POST /v1/iam/signin. After the authorize/login flow
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/users"
)
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// fullApp gives a test full control over the signup-relevant application fields the
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/users"
)
+1 -1
View File
@@ -19,7 +19,7 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/users"
)
+1 -1
View File
@@ -16,7 +16,7 @@ import (
ormdb "github.com/hanzoai/orm/db"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// openTestDB opens a fresh SQLite store; the schema init registers the kinds.
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// RFC 8693 OAuth 2.0 Token Exchange — the standard delegation / act-on-behalf-of
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// RFC 8693 Token Exchange — the standard on-behalf-of flow (replaces the retired
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// updateUser is the ONE way the pre-authentication OIDC surface writes back a user
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"time"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/users"
)
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// The userinfo endpoint: GET/POST /v1/iam/oauth/userinfo. A bearer must satisfy
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// seedRichUser creates alice with the profile fields userinfo projects.
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// Token verification is a pure reduction of a signed value: read the `kid`,
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// GET /v1/iam/whoami — the current caller's identity, lighter than get-account:
+1 -1
View File
@@ -63,7 +63,7 @@ import (
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/users"
)
+1 -1
View File
@@ -26,7 +26,7 @@ import (
"github.com/hanzoai/iam/internal/cred"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/users"
"github.com/hanzoai/iam/internal/testhttp"
+1 -1
View File
@@ -22,7 +22,7 @@ import (
"strings"
"testing"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
const enterpriseURN = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
+1 -1
View File
@@ -47,7 +47,7 @@ import (
"strings"
"testing"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// TestRed_scimGet_noCrossOrgExistenceOracle proves the read path is not an
+1 -1
View File
@@ -20,7 +20,7 @@ import (
"golang.org/x/crypto/bcrypt"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// C1 — a REGULAR (non-admin) org member creates users, and mints a NEW ORG-ADMIN
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// seedRichUser seeds a user with MFA enrollment + a soft-delete-adjacent field set,
+1 -1
View File
@@ -31,7 +31,7 @@ import (
"github.com/hanzoai/iam/internal/routes"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/testhttp"
)
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"github.com/hanzoai/iam/internal/authz"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/users"
)
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"strings"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// ensureSigningKey fills a signing cert's PrivateKey when init_data supplies the
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/zap-proto/fiber/v3"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// sessionTTL is the portal session lifetime — 14 days, matching the refresh
-633
View File
@@ -1,633 +0,0 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package store is the IAM object layer: thin, typed reads over hanzoai/orm
// against the Phase-1 entities. It replaces the v1 xorm ormer.Engine fluent
// calls with orm.TypedQuery, so handlers depend on named operations
// (GetApplicationByClientId, GetProvider, …) rather than a query builder.
//
// Every function takes a context and an orm.DB — one storage abstraction,
// backend-agnostic (sqlite / hanzoai/sql / hanzoai/datastore).
package store
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/schema"
)
// GetApplicationByClientId resolves an OAuth2/OIDC client by its clientId,
// DETERMINISTICALLY: among any rows carrying clientId it returns the platform-
// preferred one — a reserved signing owner (admin/built-in) outranks a tenant, and
// within a tier the lexically-least (owner,name) wins. clientId is globally unique
// by the applications create/update guard, so this normally has exactly one
// candidate; the ordering is defense-in-depth that makes a stray duplicate resolve
// to the PLATFORM row rather than whichever row the storage engine's heap happened
// to return first. A First() with no ORDER BY was the collidable-mint vector (safe
// on dev sqlite by rowid, UNSPECIFIED on Postgres): a tenant that registered a row
// with a mint-allow-listed clientId could have its row win resolution and
// authenticate a mint. This can no longer happen — the platform row always wins,
// and the owner-pin on the mint/capability gates denies a non-signing owner even if
// it did. Returns (nil, nil) when no application matches.
func GetApplicationByClientId(ctx context.Context, db orm.DB, clientId string) (*schema.Application, error) {
apps, err := ListApplicationsByClientId(ctx, db, clientId)
if err != nil {
return nil, err
}
return preferredApp(apps), nil
}
// ListApplicationsByClientId returns EVERY application row carrying clientId — the
// ONE place "which applications share this clientId" is answered. It backs both the
// deterministic single resolve above and the global-uniqueness guard the
// applications create/update path enforces: a JSON-document store has no per-field
// column to hang a DB UNIQUE index on, so clientId uniqueness is enforced at the
// write, exactly as the (owner,name) natural key already is. Returns nil when
// clientId is empty or unmatched.
func ListApplicationsByClientId(ctx context.Context, db orm.DB, clientId string) ([]*schema.Application, error) {
if clientId == "" {
return nil, nil
}
return orm.TypedQuery[schema.Application](db).Filter("ClientId=", clientId).GetAll(ctx)
}
// preferredApp deterministically selects the platform-preferred application among
// rows sharing a clientId (see morePreferredApp for the total order). Returns nil
// for an empty set, preserving GetApplicationByClientId's (nil, nil) not-found
// contract.
func preferredApp(apps []*schema.Application) *schema.Application {
var best *schema.Application
for _, a := range apps {
if a == nil {
continue
}
if best == nil || morePreferredApp(a, best) {
best = a
}
}
return best
}
// morePreferredApp reports whether a outranks b for clientId resolution: a reserved
// signing owner (admin/built-in) outranks a non-reserved one; within the same tier
// the lexically-least (owner,name) wins. The order is total and independent of
// storage/heap order, so resolution is deterministic on every backend.
func morePreferredApp(a, b *schema.Application) bool {
if sa, sb := IsSigningCertOwner(a.Owner), IsSigningCertOwner(b.Owner); sa != sb {
return sa
}
return a.Owner+"/"+a.Name < b.Owner+"/"+b.Name
}
// GetApplicationByName resolves an application by (owner, name).
func GetApplicationByName(_ context.Context, db orm.DB, owner, name string) (*schema.Application, error) {
app, err := orm.TypedQuery[schema.Application](db).
Filter("Owner=", owner).Filter("Name=", name).First()
if err == orm.ErrNotFound {
return nil, nil
}
return app, err
}
// GetUserByName resolves a user by (owner, name) — owner is the organization.
// Returns (nil, nil) when absent.
//
// Case does not distinguish principals. New names are normalized to lowercase at
// creation (schema.Username), but rows written before that rule are stored as they
// arrived, and renaming them would move real principals — so the RESOLUTION
// tolerates case instead of the data being rewritten. Three steps, cheapest first:
// the exact key, then the folded key (both indexed lookups, and between them they
// answer every all-lowercase row, which is all of them going forward), then a
// case-insensitive pass over the org for a legacy mixed-case row.
//
// It FAILS CLOSED when the folding is ambiguous — the same rule GetUserById
// applies to a duplicated subject. If "Alice" and "ALICE" both exist, "alice"
// names neither of them in particular, and answering with the storage engine's
// arbitrary first row would let whoever registered the second one be resolved as
// the first. An exact match always wins, so a row that is spelled the way it was
// asked for is never subject to this.
func GetUserByName(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 {
return u, nil
}
if err != orm.ErrNotFound {
return nil, err
}
folded := strings.ToLower(strings.TrimSpace(name))
if folded == "" {
return nil, nil
}
if folded != name {
u, err := orm.TypedQuery[schema.User](db).Filter("Owner=", owner).Filter("Name=", folded).First()
if err == nil {
return u, nil
}
if err != orm.ErrNotFound {
return nil, err
}
}
us, err := orm.TypedQuery[schema.User](db).Filter("Owner=", owner).GetAll(ctx)
if err != nil {
return nil, err
}
var match *schema.User
for _, cand := range us {
if !strings.EqualFold(cand.Name, folded) {
continue
}
if match != nil {
return nil, fmt.Errorf("store: %q and %q in organization %q both fold to %q — ambiguous username", match.Name, cand.Name, owner, folded)
}
match = cand
}
return match, nil
}
// GetUserById resolves a user by its stable opaque Id — the UUID the OIDC `sub`
// carries (schema.User.Id). Filters on the persisted "id" field, which the
// domain Id dominates over the embedded orm storage id, so the value matched is
// the UUID, never the (owner,name) key. Returns (nil, nil) when id is empty or
// unmatched (a pre-cutover user with no Id has "" here and is never matched by a
// non-empty subject).
//
// It FAILS CLOSED on multiplicity: the store has no DB UNIQUE index on Id, so if two
// rows ever shared one UUID (a broken invariant — Id is the OIDC `sub` and the authz
// principal key), returning the storage engine's arbitrary First() would let an
// attacker who planted a colliding row be resolved AS a victim. More than one match
// is therefore an error, never a silently-chosen row.
func GetUserById(ctx context.Context, db orm.DB, id string) (*schema.User, error) {
if id == "" {
return nil, nil
}
us, err := orm.TypedQuery[schema.User](db).Filter("Id=", id).GetAll(ctx)
if err != nil {
return nil, err
}
switch len(us) {
case 0:
return nil, nil
case 1:
return us[0], nil
default:
return nil, fmt.Errorf("store: %d users share id %q — ambiguous subject", len(us), id)
}
}
// GetUserBySubject resolves the user a token's `sub` names — the ONE place the
// subject→user mapping lives, shared by userinfo, get-account, token exchange,
// and the authz principal, so `sub` is decoded the same way everywhere. The
// discriminator is deterministic and matches how a `sub` is MINTED (subjectOf):
// a stable opaque UUID carries no "/" and resolves by Id; an "owner/name" subject
// (a pre-cutover user with no Id, or a machine token's app identity) resolves by
// its natural key. Returns (nil, nil) when no user matches — a machine token's
// app-id subject, or a since-deleted user — the callers fail closed on nil.
func GetUserBySubject(ctx context.Context, db orm.DB, sub string) (*schema.User, error) {
if sub == "" {
return nil, nil
}
if owner, name, hasSlash := strings.Cut(sub, "/"); hasSlash {
if owner == "" || name == "" {
return nil, nil
}
return GetUserByName(ctx, db, owner, name)
}
return GetUserById(ctx, db, sub)
}
// GetUserByEmail resolves a user by (owner, email) — the email-login identifier.
func GetUserByEmail(_ context.Context, db orm.DB, owner, email string) (*schema.User, error) {
u, err := orm.TypedQuery[schema.User](db).Filter("Owner=", owner).Filter("Email=", email).First()
if err == orm.ErrNotFound {
return nil, nil
}
return u, err
}
// GetTokenByCode resolves a token row by its authorization code. Returns
// (nil, nil) when no row carries the code.
func GetTokenByCode(_ context.Context, db orm.DB, code string) (*schema.Token, error) {
if code == "" {
return nil, nil
}
t, err := orm.TypedQuery[schema.Token](db).Filter("Code=", code).First()
if err == orm.ErrNotFound {
return nil, nil
}
return t, err
}
// GetCert resolves a signing certificate by (owner, name).
func GetCert(_ context.Context, db orm.DB, owner, name string) (*schema.Cert, error) {
c, err := orm.TypedQuery[schema.Cert](db).Filter("Owner=", owner).Filter("Name=", name).First()
if err == orm.ErrNotFound {
return nil, nil
}
return c, err
}
// signingCertOwners are the reserved platform organizations that own
// token-signing certificates. A signing cert is trusted ONLY under these
// owners, so a tenant can never shadow a platform signing key by creating a cert
// with the same name (the JWKS `kid`) under its own org and forging tokens.
var signingCertOwners = []string{"admin", "built-in"}
// IsSigningCertOwner reports whether owner is a reserved platform signing-cert
// owner — the trust boundary the JWKS and token verification enforce.
func IsSigningCertOwner(owner string) bool {
for _, o := range signingCertOwners {
if o == owner {
return true
}
}
return false
}
// GetTokenByUserCode resolves a pending device authorization by the user_code a
// human transcribes at the verification URI (RFC 8628 §3.3). Returns (nil, nil)
// when no row carries the code.
func GetTokenByUserCode(_ context.Context, db orm.DB, userCode string) (*schema.Token, error) {
if userCode == "" {
return nil, nil
}
t, err := orm.TypedQuery[schema.Token](db).Filter("UserCode=", userCode).First()
if err == orm.ErrNotFound {
return nil, nil
}
return t, err
}
// IsSuperAdmin reports whether owner is the reserved admin organization — THE
// SuperAdmin predicate, the only cross-tenant scope there is. It mirrors authz's
// own adminOrg so a subsystem BELOW the authz seam (the device-approval tenant
// gate) can ask the same question without importing authz. A per-org isAdmin
// flag is a different, org-scoped question and never answers this one.
func IsSuperAdmin(owner string) bool { return owner == "admin" }
// reservedServiceOrg is the system organization that owns service/app principals —
// reserved alongside the signing-cert owners, but not itself a signing owner.
const reservedServiceOrg = "app"
// IsReservedOrg reports whether owner is a SYSTEM organization a self-service,
// federated, or otherwise customer-driven flow may NEVER land a principal in. It is
// the ONE predicate that boundary shares (signup, onboarding, and federated
// provisioning all consult it), so the reserved set is defined in exactly one place
// and can never drift between those surfaces.
//
// The set is the SuperAdmin/signing trust boundary — admin and built-in, i.e.
// IsSigningCertOwner, composed so a newly-reserved signing owner is covered here for
// free — plus the service-principal org "app". A user created under any of these is a
// platform identity, not a customer: a user under "admin" is a SuperAdmin (authz
// derives Super from owner == "admin"), and a signing/built-in or service org is
// platform trust material. These orgs are seeded, onboarded by a SuperAdmin, or
// provisioned by the operator's service token — never reached by a public signup or
// an external login. Fail-closed by construction: an unknown org is NOT reserved, so
// legitimate tenants are unaffected while every reserved org is refused.
func IsReservedOrg(owner string) bool {
return IsSigningCertOwner(owner) || owner == reservedServiceOrg
}
// GetSigningCert resolves a TRUSTED signing certificate by name (the JWKS
// `kid`), searching only the reserved platform owners in order. A cert owned by
// any other org is never returned, so an attacker-created cert with a colliding
// name can neither sign a token iam will verify nor be published in the JWKS.
// Returns (nil, nil) when no trusted cert carries the name.
func GetSigningCert(ctx context.Context, db orm.DB, name string) (*schema.Cert, error) {
if name == "" {
return nil, nil
}
for _, owner := range signingCertOwners {
c, err := GetCert(ctx, db, owner, name)
if err != nil {
return nil, err
}
if c != nil {
return c, nil
}
}
return nil, nil
}
// PersistToken binds a domain Token onto the store and creates it. Used to
// persist an authorization code minted by oidc.MintCode. The id is (owner, name);
// callers set Name to a unique value (e.g. the code) before persisting.
func PersistToken(ctx context.Context, db orm.DB, tok *schema.Token) error {
t := orm.New[schema.Token](db)
model := t.Model
*t = *tok
t.Model = model
name := tok.Name
if name == "" {
name = tok.Code // codes are unique; use as the row name when none given
t.Name = name
}
t.SetId(tok.Owner + "/" + name)
return t.CreateCtx(ctx)
}
// SaveToken read-modify-writes an existing token row (e.g. after redemption:
// CodeIsUsed=true + AccessToken set). It looks the row up by (owner, name),
// copies the mutated fields, and updates in place.
func SaveToken(ctx context.Context, db orm.DB, tok *schema.Token) error {
existing, err := orm.Get[schema.Token](db, tok.Owner+"/"+tok.Name)
if err != nil {
return err
}
model := existing.Model
*existing = *tok
existing.Model = model
return existing.UpdateCtx(ctx)
}
// ListCerts returns every certificate ordered by name. The JWKS endpoint calls
// this and filters to the token-signing certs it publishes.
func ListCerts(ctx context.Context, db orm.DB) ([]*schema.Cert, error) {
return orm.TypedQuery[schema.Cert](db).Order("Name").GetAll(ctx)
}
// PlatformSigningCert returns a deterministic trusted signing cert — the first by
// (owner, name) order among the reserved platform owners that carries a private
// key. It keys deployment-stable secret derivations (the session-cookie MAC), so
// there is no new secret to provision; a tenant cert can never be chosen. Returns
// (nil, nil) when none is seeded.
func PlatformSigningCert(ctx context.Context, db orm.DB) (*schema.Cert, error) {
certs, err := ListCerts(ctx, db)
if err != nil {
return nil, err
}
var best *schema.Cert
for _, c := range certs {
if c == nil || c.PrivateKey == "" || !IsSigningCertOwner(c.Owner) {
continue
}
if best == nil || c.Owner+"/"+c.Name < best.Owner+"/"+best.Name {
best = c
}
}
return best, nil
}
// GetTokenByAccessTokenHash resolves a live token row by the SHA-256 hash of a
// presented access token — the userinfo bearer lookup. Because the row is the
// authorization server's memory of the grant, a deleted/rotated row means the
// bearer is revoked, independent of the JWT's own expiry.
func GetTokenByAccessTokenHash(_ context.Context, db orm.DB, hash string) (*schema.Token, error) {
if hash == "" {
return nil, nil
}
t, err := orm.TypedQuery[schema.Token](db).Filter("AccessTokenHash=", hash).First()
if err == orm.ErrNotFound {
return nil, nil
}
return t, err
}
// GetTokenByRefreshHash resolves a token row by the SHA-256 hash of a presented
// refresh token — the refresh-grant lookup.
func GetTokenByRefreshHash(_ context.Context, db orm.DB, hash string) (*schema.Token, error) {
if hash == "" {
return nil, nil
}
t, err := orm.TypedQuery[schema.Token](db).Filter("RefreshTokenHash=", hash).First()
if err == orm.ErrNotFound {
return nil, nil
}
return t, err
}
// ListTokensByRefreshFamily returns every row sharing a refresh-token family —
// the rotation chain a reuse-detection event revokes as a unit.
func ListTokensByRefreshFamily(ctx context.Context, db orm.DB, family string) ([]*schema.Token, error) {
if family == "" {
return nil, nil
}
return orm.TypedQuery[schema.Token](db).Filter("RefreshFamily=", family).GetAll(ctx)
}
// DeleteToken removes a token row by (owner, name). A missing row is not an
// error — revocation is idempotent.
func DeleteToken(ctx context.Context, db orm.DB, tok *schema.Token) error {
existing, err := orm.Get[schema.Token](db, tok.Owner+"/"+tok.Name)
if err != nil {
if err == orm.ErrNotFound {
return nil
}
return err
}
return existing.DeleteCtx(ctx)
}
// GetProvider resolves a provider record by (owner, name) — e.g.
// ("admin", "provider-github"). Providers are shared org-level records the
// application's ProviderItem links to by name.
func GetProvider(_ context.Context, db orm.DB, owner, name string) (*schema.Provider, error) {
p, err := orm.TypedQuery[schema.Provider](db).
Filter("Owner=", owner).Filter("Name=", name).First()
if err == orm.ErrNotFound {
return nil, nil
}
return p, err
}
// EnrichProviders resolves each of the application's ProviderItem links to its
// shared Provider record (Category/Type/ClientId), attaching it to
// item.Provider. A link whose provider record is missing is left with a nil
// Provider (the caller treats that as unconfigured — never a dead-end button).
func EnrichProviders(ctx context.Context, db orm.DB, app *schema.Application) {
if app == nil {
return
}
for _, item := range app.Providers {
if item == nil || item.Name == "" {
continue
}
owner := item.Owner
if owner == "" {
owner = "admin" // providers are seeded under the admin org
}
if p, err := GetProvider(ctx, db, owner, item.Name); err == nil && p != nil {
item.Provider = p
}
}
}
// GetOrganizationByName resolves an organization by its name. Orgs are stored
// under the "admin" owner (v1 convention). Returns (nil, nil) when absent.
func GetOrganizationByName(_ context.Context, db orm.DB, name string) (*schema.Organization, error) {
if name == "" {
return nil, nil
}
o, err := orm.TypedQuery[schema.Organization](db).Filter("Name=", name).First()
if err == orm.ErrNotFound {
return nil, nil
}
return o, err
}
// CreateOrganization mints a tenant organization under the "admin" owner (the v1
// convention every other org follows), and returns it. Idempotent by construction:
// GetOrCreate returns the existing row when two founders race the same name, so a
// concurrent signup joins the org rather than erroring or clobbering it.
//
// The org owner is "admin" because that is where orgs live; this grants the org NO
// authority. Authority is a property of the USER row — authz derives Super from
// user.Owner == "admin" — and a self-service signup creates its user under the new
// org, never under "admin". Callers must have refused a reserved name first
// (IsReservedOrg), which is what keeps this from being a path to minting "admin".
func CreateOrganization(_ context.Context, db orm.DB, name string) (*schema.Organization, error) {
if name == "" {
return nil, fmt.Errorf("organization name is required")
}
org, _, err := orm.GetOrCreate[schema.Organization](db, MembershipOwner+"/"+name, func(o *schema.Organization) {
o.Owner = MembershipOwner
o.Name = name
o.DisplayName = name
// No PasswordOptions: an empty policy means "no EXTRA complexity rules on
// top of the platform floor" — it does NOT mean "no policy". The floor
// (min length, enforced in oidc.passwordPolicyError regardless of what an
// org declares) applies to this org exactly as it does to a hand-seeded
// one, so leaving this empty is safe. Do not read it as an exemption: it
// once was one, and an anonymous self-serve signup was accepted with the
// password "a".
})
if err != nil {
return nil, fmt.Errorf("create organization %s: %w", name, err)
}
return org, nil
}
// AddVerificationRecord persists a freshly minted verification code. The id is
// (owner, name); the caller sets Name to a unique value before persisting.
// Mirrors PersistToken: the orm.Model is preserved while the caller's fields are
// copied onto the fresh, db-bound entity.
func AddVerificationRecord(ctx context.Context, db orm.DB, rec *schema.VerificationRecord) error {
r := orm.New[schema.VerificationRecord](db)
model := r.Model
*r = *rec
r.Model = model
r.SetId(rec.Owner + "/" + rec.Name)
return r.CreateCtx(ctx)
}
// GetLatestVerificationRecord resolves the most recent UNUSED verification
// record sent to receiver — the row the check path validates a submitted code
// against. Returns (nil, nil) when none exists.
func GetLatestVerificationRecord(_ context.Context, db orm.DB, receiver string) (*schema.VerificationRecord, error) {
if receiver == "" {
return nil, nil
}
rec, err := orm.TypedQuery[schema.VerificationRecord](db).
Filter("Receiver=", receiver).Filter("IsUsed=", false).Order("-Time").First()
if err == orm.ErrNotFound {
return nil, nil
}
return rec, err
}
// PersistFederationState creates a fresh in-flight federation transaction. The
// id is (owner, name); the caller sets Name to the opaque `state` token before
// persisting. Mirrors PersistToken — the orm.Model is preserved while the
// caller's fields are copied onto the db-bound entity.
func PersistFederationState(ctx context.Context, db orm.DB, st *schema.FederationState) error {
s := orm.New[schema.FederationState](db)
model := s.Model
*s = *st
s.Model = model
s.SetId(st.Owner + "/" + st.Name)
return s.CreateCtx(ctx)
}
// GetFederationState resolves an in-flight federation transaction by its opaque
// `state` token (the row Name). The state is a 256-bit random value, so a
// name-only lookup is unambiguous. Returns (nil, nil) when no row carries it —
// the callback treats that as an invalid/expired state and fails closed.
func GetFederationState(_ context.Context, db orm.DB, state string) (*schema.FederationState, error) {
if state == "" {
return nil, nil
}
s, err := orm.TypedQuery[schema.FederationState](db).Filter("Name=", state).First()
if err == orm.ErrNotFound {
return nil, nil
}
return s, err
}
// ErrFederationConsumed is the ONE opaque refusal for a federation state that is
// gone, already burned, expired, or that lost a concurrent burn — the single-use
// guard's "no". Callers collapse it to the same "invalid or expired" answer so a
// prober cannot tell a replay from a race from a forged state.
var ErrFederationConsumed = errors.New("federation state is invalid, used, or expired")
// BurnFederationState atomically consumes an in-flight federation transaction — the
// single-use guard for the OAuth callback. The find-and-burn runs inside a
// GetForUpdate transaction (mirroring the wallet challenge burn and TakeChallenge),
// so two concurrent callbacks on ONE state cannot both flip Used=false→true: the
// loser blocks until the winner commits, then reads it spent. It resolves the row's
// real storage key via the (owner,name) query path (Name is the opaque `state`
// token), locks by that key, refuses a used/expired row with ErrFederationConsumed
// (no write), else sets Used and returns the burned row. A store fault returns the
// raw error so the caller can distinguish it from the opaque refusal.
//
// The caller performs the browser bind-cookie (CSRF) check on a prior read BEFORE
// calling this, so a request that fails the cookie check never reaches — and never
// burns — a victim's pending state.
func BurnFederationState(ctx context.Context, db orm.DB, state string, now time.Time) (*schema.FederationState, error) {
if state == "" {
return nil, ErrFederationConsumed
}
keyed, err := GetFederationState(ctx, db, state)
if err != nil {
return nil, err
}
if keyed == nil {
return nil, ErrFederationConsumed
}
storageID := keyed.Key().Encode()
var out *schema.FederationState
txErr := db.RunInTransaction(ctx, func(tx orm.DB) error {
fresh, err := orm.GetForUpdate[schema.FederationState](tx, storageID)
if err != nil {
if errors.Is(err, orm.ErrNotFound) {
return ErrFederationConsumed
}
return err
}
if fresh.Used || (fresh.ExpireIn != 0 && now.Unix() > fresh.ExpireIn) {
return ErrFederationConsumed
}
fresh.Used = true
if err := fresh.UpdateCtx(ctx); err != nil {
return err
}
out = fresh
return nil
})
if txErr != nil {
return nil, txErr
}
return out, nil
}
// GetUserByConnector resolves the user in an organization whose federated
// connector column (field — the EXACT lowercase orm/json name, e.g. "google" or
// "github") holds subject. It is the "already linked by provider subject" lookup
// the federation broker runs first. subject must be non-empty (an empty subject
// would match every unlinked row, so it is refused). Returns (nil, nil) when no
// user is linked to that subject.
func GetUserByConnector(_ context.Context, db orm.DB, owner, field, subject string) (*schema.User, error) {
if owner == "" || field == "" || subject == "" {
return nil, nil
}
u, err := orm.TypedQuery[schema.User](db).
Filter("Owner=", owner).Filter(field+"=", subject).First()
if err == orm.ErrNotFound {
return nil, nil
}
return u, err
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// Account lockout — legacy's compensating control for a password endpoint, kept in
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"time"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// TestAuthenticate_ConcurrentWrongPasswords_NoLostUpdates is the crisp atomicity
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"testing"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// Create is the ONE write every account-creation path reaches — password signup,
+1 -1
View File
@@ -22,7 +22,7 @@ import (
"github.com/hanzoai/iam/internal/cred"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// API binds the user handlers to an orm store. Construct once at boot and register.
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/google/uuid"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// A user minted natively in v2 gets a stable opaque UUID `sub` on create, ALWAYS
+1 -1
View File
@@ -15,7 +15,7 @@ import (
wc "github.com/luxwallet/connect/go/walletconnect"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// The chain-agnostic wallet-login core, decomplected from HTTP: the handler
+1 -1
View File
@@ -33,7 +33,7 @@ import (
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/oidc"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// The canonical wallet sign-in endpoints.
+1 -1
View File
@@ -39,7 +39,7 @@ import (
"github.com/hanzoai/iam/internal/routes"
_ "github.com/hanzoai/iam/internal/schema" // registers the v2 entity kinds
"github.com/hanzoai/iam/internal/seed"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/pkg/store"
)
// version is set at build time via -ldflags "-X main.version=vX.Y.Z".
+127
View File
@@ -0,0 +1,127 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package store is the IN-PROCESS entity store surface for a host binary that
// EMBEDS iam (hanzoai/cloud) rather than talking to it over HTTP. It exposes the
// ONE project CRUD path (internal/projects) and the read-only user ROSTER
// (users.go) as plain functions over an explicit orm.DB — there is NO
// package-global engine in v2, so the db a host opened with server.OpenSQLite
// (or bound from its own orm.DB) is passed on every call.
//
// The functions reproduce the exact orm calls internal/projects uses (the ONE
// query path — Filter/Order/GetAll for reads, orm.New+CreateCtx for a write,
// orm.Get+DeleteCtx for a delete) and preserve the "owner/name" id semantics, so
// an embedder reads and writes the SAME `projects` rows the registered /v1/iam/projects
// HTTP surface serves — one store, no drift. Reads return the shared model.Project
// (an alias of the core schema.Project), so the host never clones a project struct.
//
// Not-found convention differs from the HTTP handler on purpose: an embedder
// pre-checks existence (Get→nil means "create is safe"), so GetProject returns
// (nil, nil) for a missing project — the same convention the retired iam-v1 object
// store used, which is what the host's pre-check logic expects.
package store
import (
"context"
"errors"
"time"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/pkg/model"
)
// key is the orm string key for a project: "owner/name".
func key(owner, name string) string { return owner + "/" + name }
// GetProjects lists the projects owned by owner, newest first. An empty owner
// lists EVERY project — the unscoped admin (all-orgs) view, matching the registered
// /v1/iam/projects listing an empty owner serves.
func GetProjects(db orm.DB, owner string) ([]*model.Project, error) {
return listByOwner(db, owner)
}
// GetOrganizationProjects lists the projects of one organization. Owner IS the
// owning organization in v2 (schema.Project doc), so this is the owner-scoped
// listing — the org-scoped reflection a tenant sees.
func GetOrganizationProjects(db orm.DB, org string) ([]*model.Project, error) {
return listByOwner(db, org)
}
// listByOwner is the ONE list path both public readers share. It reproduces the
// internal projects List query verbatim: filter by owner (skipped when empty →
// all owners), ordered by newest createdTime.
func listByOwner(db orm.DB, owner string) ([]*model.Project, error) {
q := orm.TypedQuery[model.Project](db)
if owner != "" {
q = q.Filter("owner", owner)
}
return q.Order("-createdTime").GetAll(context.Background())
}
// GetProject returns one project addressed by its "owner/name" id, or (nil, nil)
// when it does not exist (the embedder pre-check convention). Any other orm error
// is returned as-is.
func GetProject(db orm.DB, id string) (*model.Project, error) {
p, err := orm.Get[model.Project](db, id)
if errors.Is(err, orm.ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return p, nil
}
// AddProject persists a new project from the caller-built value, reproducing the
// internal Create: a fresh orm-bound record (so defaults + db binding are applied),
// the mutable domain fields copied over, Organization defaulted to Owner, the
// created stamp filled if absent, and the "owner/name" id set. Returns true when
// the row was written. The caller pre-checks existence, so a duplicate surfaces as
// the orm insert error rather than being silently coalesced.
func AddProject(db orm.DB, in *model.Project) (bool, error) {
if in == nil || in.Owner == "" || in.Name == "" {
return false, errors.New("iam store: project owner and name are required")
}
p := orm.New[model.Project](db)
p.Owner = in.Owner
p.Name = in.Name
p.CreatedTime = in.CreatedTime
if p.CreatedTime == "" {
p.CreatedTime = time.Now().UTC().Format(time.RFC3339)
}
p.DisplayName = in.DisplayName
p.Description = in.Description
p.Organization = in.Organization
if p.Organization == "" {
p.Organization = in.Owner
}
p.Workspace = in.Workspace
p.Tags = in.Tags
p.Metadata = in.Metadata
p.IsDefault = in.IsDefault
p.SetId(key(in.Owner, in.Name))
if err := p.CreateCtx(context.Background()); err != nil {
return false, err
}
return true, nil
}
// DeleteProject removes the project keyed by (Owner, Name) on the passed value.
// A missing project is not an error — it reports (false, nil), matching the
// affected-rows semantics the embedder expects; a real orm error is returned.
func DeleteProject(db orm.DB, in *model.Project) (bool, error) {
if in == nil || in.Owner == "" || in.Name == "" {
return false, errors.New("iam store: project owner and name are required")
}
p, err := orm.Get[model.Project](db, key(in.Owner, in.Name))
if errors.Is(err, orm.ErrNotFound) {
return false, nil
}
if err != nil {
return false, err
}
if err := p.DeleteCtx(context.Background()); err != nil {
return false, err
}
return true, nil
}
+605 -99
View File
@@ -1,127 +1,633 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package store is the IN-PROCESS entity store surface for a host binary that
// EMBEDS iam (hanzoai/cloud) rather than talking to it over HTTP. It exposes the
// ONE project CRUD path (internal/projects) and the read-only user ROSTER
// (users.go) as plain functions over an explicit orm.DB — there is NO
// package-global engine in v2, so the db a host opened with server.OpenSQLite
// (or bound from its own orm.DB) is passed on every call.
// Package store is the IAM object layer: thin, typed reads over hanzoai/orm
// against the Phase-1 entities. It replaces the v1 xorm ormer.Engine fluent
// calls with orm.TypedQuery, so handlers depend on named operations
// (GetApplicationByClientId, GetProvider, …) rather than a query builder.
//
// The functions reproduce the exact orm calls internal/projects uses (the ONE
// query path — Filter/Order/GetAll for reads, orm.New+CreateCtx for a write,
// orm.Get+DeleteCtx for a delete) and preserve the "owner/name" id semantics, so
// an embedder reads and writes the SAME `projects` rows the registered /v1/iam/projects
// HTTP surface serves — one store, no drift. Reads return the shared model.Project
// (an alias of the core schema.Project), so the host never clones a project struct.
//
// Not-found convention differs from the HTTP handler on purpose: an embedder
// pre-checks existence (Get→nil means "create is safe"), so GetProject returns
// (nil, nil) for a missing project — the same convention the retired iam-v1 object
// store used, which is what the host's pre-check logic expects.
// Every function takes a context and an orm.DB — one storage abstraction,
// backend-agnostic (sqlite / hanzoai/sql / hanzoai/datastore).
package store
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/pkg/model"
"github.com/hanzoai/iam/internal/schema"
)
// key is the orm string key for a project: "owner/name".
func key(owner, name string) string { return owner + "/" + name }
// GetProjects lists the projects owned by owner, newest first. An empty owner
// lists EVERY project — the unscoped admin (all-orgs) view, matching the registered
// /v1/iam/projects listing an empty owner serves.
func GetProjects(db orm.DB, owner string) ([]*model.Project, error) {
return listByOwner(db, owner)
}
// GetOrganizationProjects lists the projects of one organization. Owner IS the
// owning organization in v2 (schema.Project doc), so this is the owner-scoped
// listing — the org-scoped reflection a tenant sees.
func GetOrganizationProjects(db orm.DB, org string) ([]*model.Project, error) {
return listByOwner(db, org)
}
// listByOwner is the ONE list path both public readers share. It reproduces the
// internal projects List query verbatim: filter by owner (skipped when empty →
// all owners), ordered by newest createdTime.
func listByOwner(db orm.DB, owner string) ([]*model.Project, error) {
q := orm.TypedQuery[model.Project](db)
if owner != "" {
q = q.Filter("owner", owner)
}
return q.Order("-createdTime").GetAll(context.Background())
}
// GetProject returns one project addressed by its "owner/name" id, or (nil, nil)
// when it does not exist (the embedder pre-check convention). Any other orm error
// is returned as-is.
func GetProject(db orm.DB, id string) (*model.Project, error) {
p, err := orm.Get[model.Project](db, id)
if errors.Is(err, orm.ErrNotFound) {
return nil, nil
}
// GetApplicationByClientId resolves an OAuth2/OIDC client by its clientId,
// DETERMINISTICALLY: among any rows carrying clientId it returns the platform-
// preferred one — a reserved signing owner (admin/built-in) outranks a tenant, and
// within a tier the lexically-least (owner,name) wins. clientId is globally unique
// by the applications create/update guard, so this normally has exactly one
// candidate; the ordering is defense-in-depth that makes a stray duplicate resolve
// to the PLATFORM row rather than whichever row the storage engine's heap happened
// to return first. A First() with no ORDER BY was the collidable-mint vector (safe
// on dev sqlite by rowid, UNSPECIFIED on Postgres): a tenant that registered a row
// with a mint-allow-listed clientId could have its row win resolution and
// authenticate a mint. This can no longer happen — the platform row always wins,
// and the owner-pin on the mint/capability gates denies a non-signing owner even if
// it did. Returns (nil, nil) when no application matches.
func GetApplicationByClientId(ctx context.Context, db orm.DB, clientId string) (*schema.Application, error) {
apps, err := ListApplicationsByClientId(ctx, db, clientId)
if err != nil {
return nil, err
}
return p, nil
return preferredApp(apps), nil
}
// AddProject persists a new project from the caller-built value, reproducing the
// internal Create: a fresh orm-bound record (so defaults + db binding are applied),
// the mutable domain fields copied over, Organization defaulted to Owner, the
// created stamp filled if absent, and the "owner/name" id set. Returns true when
// the row was written. The caller pre-checks existence, so a duplicate surfaces as
// the orm insert error rather than being silently coalesced.
func AddProject(db orm.DB, in *model.Project) (bool, error) {
if in == nil || in.Owner == "" || in.Name == "" {
return false, errors.New("iam store: project owner and name are required")
// ListApplicationsByClientId returns EVERY application row carrying clientId — the
// ONE place "which applications share this clientId" is answered. It backs both the
// deterministic single resolve above and the global-uniqueness guard the
// applications create/update path enforces: a JSON-document store has no per-field
// column to hang a DB UNIQUE index on, so clientId uniqueness is enforced at the
// write, exactly as the (owner,name) natural key already is. Returns nil when
// clientId is empty or unmatched.
func ListApplicationsByClientId(ctx context.Context, db orm.DB, clientId string) ([]*schema.Application, error) {
if clientId == "" {
return nil, nil
}
p := orm.New[model.Project](db)
p.Owner = in.Owner
p.Name = in.Name
p.CreatedTime = in.CreatedTime
if p.CreatedTime == "" {
p.CreatedTime = time.Now().UTC().Format(time.RFC3339)
}
p.DisplayName = in.DisplayName
p.Description = in.Description
p.Organization = in.Organization
if p.Organization == "" {
p.Organization = in.Owner
}
p.Workspace = in.Workspace
p.Tags = in.Tags
p.Metadata = in.Metadata
p.IsDefault = in.IsDefault
p.SetId(key(in.Owner, in.Name))
if err := p.CreateCtx(context.Background()); err != nil {
return false, err
}
return true, nil
return orm.TypedQuery[schema.Application](db).Filter("ClientId=", clientId).GetAll(ctx)
}
// DeleteProject removes the project keyed by (Owner, Name) on the passed value.
// A missing project is not an error — it reports (false, nil), matching the
// affected-rows semantics the embedder expects; a real orm error is returned.
func DeleteProject(db orm.DB, in *model.Project) (bool, error) {
if in == nil || in.Owner == "" || in.Name == "" {
return false, errors.New("iam store: project owner and name are required")
// preferredApp deterministically selects the platform-preferred application among
// rows sharing a clientId (see morePreferredApp for the total order). Returns nil
// for an empty set, preserving GetApplicationByClientId's (nil, nil) not-found
// contract.
func preferredApp(apps []*schema.Application) *schema.Application {
var best *schema.Application
for _, a := range apps {
if a == nil {
continue
}
if best == nil || morePreferredApp(a, best) {
best = a
}
}
p, err := orm.Get[model.Project](db, key(in.Owner, in.Name))
if errors.Is(err, orm.ErrNotFound) {
return false, nil
return best
}
// morePreferredApp reports whether a outranks b for clientId resolution: a reserved
// signing owner (admin/built-in) outranks a non-reserved one; within the same tier
// the lexically-least (owner,name) wins. The order is total and independent of
// storage/heap order, so resolution is deterministic on every backend.
func morePreferredApp(a, b *schema.Application) bool {
if sa, sb := IsSigningCertOwner(a.Owner), IsSigningCertOwner(b.Owner); sa != sb {
return sa
}
return a.Owner+"/"+a.Name < b.Owner+"/"+b.Name
}
// GetApplicationByName resolves an application by (owner, name).
func GetApplicationByName(_ context.Context, db orm.DB, owner, name string) (*schema.Application, error) {
app, err := orm.TypedQuery[schema.Application](db).
Filter("Owner=", owner).Filter("Name=", name).First()
if err == orm.ErrNotFound {
return nil, nil
}
return app, err
}
// GetUserByName resolves a user by (owner, name) — owner is the organization.
// Returns (nil, nil) when absent.
//
// Case does not distinguish principals. New names are normalized to lowercase at
// creation (schema.Username), but rows written before that rule are stored as they
// arrived, and renaming them would move real principals — so the RESOLUTION
// tolerates case instead of the data being rewritten. Three steps, cheapest first:
// the exact key, then the folded key (both indexed lookups, and between them they
// answer every all-lowercase row, which is all of them going forward), then a
// case-insensitive pass over the org for a legacy mixed-case row.
//
// It FAILS CLOSED when the folding is ambiguous — the same rule GetUserById
// applies to a duplicated subject. If "Alice" and "ALICE" both exist, "alice"
// names neither of them in particular, and answering with the storage engine's
// arbitrary first row would let whoever registered the second one be resolved as
// the first. An exact match always wins, so a row that is spelled the way it was
// asked for is never subject to this.
func GetUserByName(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 {
return u, nil
}
if err != orm.ErrNotFound {
return nil, err
}
folded := strings.ToLower(strings.TrimSpace(name))
if folded == "" {
return nil, nil
}
if folded != name {
u, err := orm.TypedQuery[schema.User](db).Filter("Owner=", owner).Filter("Name=", folded).First()
if err == nil {
return u, nil
}
if err != orm.ErrNotFound {
return nil, err
}
}
us, err := orm.TypedQuery[schema.User](db).Filter("Owner=", owner).GetAll(ctx)
if err != nil {
return false, err
return nil, err
}
if err := p.DeleteCtx(context.Background()); err != nil {
return false, err
var match *schema.User
for _, cand := range us {
if !strings.EqualFold(cand.Name, folded) {
continue
}
if match != nil {
return nil, fmt.Errorf("store: %q and %q in organization %q both fold to %q — ambiguous username", match.Name, cand.Name, owner, folded)
}
match = cand
}
return true, nil
return match, nil
}
// GetUserById resolves a user by its stable opaque Id — the UUID the OIDC `sub`
// carries (schema.User.Id). Filters on the persisted "id" field, which the
// domain Id dominates over the embedded orm storage id, so the value matched is
// the UUID, never the (owner,name) key. Returns (nil, nil) when id is empty or
// unmatched (a pre-cutover user with no Id has "" here and is never matched by a
// non-empty subject).
//
// It FAILS CLOSED on multiplicity: the store has no DB UNIQUE index on Id, so if two
// rows ever shared one UUID (a broken invariant — Id is the OIDC `sub` and the authz
// principal key), returning the storage engine's arbitrary First() would let an
// attacker who planted a colliding row be resolved AS a victim. More than one match
// is therefore an error, never a silently-chosen row.
func GetUserById(ctx context.Context, db orm.DB, id string) (*schema.User, error) {
if id == "" {
return nil, nil
}
us, err := orm.TypedQuery[schema.User](db).Filter("Id=", id).GetAll(ctx)
if err != nil {
return nil, err
}
switch len(us) {
case 0:
return nil, nil
case 1:
return us[0], nil
default:
return nil, fmt.Errorf("store: %d users share id %q — ambiguous subject", len(us), id)
}
}
// GetUserBySubject resolves the user a token's `sub` names — the ONE place the
// subject→user mapping lives, shared by userinfo, get-account, token exchange,
// and the authz principal, so `sub` is decoded the same way everywhere. The
// discriminator is deterministic and matches how a `sub` is MINTED (subjectOf):
// a stable opaque UUID carries no "/" and resolves by Id; an "owner/name" subject
// (a pre-cutover user with no Id, or a machine token's app identity) resolves by
// its natural key. Returns (nil, nil) when no user matches — a machine token's
// app-id subject, or a since-deleted user — the callers fail closed on nil.
func GetUserBySubject(ctx context.Context, db orm.DB, sub string) (*schema.User, error) {
if sub == "" {
return nil, nil
}
if owner, name, hasSlash := strings.Cut(sub, "/"); hasSlash {
if owner == "" || name == "" {
return nil, nil
}
return GetUserByName(ctx, db, owner, name)
}
return GetUserById(ctx, db, sub)
}
// GetUserByEmail resolves a user by (owner, email) — the email-login identifier.
func GetUserByEmail(_ context.Context, db orm.DB, owner, email string) (*schema.User, error) {
u, err := orm.TypedQuery[schema.User](db).Filter("Owner=", owner).Filter("Email=", email).First()
if err == orm.ErrNotFound {
return nil, nil
}
return u, err
}
// GetTokenByCode resolves a token row by its authorization code. Returns
// (nil, nil) when no row carries the code.
func GetTokenByCode(_ context.Context, db orm.DB, code string) (*schema.Token, error) {
if code == "" {
return nil, nil
}
t, err := orm.TypedQuery[schema.Token](db).Filter("Code=", code).First()
if err == orm.ErrNotFound {
return nil, nil
}
return t, err
}
// GetCert resolves a signing certificate by (owner, name).
func GetCert(_ context.Context, db orm.DB, owner, name string) (*schema.Cert, error) {
c, err := orm.TypedQuery[schema.Cert](db).Filter("Owner=", owner).Filter("Name=", name).First()
if err == orm.ErrNotFound {
return nil, nil
}
return c, err
}
// signingCertOwners are the reserved platform organizations that own
// token-signing certificates. A signing cert is trusted ONLY under these
// owners, so a tenant can never shadow a platform signing key by creating a cert
// with the same name (the JWKS `kid`) under its own org and forging tokens.
var signingCertOwners = []string{"admin", "built-in"}
// IsSigningCertOwner reports whether owner is a reserved platform signing-cert
// owner — the trust boundary the JWKS and token verification enforce.
func IsSigningCertOwner(owner string) bool {
for _, o := range signingCertOwners {
if o == owner {
return true
}
}
return false
}
// GetTokenByUserCode resolves a pending device authorization by the user_code a
// human transcribes at the verification URI (RFC 8628 §3.3). Returns (nil, nil)
// when no row carries the code.
func GetTokenByUserCode(_ context.Context, db orm.DB, userCode string) (*schema.Token, error) {
if userCode == "" {
return nil, nil
}
t, err := orm.TypedQuery[schema.Token](db).Filter("UserCode=", userCode).First()
if err == orm.ErrNotFound {
return nil, nil
}
return t, err
}
// IsSuperAdmin reports whether owner is the reserved admin organization — THE
// SuperAdmin predicate, the only cross-tenant scope there is. It mirrors authz's
// own adminOrg so a subsystem BELOW the authz seam (the device-approval tenant
// gate) can ask the same question without importing authz. A per-org isAdmin
// flag is a different, org-scoped question and never answers this one.
func IsSuperAdmin(owner string) bool { return owner == "admin" }
// reservedServiceOrg is the system organization that owns service/app principals —
// reserved alongside the signing-cert owners, but not itself a signing owner.
const reservedServiceOrg = "app"
// IsReservedOrg reports whether owner is a SYSTEM organization a self-service,
// federated, or otherwise customer-driven flow may NEVER land a principal in. It is
// the ONE predicate that boundary shares (signup, onboarding, and federated
// provisioning all consult it), so the reserved set is defined in exactly one place
// and can never drift between those surfaces.
//
// The set is the SuperAdmin/signing trust boundary — admin and built-in, i.e.
// IsSigningCertOwner, composed so a newly-reserved signing owner is covered here for
// free — plus the service-principal org "app". A user created under any of these is a
// platform identity, not a customer: a user under "admin" is a SuperAdmin (authz
// derives Super from owner == "admin"), and a signing/built-in or service org is
// platform trust material. These orgs are seeded, onboarded by a SuperAdmin, or
// provisioned by the operator's service token — never reached by a public signup or
// an external login. Fail-closed by construction: an unknown org is NOT reserved, so
// legitimate tenants are unaffected while every reserved org is refused.
func IsReservedOrg(owner string) bool {
return IsSigningCertOwner(owner) || owner == reservedServiceOrg
}
// GetSigningCert resolves a TRUSTED signing certificate by name (the JWKS
// `kid`), searching only the reserved platform owners in order. A cert owned by
// any other org is never returned, so an attacker-created cert with a colliding
// name can neither sign a token iam will verify nor be published in the JWKS.
// Returns (nil, nil) when no trusted cert carries the name.
func GetSigningCert(ctx context.Context, db orm.DB, name string) (*schema.Cert, error) {
if name == "" {
return nil, nil
}
for _, owner := range signingCertOwners {
c, err := GetCert(ctx, db, owner, name)
if err != nil {
return nil, err
}
if c != nil {
return c, nil
}
}
return nil, nil
}
// PersistToken binds a domain Token onto the store and creates it. Used to
// persist an authorization code minted by oidc.MintCode. The id is (owner, name);
// callers set Name to a unique value (e.g. the code) before persisting.
func PersistToken(ctx context.Context, db orm.DB, tok *schema.Token) error {
t := orm.New[schema.Token](db)
model := t.Model
*t = *tok
t.Model = model
name := tok.Name
if name == "" {
name = tok.Code // codes are unique; use as the row name when none given
t.Name = name
}
t.SetId(tok.Owner + "/" + name)
return t.CreateCtx(ctx)
}
// SaveToken read-modify-writes an existing token row (e.g. after redemption:
// CodeIsUsed=true + AccessToken set). It looks the row up by (owner, name),
// copies the mutated fields, and updates in place.
func SaveToken(ctx context.Context, db orm.DB, tok *schema.Token) error {
existing, err := orm.Get[schema.Token](db, tok.Owner+"/"+tok.Name)
if err != nil {
return err
}
model := existing.Model
*existing = *tok
existing.Model = model
return existing.UpdateCtx(ctx)
}
// ListCerts returns every certificate ordered by name. The JWKS endpoint calls
// this and filters to the token-signing certs it publishes.
func ListCerts(ctx context.Context, db orm.DB) ([]*schema.Cert, error) {
return orm.TypedQuery[schema.Cert](db).Order("Name").GetAll(ctx)
}
// PlatformSigningCert returns a deterministic trusted signing cert — the first by
// (owner, name) order among the reserved platform owners that carries a private
// key. It keys deployment-stable secret derivations (the session-cookie MAC), so
// there is no new secret to provision; a tenant cert can never be chosen. Returns
// (nil, nil) when none is seeded.
func PlatformSigningCert(ctx context.Context, db orm.DB) (*schema.Cert, error) {
certs, err := ListCerts(ctx, db)
if err != nil {
return nil, err
}
var best *schema.Cert
for _, c := range certs {
if c == nil || c.PrivateKey == "" || !IsSigningCertOwner(c.Owner) {
continue
}
if best == nil || c.Owner+"/"+c.Name < best.Owner+"/"+best.Name {
best = c
}
}
return best, nil
}
// GetTokenByAccessTokenHash resolves a live token row by the SHA-256 hash of a
// presented access token — the userinfo bearer lookup. Because the row is the
// authorization server's memory of the grant, a deleted/rotated row means the
// bearer is revoked, independent of the JWT's own expiry.
func GetTokenByAccessTokenHash(_ context.Context, db orm.DB, hash string) (*schema.Token, error) {
if hash == "" {
return nil, nil
}
t, err := orm.TypedQuery[schema.Token](db).Filter("AccessTokenHash=", hash).First()
if err == orm.ErrNotFound {
return nil, nil
}
return t, err
}
// GetTokenByRefreshHash resolves a token row by the SHA-256 hash of a presented
// refresh token — the refresh-grant lookup.
func GetTokenByRefreshHash(_ context.Context, db orm.DB, hash string) (*schema.Token, error) {
if hash == "" {
return nil, nil
}
t, err := orm.TypedQuery[schema.Token](db).Filter("RefreshTokenHash=", hash).First()
if err == orm.ErrNotFound {
return nil, nil
}
return t, err
}
// ListTokensByRefreshFamily returns every row sharing a refresh-token family —
// the rotation chain a reuse-detection event revokes as a unit.
func ListTokensByRefreshFamily(ctx context.Context, db orm.DB, family string) ([]*schema.Token, error) {
if family == "" {
return nil, nil
}
return orm.TypedQuery[schema.Token](db).Filter("RefreshFamily=", family).GetAll(ctx)
}
// DeleteToken removes a token row by (owner, name). A missing row is not an
// error — revocation is idempotent.
func DeleteToken(ctx context.Context, db orm.DB, tok *schema.Token) error {
existing, err := orm.Get[schema.Token](db, tok.Owner+"/"+tok.Name)
if err != nil {
if err == orm.ErrNotFound {
return nil
}
return err
}
return existing.DeleteCtx(ctx)
}
// GetProvider resolves a provider record by (owner, name) — e.g.
// ("admin", "provider-github"). Providers are shared org-level records the
// application's ProviderItem links to by name.
func GetProvider(_ context.Context, db orm.DB, owner, name string) (*schema.Provider, error) {
p, err := orm.TypedQuery[schema.Provider](db).
Filter("Owner=", owner).Filter("Name=", name).First()
if err == orm.ErrNotFound {
return nil, nil
}
return p, err
}
// EnrichProviders resolves each of the application's ProviderItem links to its
// shared Provider record (Category/Type/ClientId), attaching it to
// item.Provider. A link whose provider record is missing is left with a nil
// Provider (the caller treats that as unconfigured — never a dead-end button).
func EnrichProviders(ctx context.Context, db orm.DB, app *schema.Application) {
if app == nil {
return
}
for _, item := range app.Providers {
if item == nil || item.Name == "" {
continue
}
owner := item.Owner
if owner == "" {
owner = "admin" // providers are seeded under the admin org
}
if p, err := GetProvider(ctx, db, owner, item.Name); err == nil && p != nil {
item.Provider = p
}
}
}
// GetOrganizationByName resolves an organization by its name. Orgs are stored
// under the "admin" owner (v1 convention). Returns (nil, nil) when absent.
func GetOrganizationByName(_ context.Context, db orm.DB, name string) (*schema.Organization, error) {
if name == "" {
return nil, nil
}
o, err := orm.TypedQuery[schema.Organization](db).Filter("Name=", name).First()
if err == orm.ErrNotFound {
return nil, nil
}
return o, err
}
// CreateOrganization mints a tenant organization under the "admin" owner (the v1
// convention every other org follows), and returns it. Idempotent by construction:
// GetOrCreate returns the existing row when two founders race the same name, so a
// concurrent signup joins the org rather than erroring or clobbering it.
//
// The org owner is "admin" because that is where orgs live; this grants the org NO
// authority. Authority is a property of the USER row — authz derives Super from
// user.Owner == "admin" — and a self-service signup creates its user under the new
// org, never under "admin". Callers must have refused a reserved name first
// (IsReservedOrg), which is what keeps this from being a path to minting "admin".
func CreateOrganization(_ context.Context, db orm.DB, name string) (*schema.Organization, error) {
if name == "" {
return nil, fmt.Errorf("organization name is required")
}
org, _, err := orm.GetOrCreate[schema.Organization](db, MembershipOwner+"/"+name, func(o *schema.Organization) {
o.Owner = MembershipOwner
o.Name = name
o.DisplayName = name
// No PasswordOptions: an empty policy means "no EXTRA complexity rules on
// top of the platform floor" — it does NOT mean "no policy". The floor
// (min length, enforced in oidc.passwordPolicyError regardless of what an
// org declares) applies to this org exactly as it does to a hand-seeded
// one, so leaving this empty is safe. Do not read it as an exemption: it
// once was one, and an anonymous self-serve signup was accepted with the
// password "a".
})
if err != nil {
return nil, fmt.Errorf("create organization %s: %w", name, err)
}
return org, nil
}
// AddVerificationRecord persists a freshly minted verification code. The id is
// (owner, name); the caller sets Name to a unique value before persisting.
// Mirrors PersistToken: the orm.Model is preserved while the caller's fields are
// copied onto the fresh, db-bound entity.
func AddVerificationRecord(ctx context.Context, db orm.DB, rec *schema.VerificationRecord) error {
r := orm.New[schema.VerificationRecord](db)
model := r.Model
*r = *rec
r.Model = model
r.SetId(rec.Owner + "/" + rec.Name)
return r.CreateCtx(ctx)
}
// GetLatestVerificationRecord resolves the most recent UNUSED verification
// record sent to receiver — the row the check path validates a submitted code
// against. Returns (nil, nil) when none exists.
func GetLatestVerificationRecord(_ context.Context, db orm.DB, receiver string) (*schema.VerificationRecord, error) {
if receiver == "" {
return nil, nil
}
rec, err := orm.TypedQuery[schema.VerificationRecord](db).
Filter("Receiver=", receiver).Filter("IsUsed=", false).Order("-Time").First()
if err == orm.ErrNotFound {
return nil, nil
}
return rec, err
}
// PersistFederationState creates a fresh in-flight federation transaction. The
// id is (owner, name); the caller sets Name to the opaque `state` token before
// persisting. Mirrors PersistToken — the orm.Model is preserved while the
// caller's fields are copied onto the db-bound entity.
func PersistFederationState(ctx context.Context, db orm.DB, st *schema.FederationState) error {
s := orm.New[schema.FederationState](db)
model := s.Model
*s = *st
s.Model = model
s.SetId(st.Owner + "/" + st.Name)
return s.CreateCtx(ctx)
}
// GetFederationState resolves an in-flight federation transaction by its opaque
// `state` token (the row Name). The state is a 256-bit random value, so a
// name-only lookup is unambiguous. Returns (nil, nil) when no row carries it —
// the callback treats that as an invalid/expired state and fails closed.
func GetFederationState(_ context.Context, db orm.DB, state string) (*schema.FederationState, error) {
if state == "" {
return nil, nil
}
s, err := orm.TypedQuery[schema.FederationState](db).Filter("Name=", state).First()
if err == orm.ErrNotFound {
return nil, nil
}
return s, err
}
// ErrFederationConsumed is the ONE opaque refusal for a federation state that is
// gone, already burned, expired, or that lost a concurrent burn — the single-use
// guard's "no". Callers collapse it to the same "invalid or expired" answer so a
// prober cannot tell a replay from a race from a forged state.
var ErrFederationConsumed = errors.New("federation state is invalid, used, or expired")
// BurnFederationState atomically consumes an in-flight federation transaction — the
// single-use guard for the OAuth callback. The find-and-burn runs inside a
// GetForUpdate transaction (mirroring the wallet challenge burn and TakeChallenge),
// so two concurrent callbacks on ONE state cannot both flip Used=false→true: the
// loser blocks until the winner commits, then reads it spent. It resolves the row's
// real storage key via the (owner,name) query path (Name is the opaque `state`
// token), locks by that key, refuses a used/expired row with ErrFederationConsumed
// (no write), else sets Used and returns the burned row. A store fault returns the
// raw error so the caller can distinguish it from the opaque refusal.
//
// The caller performs the browser bind-cookie (CSRF) check on a prior read BEFORE
// calling this, so a request that fails the cookie check never reaches — and never
// burns — a victim's pending state.
func BurnFederationState(ctx context.Context, db orm.DB, state string, now time.Time) (*schema.FederationState, error) {
if state == "" {
return nil, ErrFederationConsumed
}
keyed, err := GetFederationState(ctx, db, state)
if err != nil {
return nil, err
}
if keyed == nil {
return nil, ErrFederationConsumed
}
storageID := keyed.Key().Encode()
var out *schema.FederationState
txErr := db.RunInTransaction(ctx, func(tx orm.DB) error {
fresh, err := orm.GetForUpdate[schema.FederationState](tx, storageID)
if err != nil {
if errors.Is(err, orm.ErrNotFound) {
return ErrFederationConsumed
}
return err
}
if fresh.Used || (fresh.ExpireIn != 0 && now.Unix() > fresh.ExpireIn) {
return ErrFederationConsumed
}
fresh.Used = true
if err := fresh.UpdateCtx(ctx); err != nil {
return err
}
out = fresh
return nil
})
if txErr != nil {
return nil, txErr
}
return out, nil
}
// GetUserByConnector resolves the user in an organization whose federated
// connector column (field — the EXACT lowercase orm/json name, e.g. "google" or
// "github") holds subject. It is the "already linked by provider subject" lookup
// the federation broker runs first. subject must be non-empty (an empty subject
// would match every unlinked row, so it is refused). Returns (nil, nil) when no
// user is linked to that subject.
func GetUserByConnector(_ context.Context, db orm.DB, owner, field, subject string) (*schema.User, error) {
if owner == "" || field == "" || subject == "" {
return nil, nil
}
u, err := orm.TypedQuery[schema.User](db).
Filter("Owner=", owner).Filter(field+"=", subject).First()
if err == orm.ErrNotFound {
return nil, nil
}
return u, err
}