Centralized IAM & Authorization Platform
Status: v1.0 — build-ready · Audience: an autonomous LLM coding agent + human reviewers
Codename: iam · Repo style: single pnpm + Turborepo monorepo
Methodology: Test-Driven. Tests are the spec. Every unit below ships its acceptance test FIRST.
How an LLM Must Use This Document
This PRD is written outcome-first. For every buildable unit you will find this exact shape:
Contract — what the unit guarantees (the interface). Test (write FIRST) — executable acceptance criteria. Author and run these before implementation. They MUST fail (red) before code exists. Implementation — how to satisfy the test. Done when — the green gate.
Rules you MUST follow:
- Never write implementation before its test exists and fails. A unit is not “started” until its red test is committed.
- RFC-2119 keywords (MUST/MUST NOT/SHOULD/MAY) are normative.
- §14 Invariants override convenience everywhere. If a task conflicts with an invariant, stop and surface it.
- The SQL schema (§5) is the contract, not any ORM. Kysely types are generated from the DB, never the reverse.
- Stack (§2) is fixed. Do not substitute libraries silently.
- Build in milestone order (§13). Each milestone is a red→green cycle.
Vision & Scope
One microservice that is three things at once:
- Identity broker — users sign in with GitHub, Google, and enterprise IdPs (SAML/OIDC); all resolve to one canonical identity.
- OAuth 2.1 / OIDC provider — ~30 first-party apps are OAuth clients; they redirect to one hosted login and receive verifiable JWTs.
- Authorization engine — roles & permissions defined ONCE, enforced identically in React (hide UI) and in every API (block calls), across a multi-tenant hierarchy.
Outcome metric: a new app integrates auth + authz in under 1 day via one client package + one declarative client-config PR. Zero bespoke permission code.
Non-goals (v1)
- Not a per-app feature backend. Owns identity, tokens, and authorization decisions only.
- Not a general policy language. Authorization = RBAC + scoped grants. ReBAC is a deferred option (§9.4).
- Not cross-domain cookie sharing. Apps get tokens via the OAuth flow.
- ORM-agnostic, not DB-agnostic. Postgres is committed; use its native types freely.
Fixed Stack
| Concern | Choice | Notes |
|---|---|---|
| Auth framework | Better Auth | core + plugins below |
| Federation IN | social providers (GitHub, Google) + @better-auth/sso | SAML 2.0 + OIDC |
| Tokens OUT | OAuth 2.1 Provider plugin | replaces deprecating OIDC Provider; JWKS, introspection (RFC 7662), revocation (RFC 7009) |
| JWT/JWKS | jwt plugin | exposes /api/auth/jwks; kid in header |
| RBAC primitives | organization + admin plugins, createAccessControl | resource:action statements |
| Authorization | CASL (@casl/ability, @casl/react) | isomorphic; one Ability gates UI + API |
| Query layer | Kysely (+ kysely-codegen) | typesafe SQL; DB is source of truth. No Prisma. |
| DB | PostgreSQL | uuid, jsonb, partial indexes, recursive CTEs, RLS optional |
| Migrations | Atlas (or dbmate) | ORM-independent, readable by non-TS consumers |
| Downstream (TS) | @iam/client SDK | AuthProvider, PermissionsProvider, hooks |
| Test (TS) | Vitest + Testing Library | unit/integration |
| Test (components) | Storybook + @storybook/test play fns + a11y | component states ARE the tests |
| Test (E2E) | Playwright | full auth flows |
| Schema validation | Ajv (JSON Schema) | DB & API payload validators |
Monorepo Layout
iam/├── apps/│ ├── iam-service/ # Better Auth + Kysely + PDP (the backend API)│ └── iam-login/ # hosted login + OAuth consent UI (the IdP front door)├── packages/│ ├── iam-core/ # ISOMORPHIC: types, permission catalog, CASL ability factory, JSON Schemas│ ├── iam-db/ # Kysely types (generated), repositories, query fns│ ├── iam-client/ # React: AuthProvider, PermissionsProvider, Can, hooks, callback│ ├── iam-ui/ # React components: forms, settings, members (design-system driven)├── db/│ ├── migrations/ # Atlas SQL — the CONTRACT│ └── schema/ # *.schema.json + domain.yaml (source of truth definitions)├── test/│ ├── fixtures/ # shared seed data (users, scopes, roles)│ └── e2e/ # Playwright└── turbo.jsonDependency direction (MUST hold): iam-core depends on nothing app-specific. iam-db, iam-client, iam-ui depend on iam-core. Apps depend on packages. Nothing depends back upward. The CASL ability factory lives in iam-core precisely because it must run identically in React and in the Node API.
Architecture & Workflows (LLM-Friendly Diagrams)
The Three Planes
Upstream IdPs IAM SERVICE GitHub / Google / Okta / Entra ──┐ │ federation in (social + @better-auth/sso) ▼ ┌──────────────────────────────┐ 30 apps ──auth-code │ A. AuthN / Broker │ Better Auth core +PKCE────────▶ │ B. OAuth 2.1 Provider (IdP) │ → issues JWT, JWKS, introspect ◀──JWT/JWKS── │ C. Authorization (PDP) │ CASL + catalog + scoped roles └──────────────────────────────┘ │ Kysely ▼ PostgreSQLFlow: Federated Login + First-Party App Token
sequenceDiagram participant App as App (OAuth client) participant IAM as IAM (Better Auth) participant IdP as GitHub/Okta participant API as Downstream API App->>IAM: redirect /oauth2/authorize (PKCE challenge, audience) IAM-->>App: no session -> show hosted login App->>IAM: user picks "GitHub" IAM->>IdP: OAuth/OIDC federation IdP-->>IAM: verified identity (email verified?) IAM->>IAM: link/create canonical user (verified-email rule) IAM-->>App: redirect back with authorization code App->>IAM: /oauth2/token (code + PKCE verifier) IAM-->>App: access JWT (aud=app-api) + refresh App->>API: GET /resource Authorization: Bearer <JWT> API->>IAM: (first time) GET /api/auth/jwks [cached after] API->>API: verify sig + iss + aud + exp; build CASL ability API-->>App: 200 filtered data / 403Flow: Authorization Decision With Scope Inheritance
sequenceDiagram participant C as Client/<Can> participant API as API guard (Require) participant PDP as PDP /v1/authz/check participant DB as Postgres C->>API: action="invoice:approve" resource=project:P3 API->>PDP: { subject, action, resource, scopeNode: project:P3 } PDP->>DB: recursive CTE: ancestors(P3) = [P3, W1, Ent] DB-->>PDP: union of grants from assignments at P3/W1/Ent PDP->>PDP: build CASL ability; ability.can(action, subject) PDP-->>API: { allow: true/false, reason } API-->>C: proceed / 403 (and <Can> hides the button)Domain Model & Database (The Contract)
Hierarchy & Concepts
- Scope tree:
enterprise → workspace → project. Modeled as ONE self-referentialscope_nodetable for clean ancestor resolution. - Permission: a
resource:actionstring (e.g.invoice:approve). Drupal-style granular. Lives in a catalog keyed byaudience(the app/API). - Role: a named bundle of grants, scoped to a tier or global. 5 standardized roles (§5.5) + custom roles.
- Assignment:
(subject, role, scope_node). A user MAY hold different roles at different nodes. - Effective permissions on node N = union of grants from every assignment whose scope is N or an ancestor of N (inheritance flows DOWN the tree). Optional
inherit=falseassignments isolate a node.
Domain Definition — db/schema/domain.yaml (Source of Truth)
scope_node: description: A node in the enterprise→workspace→project tree. fields: id: { type: uuid, pk: true } type: { type: enum, values: [enterprise, workspace, project] } parent_id: { type: uuid, nullable: true, fk: scope_node.id } name: { type: text } created_at:{ type: timestamptz, default: now } invariants: - "enterprise nodes MUST have parent_id = null" - "workspace.parent MUST be an enterprise" - "project.parent MUST be a workspace"
permission_catalog: description: Every resource:action an app declares it owns. fields: id: { type: uuid, pk: true } audience: { type: text } # e.g. billing-api resource: { type: text } # e.g. invoice action: { type: text } # e.g. approve unique: [audience, resource, action]
role: fields: id: { type: uuid, pk: true } audience: { type: text, nullable: true } # null => global role name: { type: text } # owner|admin|editor|viewer|billing|custom is_system: { type: boolean, default: false } unique: [audience, name]
role_grant: description: The permissions a role confers. fields: id: { type: uuid, pk: true } role_id: { type: uuid, fk: role.id, on_delete: cascade } resource: { type: text } action: { type: text } unique: [role_id, resource, action]
role_assignment: description: A subject holding a role at a scope node. fields: id: { type: uuid, pk: true } subject_type:{ type: enum, values: [user, group] } subject_id: { type: uuid } role_id: { type: uuid, fk: role.id, on_delete: cascade } scope_id: { type: uuid, fk: scope_node.id, on_delete: cascade } inherit: { type: boolean, default: true } unique: [subject_type, subject_id, role_id, scope_id]
authz_audit: fields: id: { type: uuid, pk: true } ts: { type: timestamptz, default: now } subject_id:{ type: uuid } action: { type: text } resource: { type: text } scope_id: { type: uuid } allow: { type: boolean } reason: { type: text }Better Auth migrations own
user,session,account,verification,jwks,oauthApplication/oauthAccessToken,ssoProvider,organization,member. The tables above are the authz layer you build. Map Better Authorganization↔ ascope_nodeof typeenterprise(orworkspace) so org-plugin membership and the scope tree stay consistent.
JSON Schema Validators — db/schema/scope_node.schema.json
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "scope_node.schema.json", "type": "object", "required": ["id", "type", "name"], "additionalProperties": false, "properties": { "id": { "type": "string", "format": "uuid" }, "type": { "enum": ["enterprise", "workspace", "project"] }, "parent_id": { "type": ["string", "null"], "format": "uuid" }, "name": { "type": "string", "minLength": 1 }, "created_at": { "type": "string", "format": "date-time" } }, "allOf": [ { "if": { "properties": { "type": { "const": "enterprise" } } }, "then": { "properties": { "parent_id": { "type": "null" } } } } ]}Test (write FIRST) — iam-core/schema.test.ts
import Ajv from "ajv"; import addFormats from "ajv-formats";import schema from "../../db/schema/scope_node.schema.json";const ajv = addFormats(new Ajv({ allErrors: true }));const validate = ajv.compile(schema);
test("enterprise must have null parent", () => { expect(validate({ id: crypto.randomUUID(), type: "enterprise", name: "Acme" })).toBe(true); expect(validate({ id: crypto.randomUUID(), type: "enterprise", parent_id: crypto.randomUUID(), name: "Acme" })).toBe(false);});test("project may have a parent", () => { expect(validate({ id: crypto.randomUUID(), type: "project", parent_id: crypto.randomUUID(), name: "P3" })).toBe(true);});Done when the validator rejects malformed nodes and accepts valid ones. Author a .schema.json + test per table.
SQL DDL — db/migrations/0001_init.sql (Atlas)
CREATE TYPE scope_type AS ENUM ('enterprise','workspace','project');CREATE TYPE subject_type AS ENUM ('user','group');
CREATE TABLE scope_node ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), type scope_type NOT NULL, parent_id uuid REFERENCES scope_node(id) ON DELETE CASCADE, name text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT enterprise_has_no_parent CHECK ((type = 'enterprise') = (parent_id IS NULL)));CREATE INDEX idx_scope_parent ON scope_node(parent_id);
CREATE TABLE permission_catalog ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), audience text NOT NULL, resource text NOT NULL, action text NOT NULL, UNIQUE (audience, resource, action));
CREATE TABLE role ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), audience text, name text NOT NULL, is_system boolean NOT NULL DEFAULT false, UNIQUE (audience, name));
CREATE TABLE role_grant ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), role_id uuid NOT NULL REFERENCES role(id) ON DELETE CASCADE, resource text NOT NULL, action text NOT NULL, UNIQUE (role_id, resource, action));
CREATE TABLE role_assignment ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), subject_type subject_type NOT NULL, subject_id uuid NOT NULL, role_id uuid NOT NULL REFERENCES role(id) ON DELETE CASCADE, scope_id uuid NOT NULL REFERENCES scope_node(id) ON DELETE CASCADE, inherit boolean NOT NULL DEFAULT true, UNIQUE (subject_type, subject_id, role_id, scope_id));CREATE INDEX idx_assign_subject ON role_assignment(subject_id);CREATE INDEX idx_assign_scope ON role_assignment(scope_id);
CREATE TABLE authz_audit ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), ts timestamptz NOT NULL DEFAULT now(), subject_id uuid NOT NULL, action text NOT NULL, resource text NOT NULL, scope_id uuid, allow boolean NOT NULL, reason text);Test (write FIRST) — iam-db/migration.test.ts (against an ephemeral Postgres, e.g. Testcontainers)
test("enterprise_has_no_parent constraint is enforced", async () => { await db.insertInto("scope_node").values({ type: "enterprise", name: "Acme" }).execute(); // ok await expect( db.insertInto("scope_node").values({ type: "enterprise", name: "Bad", parent_id: someUuid }).execute() ).rejects.toThrow();});test("catalog uniqueness holds", async () => { const row = { audience: "billing-api", resource: "invoice", action: "approve" }; await db.insertInto("permission_catalog").values(row).execute(); await expect(db.insertInto("permission_catalog").values(row).execute()).rejects.toThrow();});Done when constraints reject violations and the schema matches domain.yaml field-for-field.
The 5 Standardized Roles (System Seed)
Tier-agnostic: same name = same meaning at any node. manage is a CASL keyword = all actions on a resource.
| Role | members | settings | billing | content/resources | entity delete/transfer |
|---|---|---|---|---|---|
| owner | manage | manage | manage | manage | yes |
| admin | manage | manage | read | manage | no |
| editor | read | read | — | create / read / update | no |
| viewer | read | read | — | read | no |
| billing | read | read | manage | — | no |
Seed as is_system = true, audience = null (global definitions). Apps add their own resource:action to the catalog and may bundle them into these roles or define custom roles.
Test (write FIRST) — iam-db/seed.test.ts
test("system roles seeded with correct grant cardinality", async () => { const owner = await grantsFor("owner"); expect(owner).toContainEqual({ resource: "*", action: "manage" }); // or explicit manage per resource const viewer = await grantsFor("viewer"); expect(viewer.every(g => g.action === "read")).toBe(true); const billing = await grantsFor("billing"); expect(billing).toContainEqual({ resource: "billing", action: "manage" }); expect(billing.find(g => g.resource === "content")).toBeUndefined();});Effective-Permissions Query (The Heart) — Recursive CTE
-- effective grants for a subject acting on a target nodeWITH RECURSIVE ancestors AS ( SELECT id, parent_id FROM scope_node WHERE id = :nodeId UNION ALL SELECT s.id, s.parent_id FROM scope_node s JOIN ancestors a ON s.id = a.parent_id)SELECT DISTINCT rg.resource, rg.actionFROM role_assignment raJOIN ancestors a ON a.id = ra.scope_idJOIN role_grant rg ON rg.role_id = ra.role_idWHERE ra.subject_id = :subjectId AND (ra.scope_id = :nodeId OR ra.inherit = true);Test (write FIRST) — iam-db/effective-perms.test.ts
// fixtures: Ent > W1 > P3 ; Alice admin@Ent ; Bob editor@W1 ; Carol viewer@Ent + admin@P3test("enterprise admin inherits down to project", async () => { const g = await effectivePerms(alice, "P3"); expect(g).toContainEqual({ resource: "member", action: "manage" });});test("workspace editor does not leak to sibling workspace", async () => { expect(await effectivePerms(bob, "W2")).toHaveLength(0);});test("carol reads everywhere but admins only P3", async () => { expect(await effectivePerms(carol, "W1")).toEqual( expect.arrayContaining([{ resource: "content", action: "read" }])); expect(await effectivePerms(carol, "P3")).toContainEqual( { resource: "member", action: "manage" });});test("inherit=false isolates a node", async () => { // assignment with inherit=false at W1 should not apply when querying P3 via ancestry});Done when all inheritance, isolation, and non-leak cases pass.
iam-db — Kysely Repository Layer
Contract
The rest of the system NEVER imports Kysely directly. It depends on repository interfaces returning plain domain objects. This is the swap-the-backend seam and the test seam.
// iam-core/ports.ts (interfaces only — no Kysely here)export interface RoleRepository { effectivePermissions(subjectId: string, scopeNodeId: string): Promise<Permission[]>; assignmentsForSubject(subjectId: string): Promise<Assignment[]>; ancestorsOf(scopeNodeId: string): Promise<ScopeNode[]>;}export interface CatalogRepository { upsertManifest(m: PermissionManifest): Promise<void>; // idempotent list(audience: string): Promise<Permission[]>;}export type Permission = { resource: string; action: string };// iam-db/role.repository.ts (Kysely confined here)export class KyselyRoleRepository implements RoleRepository { constructor(private db: Kysely<DB>) {} // DB type generated by kysely-codegen async effectivePermissions(subjectId, scopeNodeId) { return this.db.withRecursive("ancestors", (q) => /* CTE from §5.6 */).select(...).execute(); }}Test (Write FIRST) — Repository Contract Test Against Real Kysely + In-Memory Fake
function runContract(makeRepo: () => RoleRepository) { test("effectivePermissions unions ancestor grants", async () => { /* §5.6 expectations */ }); test("returns [] for unknown subject", async () => { expect(await makeRepo().effectivePermissions("nobody", root)).toEqual([]); });}runContract(() => new KyselyRoleRepository(testDb));runContract(() => new InMemoryRoleRepository(fixtures)); // proves the interface, enables UI/Storybook mocksDone when the same contract suite is green for both implementations. (This is what makes Prisma/Drizzle/Kysely interchangeable — and what powers the mock adapter in §11.)
kysely-codegen
kysely-codegen --out-file packages/iam-db/src/db.d.ts runs in CI after migrations apply to a scratch DB. A drift test asserts generated types match committed types — DB stays the source of truth.
iam-service — Better Auth Backend Configuration
Contract — apps/iam-service/auth.ts
import { betterAuth } from "better-auth";import { oauthProvider } from "better-auth/plugins"; // OAuth 2.1 Providerimport { jwt } from "better-auth/plugins";import { organization, admin } from "better-auth/plugins";import { sso } from "@better-auth/sso";
export const auth = betterAuth({ database: /* Better Auth's own adapter for its tables; app authz tables use Kysely directly */, emailAndPassword: { enabled: true }, socialProviders: { github: { clientId: env.GH_ID, clientSecret: env.GH_SECRET }, google: { clientId: env.GOOGLE_ID, clientSecret: env.GOOGLE_SECRET }, }, account: { accountLinking: { enabled: true, trustedProviders: ["github","google"] } }, // verified-email only plugins: [ sso(), // enterprise OIDC + SAML (Okta/Entra) organization(), // tenant + member roles; map org -> scope_node admin(), // super-admin, impersonation jwt(), // /api/auth/jwks for downstream verification oauthProvider({ // makes IAM the IdP to the 30 apps requirePKCE: true, // trusted first-party clients (consent bypass) loaded from declarative config (§8) }), ],});Tests (Write FIRST) — apps/iam-service/auth.test.ts
test("JWKS endpoint serves a key set", async () => { const r = await fetch(`${base}/api/auth/jwks`); const jwks = await r.json(); expect(jwks.keys.length).toBeGreaterThan(0); expect(jwks.keys[0]).toHaveProperty("kid");});test("auth-code + PKCE issues a JWT with correct aud", async () => { const { token } = await runAuthCodePkce({ clientId: "billing", audience: "billing-api" }); const claims = decode(token); expect(claims.aud).toBe("billing-api"); expect(claims.iss).toBe(env.ISSUER);});test("PKCE is mandatory", async () => { await expect(runAuthCodeWithoutPkce({ clientId: "billing" })).rejects.toMatchObject({ status: 400 });});test("unverified federated email does NOT auto-link", async () => { const u = await federate({ provider: "okta", email: "x@y.com", emailVerified: false }); expect(u.linkedToExisting).toBe(false);});test("introspection reports active for a live token, inactive after revoke", async () => { const t = await mintToken(); expect((await introspect(t)).active).toBe(true); await revoke(t); expect((await introspect(t)).active).toBe(false);});Done when all green. These encode Invariants I1, I4, I9.
Declarative Client & Permission Registration (GitOps)
OAuth Client Config — clients/billing.yaml
clientId: billingdisplayName: Billingtrusted: truepkce: requiredaudience: billing-apiredirectUris: # EXACT match — wildcards forbidden (Invariant I3) - https://billing.example.gov/auth/callback - https://billing.staging.example.gov/auth/callbackpostLogoutRedirectUris: [ https://billing.example.gov/ ]scopes: [openid, profile, email]accessTokenTtlSeconds: 600Permission Manifest — permissions/billing.yaml
audience: billing-apiresources: invoice: [read, write, approve, void] report: [read, export]roles: billing-viewer: { invoice: [read], report: [read] } billing-approver: { invoice: [read, write, approve] }Reconciler Contract + Tests (Write FIRST)
test("reconcile is idempotent", async () => { await reconcile("permissions/billing.yaml"); const before = await snapshot(); await reconcile("permissions/billing.yaml"); expect(await snapshot()).toEqual(before); // no diff => no-op});test("wildcard redirect URI fails validation", async () => { await expect(reconcileClient({ redirectUris: ["https://*.example.gov/cb"] })) .rejects.toThrow(/wildcard/);});test("removing a catalog action with active grants fails loudly", async () => { await expect(removeCatalog("billing-api","invoice","approve")) .rejects.toThrow(/active grants/);});Done when reconcile is idempotent, rejects wildcards, and never silently orphans grants.
iam-core — CASL Authorization (Isomorphic)
Contract — The Ability Factory (Runs in React and Node)
import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability";export type AppAbility = MongoAbility<[string, string]>; // [action, subject]
// Build from already-resolved effective permissions (from RoleRepository).export function defineAbilityFor(perms: Permission[]): AppAbility { const { can, build } = new AbilityBuilder<AppAbility>(createMongoAbility); for (const p of perms) can(p.action, p.resource); // 'manage' => all actions (CASL keyword) return build();}PDP Service — /v1/authz/check
export async function check(input: CheckInput, repo: RoleRepository): Promise<CheckResult> { const perms = await repo.effectivePermissions(input.subjectId, input.scopeNodeId); const ability = defineAbilityFor(perms); const allow = ability.can(input.action, input.resource); await audit({ ...input, allow }); // Invariant I7 return { allow, reason: allow ? "granted" : "no-grant" };}Server decision is authoritative (Invariant I5). Better Auth’s client-side role check does not support dynamic AC, and union-of-roles checks have known bugs — clients MUST treat the PDP as truth. The CASL ability is built from the DB-resolved union, sidestepping that bug.
Tests (Write FIRST)
test("manage grants all CRUD on its resource", () => { const a = defineAbilityFor([{ resource: "invoice", action: "manage" }]); for (const act of ["read","write","approve","void"]) expect(a.can(act, "invoice")).toBe(true);});test("union across two roles allows the union (guards known bug)", async () => { // subject has role A(invoice:read) + role B(invoice:approve) expect((await check({ subjectId: u, scopeNodeId: P3, action: "approve", resource: "invoice" }, repo)).allow).toBe(true);});test("every decision writes an audit row", async () => { const n = await auditCount(); await check(input, repo); expect(await auditCount()).toBe(n + 1);});Deferred: ReBAC Option
If resource-graph relationships emerge (“shared with these users”, folder inheritance), add a Postgres relationship-tuples table behind the SAME /v1/authz/check API. Do NOT add an external engine (OpenFGA) unless this is explicitly triggered.
API Surface (Every Endpoint Test-First)
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /.well-known/openid-configuration | OIDC discovery | public |
| GET | /api/auth/jwks | public keys | public |
| * | /api/auth/* | Better Auth (login, social, sso, sessions) | per-flow |
| GET/POST | /oauth2/authorize /oauth2/token | OAuth 2.1 flow | client |
| POST | /oauth2/introspect | RFC 7662 | client |
| POST | /oauth2/revoke | RFC 7009 | client |
| POST | /v1/authz/check | single decision | bearer |
| POST | /v1/authz/check/batch | bulk decisions (screen load) | bearer |
| GET | /v1/me/permissions?scope= | effective perms for UI bootstrap | bearer |
| POST | /v1/scopes /v1/scopes/:id/children | manage tree | bearer + admin |
| POST | /v1/assignments | assign role at scope | bearer + manage:member |
| GET | /v1/catalog?audience= | published permissions | bearer |
Every payload has a JSON Schema in db/schema/api/ + an Ajv validator test. Example:
Test (write FIRST) — check.contract.test.ts
test("/v1/authz/check request+response match schema", async () => { const body = { subjectId: u, action: "invoice:approve", resource: "invoice", scopeNodeId: P3 }; expect(validateReq(body)).toBe(true); const res = await api.post("/v1/authz/check", body, bearer(token)); expect(validateRes(res.data)).toBe(true); expect(res.data).toHaveProperty("allow");});test("check requires a valid bearer", async () => { expect((await api.post("/v1/authz/check", {})).status).toBe(401);});Done when schema validation passes both directions and authz/authn guards return correct codes.
React Component Library (iam-client + iam-ui)
Design constraints (MUST): components consume the app’s design system tokens (do not ship hard-coded styles); use compound-component / slot composition so apps re-skin and re-layout without forking; tokens/branding via props. Storybook stories ARE the acceptance tests — every component ships stories for all states with play() interaction assertions + a11y checks. Every story runs against MockAuthAdapter/InMemoryRoleRepository (no live IAM).
The Adapter Boundary (Enables Storybook + Tests)
export interface AuthClientAdapter { signIn(i: SignInInput): Promise<SignInResult>; signOut(): Promise<void>; getSession(): Promise<Session | null>; getAccessToken(o?: { audience?: string }): Promise<string>; getPermissions(scopeNodeId: string): Promise<Permission[]>;}// BetterAuthAdapter (real) | MockAuthAdapter (Storybook/tests)Components to Build
iam-client (logic/context):
AuthProvider— owns session + token lifecycle (in-memory token, silent refresh via httpOnly cookie/BFF). Config:authBaseUrl, clientId, audience, redirectUri, postLoginRoute. No tokens in localStorage (Invariant I2).PermissionsProvider— wraps CASLAbilityProvider; fetches/v1/me/permissions?scope=for the active scope, buildsAppAbility, re-fetches on scope switch.useSession(),useAccessToken(),useCan().<Can action="invoice:approve" subject="invoice" else={<Hidden/>}>…</Can>(re-exports@casl/react).<ProtectedRoute>— gates on session; redirects to login.<AuthCallback>— exchanges code + PKCE, routes topostLoginRoute.<ScopeProvider>+useScope()— current enterprise/workspace/project context.
iam-ui (visual, design-system driven, compound):
LoginPage/LoginForm— email/password + social buttons (slots:<LoginForm.Social>,<LoginForm.Footer>).SignupForm,ForgotPasswordForm,ResetPasswordForm.MfaChallenge(TOTP/passkey).OAuthConsentScreen— app name, requested scopes, allow/deny; bypassed for trusted first-party clients but built for any 3rd-party.AccountSelector— pick among linked identities.UserSettingsPage— compound:Settings.Profile(name, avatar, email)Settings.Security(change password, MFA enroll, active sessions list + revoke)Settings.ConnectedAccounts(linked GitHub/Google/SSO; link/unlink)Settings.Danger(delete account)
WorkspaceSwitcher— switches active scope (drivesPermissionsProviderrefetch).MembersTable— list members at a scope, assign/change role (gated bymember:manage),RoleBadge.SsoSetupForm— self-service enterprise SSO (issuer, metadata, domain verify).
Storybook Tests (Write FIRST) — Examples
export const LoginFails: Story = { args: { adapter: mockFailing }, play: async ({ canvas }) => { await userEvent.type(canvas.getByLabelText("Email"), "a@b.com"); await userEvent.type(canvas.getByLabelText("Password"), "wrong"); await userEvent.click(canvas.getByRole("button", { name: /sign in/i })); await expect(canvas.findByRole("alert")).resolves.toBeVisible(); },};
// Can.stories.tsxexport const HidesWhenDenied: Story = { decorators: [withAbility([{ resource: "invoice", action: "read" }])], // no approve render: () => <Can action="approve" subject="invoice"><button>Approve</button></Can>, play: async ({ canvas }) => { expect(canvas.queryByText("Approve")).toBeNull(); },};
// UserSettingsPage.stories.tsx — states: loading, loaded, mfa-enabled, session-revokeexport const RevokeSession: Story = { play: async ({ canvas }) => { await userEvent.click(canvas.getAllByText("Revoke")[0]); await expect(canvas.findByText(/session ended/i)).resolves.toBeVisible(); },};Required story states per component (the test matrix)
LoginForm: idle · submitting · invalid creds · social redirect · MFA required · rate-limited.
OAuthConsentScreen: first-party (auto-approve) · third-party (consent) · scope denied.
UserSettingsPage: loading · loaded · MFA on/off · 1 vs many sessions · unlink last provider (blocked).
MembersTable: viewer (no controls) · admin (controls visible) · self-demotion guard.
Can/ProtectedRoute: allowed · denied · loading ability.
Done when every listed state has a story and its play()/a11y assertions are green in CI (test-storybook).
Optional Future: Downstream Non-TS Verification SDKs
MVP does not require non-TS downstream SDKs. Current scope assumes first-party Astro/TypeScript apps and APIs. Add language-specific verification SDKs only when non-TS downstreams are in scope.
Example Contract (Go)
// iamauth.Middleware verifies the bearer JWT; iamauth.Require guards authorization.mw := iamauth.New(iamauth.Config{ JWKSURL: "https://auth.example.gov/api/auth/jwks", Issuer: "https://auth.example.gov", Audience: "billing-api", PDPURL: "https://auth.example.gov/v1/authz/check", // for fine-grained checks})r.With(mw.Middleware).Get("/invoices/{id}", mw.Require("invoice:read")(handler))Verification steps (MUST, in order): signature via cached JWKS (lestrrat-go/jwx) → iss → aud == this API → exp/nbf → extract claims. JWKS cached with TTL; on unknown kid, refetch once before failing. A single refetch MUST NOT fail all in-flight requests (Invariant compliance: I9 interop).
Example Tests (Write FIRST) — iamauth_test.go
func TestRejectsWrongAudience(t *testing.T) { tok := signTestJWT(t, claims{Aud: "other-api"}) rec := callWithToken(mw.Middleware, tok) if rec.Code != http.StatusForbidden { t.Fatalf("want 403 got %d", rec.Code) }}func TestRefetchesJWKSOnUnknownKid(t *testing.T) { rotateSigningKey() // new kid not in cache tok := signTestJWT(t, validClaims()) rec := callWithToken(mw.Middleware, tok) if rec.Code != http.StatusOK { t.Fatalf("expected refetch+accept, got %d", rec.Code) }}func TestExpiredTokenRejected(t *testing.T) { /* exp in past => 401 */ }func TestRequireCallsPDPForResourceScopedAction(t *testing.T) { /* mock PDP allow/deny */ }Done when wrong-aud, expired, tampered, and unknown-kid cases behave correctly and Require honors PDP decisions.
Milestones (Each = Red→Green TDD Cycle)
| # | Milestone | Write tests first for | Green gate |
|---|---|---|---|
| M0 | Repo + CI + test harness | turbo pipelines, Testcontainers PG, Ajv, Storybook test-runner | empty suites run; pnpm test wired |
| M1 | Schema & repos | §5 schema validators, §5.6 effective-perms, §6 repo contract (both impls) | constraints + inheritance + non-leak pass |
| M2 | Better Auth core | §7 auth tests (JWKS, PKCE, no-auto-link, introspect/revoke) | federated login + JWT issuance |
| M3 | OAuth provider + GitOps reconcile | §8 idempotency, wildcard-reject, orphan-guard | add an app via PR end-to-end |
| M4 | CASL PDP + API | §9 ability/union/audit, §10 endpoint schema + guard tests | identical decision across 2 apps; revoke is immediate |
| M5 | React library | §11 Storybook state matrix + play/a11y | all component states green; mock adapter only |
| M6 | Optional non-TS SDKs (post-MVP) | deferred until a non-TS downstream requires first-class support | reference SDK + verification tests available |
| M7 | Hardening + pilot | key-rotation overlap, rate-limit, MFA, E2E Playwright across 5 pilot apps | onboarding runbook < 1 day/app |
Invariants (Override Everything)
- I1 PKCE required on every OAuth client.
- I2 Tokens never in
localStorage/sessionStorage; access token in memory, refresh via httpOnly cookie/BFF. - I3 Redirect URIs exact-match allowlisted. No wildcards, ever. Reconcile rejects them.
- I4 Auto account-linking ONLY on provider-asserted verified email.
- I5 Server PDP is authoritative; client
<Can>is a UX hint, never the security boundary. - I6 JWT carries only the requesting audience’s coarse roles — never the union of all apps’ permissions.
- I7 Every identity, token, consent, and authorization decision is audit-logged.
- I8 Auth + authz endpoints are rate-limited (per-endpoint).
- I9 Signing-key rotation overlaps: publish-before-sign, retire-after-grace; verifiers refetch JWKS on unknown
kid. - I10
iam-clientand any downstream SDK changes keep N-1 backward compat — no lockstep deploy of 30 apps. - I11 Nothing imports Kysely outside
iam-db; everything else depends oniam-coreports (the swap seam + test seam). - I12 No implementation merges without its red-first test green in CI.
Open Questions (Resolve Within M4)
- Q1 Back-channel single logout in v1, or rely on short token lifetime + revocation?
- Q2 Group/team assignments in v1, or user-direct only first?
- Q3 Any app’s resource graph complex enough to trigger §9.4 ReBAC early?
- Q4 Is Better Auth
organizationthe tenant boundary, or do some apps need a different scoping axis than enterprise/workspace/project? - Q5 Token lifetime per client — confirm default 600s and which apps need shorter.
Definition of Done (Whole Platform)
A new app: installs @iam/client, adds AuthProvider + PermissionsProvider, and drops one clients/<app>.yaml + one permissions/<app>.yaml PR. It then has SSO login, centralized roles, UI gating, and verified API authorization — with zero bespoke auth or permission code, and every layer covered by the tests defined above. Non-TS downstream SDKs are optional and deferred until needed.