store: one store package, not two
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user