- Add deployment-name branding to titles, mastheads, and OG tags - Share one grant delivery-state query with lineage across grants surfaces - Show pool status/usage, org owners, and config readiness - Make billing views projection-aware with recency and sync vocabulary - Guard FedWiki creation without domains and render route-aware 404s
12 KiB
title, audience, summary
| title | audience | summary | |
|---|---|---|---|
| Identity, Organizations, and Workspaces |
|
Who is acting and in what container: users as the IdP cache, persons as the stable anchor, organizations and their types, workspaces, and the first-login provisioning chain. |
Identity, Organizations, and Workspaces
Purpose
This model answers "who is acting, and inside what container?" — and it starts with a split that looks redundant until it isn't. A user is the cached authentication identity: one row per account at the external identity provider (the OIDC service — Keycloak in practice — that owns login, names, emails, and roles; the console caches this data and never masters it). A person is the stable identity anchor that every downstream table actually references. Today the two are rigidly one-to-one, but they are different concepts: the user row mirrors the IdP and is refreshed at each login; the person row is the durable key.
Four OIDC words this card depends on: the subject is the IdP's stable, opaque per-account identifier — the thing the console keys identity on, never the email; a token claim is a named field inside the login token (no relation to the Domains Registry's DNS claims); the client is this application as registered at the IdP; and realm roles are roles defined IdP-server-wide (Keycloak's term), as opposed to per-client roles scoped to one application.
Containment runs person → organization → workspaces. The organization is the ownership, billing, and entitlement boundary — billing accounts, resource pools, and grants all key on it — and every organization has an organization type, a policy row (not code) that decides what a new organization of that type gets at creation; the type's default plan ladder drives the signup default grant. The workspace is the working surface inside an organization, where pools attach and domain claims live. First login runs the whole chain as one all-or-nothing transaction: user, person, personal organization, owner membership, default workspace, role assignment, default pool, pool assignment, billing account, and (if configured) the default plan grant. One deliberate exception to all-or-nothing: when the configured default ladder has no entry tier, provisioning completes without the plan grant and logs an error-level alarm, so a broken ladder degrades new signups instead of failing every one of them.
Two role systems exist in parallel, and only one of them does anything. The database roles — memberships, role assignments, seeded permission arrays — are written at signup and read by nothing for authorization. The operative gates are the IdP session roles: member surfaces are scoped by the session's identity trio (person, organization, workspace), and every operator route requires the IdP-issued operator-member role. Separately from all member tenancy, boot ensures a singleton System tenant — a synthetic organization with a non-loginable owner — that holds ownerless resources like operator-root domain claims.
Vocabulary reused: resource pool and pool assignment (Resource Pools), billing account (Payments and Billing), conferral and grant (Product Catalog and Entitlements), plan ladder and rank-0 tier (Plan Ladders and Transitions), operator-root claims (Domains Registry).
Where this lives
All tables sit in the core schema (internal/db/migrations/00001_init.sql; seeds in 00003_seed_system_roles_org_types.sql).
| Piece | What it is | Where |
|---|---|---|
core.users |
The IdP cache: unique OIDC subject, last-login tracking | 00001_init.sql |
core.persons |
The stable anchor everything references; linked one-to-one to users | 00001_init.sql |
core.organizations |
Organizations: globally unique slug, a required owner person, a type, and a free-text status column born active that nothing evaluates (the operator list renders it as plain text, never a badge, so the unread column implies no lifecycle it doesn't have; the unchecked-status gap itself is ledgered) |
00001_init.sql |
core.org_types |
Policy per type: display fields, is_active, is_reserved, and the default plan ladder that drives signup grants |
00001_init.sql; seeds in 00003 |
core.org_members |
Baseline belonging: one row per (organization, person) with exactly one role, born active — no invited or pending state exists | 00001_init.sql |
core.roles, core.role_assignments |
Role definitions (with permission arrays nothing reads) and scoped refinements (only the owner self-assignment is ever written) | 00001_init.sql; 00003 |
core.workspaces |
Working surfaces, slug-unique per organization, with a free-text status born active (read only by the switch guard, invariant 14) |
00001_init.sql |
| First-login provisioning | AutoProvision: the single-transaction signup chain |
internal/provisioning/provisioning.go |
| Login and session | OIDC verification, auto-provisioning of new subjects, name/email resync on return, and the session identity trio plus IdP roles | internal/auth/auth.go |
| Role extraction | extractRoles: merges the four claim locations IdPs use (roles, groups, realm roles, per-client roles), deduplicated and scoped to this app's client |
internal/auth/auth.go |
| System tenant | Boot-time idempotent ensure of the singleton system organization, resolved by its type, never by a configured id | internal/systemtenant/systemtenant.go |
| Operator gate | The middleware requiring the IdP role operator-member on every operator route |
internal/server/operator.go |
| Org-types operator surface | Default-ladder configuration per type, with previewed per-organization dispositions | internal/server/operator_org_types.go |
| Organizations list operator surface | Every row shows its guaranteed owner (ListOrganizationsWithOwner, joined on owner_person_id) so no organization reads as unowned, and the reserved System row carries a visible synthetic marker instead of the ordinary Enrollment action |
internal/server/operator_pages.go (GetOrganizationsPage); internal/organization/queries/organizations.sql |
| Workspace member surface | List, create (via the shared provisioning routine: workspace and primary pool assignment in one transaction), and switch (ownership- and status-checked, then the session rewrites) | internal/server/workspace_partials.go; internal/provisioning/workspace.go |
| Behavioral contracts | First-login provisioning, identity, workspace management, and the system tenant | openspec/specs/{auto-provisioning,identity,workspace-management,system-tenant}/ |
| IdP setup guide | The admin-facing configuration doc (partially stale on role extraction — see the ledger) | docs/identity-provider-setup.md |
Invariants
An invariant is a rule the model guarantees everywhere; code that would break one is wrong even if it works locally. Tags: [db] — a schema constraint rejects violations; [db-code] — database-side code upholds it; [app] — only Go code upholds it.
- [db] Every person has exactly one user and every user at most one person; deleting the user cascades to the person. (No code path deletes users today — what such a cascade would reach downstream is undefined, part of the design-only soft-delete story.) The designed model's "invited before signup" nullable link (
design/data-model.md) does not exist in code. - [db] Local identity keys on the OIDC subject, never on email. Email is display data; the login code resyncs it from the IdP on each return — an app-layer behavior riding on the db-enforced key.
- [db] Every organization has an owner person, the system organization included.
- [db] Organization slugs are globally unique; workspace slugs are unique per organization. Signup resolves slug collisions in code with numeric suffixes.
- [db] Every organization's type must exist in the types table — that is the schema's whole guarantee. That only two types exist (
personalseeded,systemboot-ensured) is an app-layer fact: nothing blocks inserting more, no surface creates them, and the designed team/enterprise types (design/data-model.md) remain unbuilt. - [db] A type's default ladder must be a real ladder — the foreign key blocks deleting a ladder any type defaults to. Nothing in the schema guarantees that ladder still has a rank-0 tier; that fragility is tracked in the model-documentation ledger in
status/issues.md(every "ledgered" below means that ledger). - [db] A person belongs to an organization at most once, with exactly one role on the membership row.
- [app] First login is all-or-nothing: the entire provisioning chain runs in one database transaction, and any failure rolls all of it back — leaving nothing behind, so the next login attempt simply retries the whole chain.
- [app] The signup default grant fires exactly when the organization type has a default ladder configured, resolves that ladder's rank-0 tier, and goes through the standard conferral path (a system-authored grant with reason "default") — no bespoke provisioning writes exist.
- [app] The System tenant is resolved by its type (
system), never by a configured id. Its boot-time ensure creates the synthetic user, person, the system organization, and its workspace — no pool, billing account, or grant — and is adopt-first: it looks for each piece by natural key and creates only what is missing, so concurrent or repeated boots converge. The synthetic owner cannot log in by construction (a URN subject no IdP issues, an email under the reserved.invaliddomain). - [app] Only
systemis reserved, and reserved types are hidden from the operator configuration surface.personalis unreserved on purpose — it is exactly the type operators configure. No surface creates new types. - [app] Operator access is gated solely by the IdP role
operator-member, captured into the session at login — database roles play no part, and role changes take effect only on re-login. - [app] Role extraction unions all four claim locations an IdP may use, deduplicated, with per-client roles scoped to this app's client so another client's roles never leak in. A same-named role in any location grants.
- [app] Member workspace operations are session-scoped: switching verifies the target belongs to the session's organization and carries status
activebefore the handler rewrites the session; creation runs the same transactional routine signup uses, so a workspace cannot be created without its primary pool assignment — a failure rolls the whole creation back and the member sees an error, never a false success. (The switch guard is the only reader of workspace status — an interim check; the wider status story is ledgered.)
Dimensions
- User vs person — the IdP cache vs the durable anchor. One-to-one today; two concepts always.
- The containment ladder — person → organization → workspace. Ownership, billing, and entitlements stop at the organization; work and domain claims live at the workspace.
- Organization type as policy data — the reserved flag and the default ladder are the operative columns; the row, not code, decides what a new organization gets. (An
is_activeflag is stored alongside them; the cards make no claim about its effect.) - Two parallel role systems — dormant database roles vs operative IdP session roles. Which one governs a surface is a per-surface fact; today the answer is always the session.
- Membership vs assignment — baseline belonging (one role per organization) vs scoped refinement, where an assignment names a scope type and id (today only the
organizationscope is ever written, as the owner's self-assignment). - Member vs operator surface — member routes scope by the session trio and never accept cross-organization ids; operator routes are role-gated and range over everything.
- Two tenancy creation paths — first-login provisioning (real owner, default grant) vs the boot-time System tenant (synthetic owner, no grant). Different owners, different idempotency stories.
- Session identity source — a first login feeds the session from the provisioning result; a returning login binds positionally: the first organization the ownership lookup returns, then that organization's first workspace, with the member free to switch workspaces afterward. Whether that positional binding is a contract or a placeholder is an open question in the ledger; multi-organization membership has no session story yet.