Skip to content

Auth & identity

Crate: platyn-identity · Migration: 0003_identity.sql · Status: Built

Identity is the domain everything else depends on, and it was built second — immediately after the tenancy primitive — rather than last. Retrofitting tenancy onto a system that grew up single-tenant is what produces a single-row portals table and a year of unpicking it.

erDiagram
    TENANTS  ||--o{ MEMBERSHIPS : "has"
    USERS    ||--o{ MEMBERSHIPS : "holds"
    ROLES    ||--o{ MEMBERSHIPS : "grants"
    ROLES    ||--o{ ROLE_PERMISSIONS : "expands to"
    USERS    ||--o{ SESSIONS : "authenticates"
    TENANTS  ||--o{ SESSIONS : "scoped to"
    USERS    ||--o{ TOKENS : "single-use"
    TENANTS  ||--o{ VENDOR_ACCOUNTS : "owns"
    TENANTS  ||--o{ TENANT_KEYS : "wraps DEK"

app.users is global — deliberately not tenant-scoped. A bookkeeper who works for three decorators is one row with one password and one email, not three accounts. app.memberships is the join, and it is RLS-scoped.

That means the login flow has two steps: authenticate the human, then choose the shop.

  1. POST /v1/auth/login verifies the password and mints a session with tenant_id null.
  2. GET /v1/auth/tenants lists live memberships. One membership? The BFF selects it silently.
  3. POST /v1/auth/tenants/{id}/select stamps sessions.tenant_id, after verifying a live membership exists.
  4. Every request from then on opens its transaction with that tenant id, and RLS does the rest.

The shop. This table is the tenant, so its policy keys on id rather than a tenant_id column.

ColumnTypeNotes
idUUID PK
slugCITEXT uniqueURL-safe handle. CITEXT so Acme and acme collide at the database.
legal_nameTEXT
statusapp.tenant_statustrial · active · past_due · suspended · cancelled
timezoneTEXTDefault America/New_York. Production schedules are local-time.
currencyCHAR(3)Default USD.
settingsJSONBShop preferences that do not warrant columns.
suspended_at, created_at, deleted_atTIMESTAMPTZ
ALTER TABLE app.tenants ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.tenants FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_self ON app.tenants
USING (id = app.current_tenant_id()) WITH CHECK (id = app.current_tenant_id());
ColumnTypeNotes
idUUID PK
emailCITEXT uniqueCase-insensitive at the database, not in application code.
email_verified_atTIMESTAMPTZNull until the emailed token is consumed.
password_hashTEXTArgon2id. Nullable — SSO-only users have no password.
full_name, avatar_urlTEXT
last_login_atTIMESTAMPTZ
created_at, disabled_atTIMESTAMPTZ

No RLS: a user is not owned by a tenant. Reaching a user always goes through app.memberships, which is scoped.

Seven seeded roles, ranked so “can this person manage that person” is a comparison rather than a lookup table:

idcodelabelrank
1ownerOwner100
2adminAdmin90
3salesSales60
4accountAccount Manager60
5designerDesigner50
6productionProduction40
7viewerViewer10

Permissions are rows, not an enum. A shop that wants “a senior CSR who can discount” adds a row rather than waiting for a deploy.

INSERT INTO app.role_permissions (role_id, permission) VALUES
(1,'*'), -- owner
(2,'company.read'),(2,'company.write'), … -- admin

The permission vocabulary in use today:

PermissionHeld by
company.read / company.writeread: all roles · write: admin
customer.read / customer.writeread: all · write: admin, sales, account
deal.read / deal.writeread: all · write: admin, sales, account
deal.price.read / deal.price.writeread: admin, sales, account · write: admin, sales
design.read / design.write / design.approveread: all · write: admin, designer · approve: admin
catalog.readall roles
member.manageowner, admin
vendor.manageowner, admin
*owner only
ColumnTypeNotes
idUUID PK
tenant_idUUIDRLS key.
user_idUUID
role_idSMALLINT
invited_byUUID
accepted_atTIMESTAMPTZNull while an invite is outstanding.
revoked_atTIMESTAMPTZSoft revoke — history survives.

UNIQUE (tenant_id, user_id) — one role per person per shop. Two roles would mean resolving a conflict on every check, so the model refuses the question.

SELECT app.apply_tenant_rls('app.memberships');

ColumnTypeNotes
idUUID PK
token_keyBYTEA uniqueSHA-256(pepper ‖ raw_token). The raw token is never stored.
user_idUUID
tenant_idUUID nullableNull between login and tenant selection.
idle_expires_atTIMESTAMPTZSlides forward on use.
absolute_expires_atTIMESTAMPTZNever moves.
csrf_secretBYTEAFor the BFF’s double-submit check.
ip_hash, user_agentBYTEA, TEXTAnomaly review. IP is hashed, not stored raw.
last_seen_at, revoked_atTIMESTAMPTZ

Three partial indexes, all WHERE revoked_at IS NULL, so the hot path never walks dead rows: sessions_user, sessions_tenant, sessions_expiry.

Single-use tokens for email verification, invitations, and password reset. The primary key is the hash — the raw token exists only in the email that was sent.

ColumnTypeNotes
token_hashBYTEA PK
purposeTEXTemail_verify · invite · password_reset
user_id, tenant_idUUIDBoth nullable — an invite predates the user row.
emailCITEXTThe invited address, before an account exists.
role_idSMALLINTThe role the invite will grant.
expires_at, consumed_atTIMESTAMPTZconsumed_at makes reuse a no-op, not an error.

app.tokens is the second declared RLS exemption, for the same reason as sessions: an invite or reset token is resolved by token_hash before the user is authenticated. Single-use and time-boxed.

A shop’s own negotiated vendor credentials, under RLS and under envelope encryption. They live in the identity migration because they are tenant configuration, but the catalog domain consumes them. The platform’s catalog-read credentials are not here — the catalog is global, so those are platform config.

stateDiagram-v2
    [*] --> Unauthenticated
    Unauthenticated --> TenantPending : POST /v1/auth/login
    TenantPending --> Active : POST /v1/auth/tenants/{id}/select
    Active --> Active : request slides idle_expires_at
    Active --> Revoked : DELETE /v1/auth/session
    Active --> Revoked : admin revokes membership
    Active --> Revoked : tenant suspended
    Active --> Expired : idle or absolute expiry passes
    Revoked --> [*]
    Expired --> [*]

Revocation is a single UPDATE and takes effect on the very next request. That immediacy is the entire reason sessions are opaque rows rather than JWTs — ADR-05.

MethodPathPermissionNotes
POST/v1/auth/loginArgon2id verify, mint token. Returns the raw token once.
DELETE/v1/auth/sessionsessionSets revoked_at.
GET/v1/auth/sessionsessionUser, tenant, role, flattened permissions.
GET/v1/auth/tenantssessionLive memberships for this user.
POST/v1/auth/tenants/{id}/selectsessionBinds the session to a tenant.
POST/v1/auth/password/forgotAlways 204, regardless of whether the email exists.
POST/v1/auth/password/resettokenConsumes a password_reset token.
POST/v1/auth/verifytokenConsumes an email_verify token.
GET/v1/membersmember.manageMemberships in the current tenant.
POST/v1/members/invitemember.manageIssues an invite token and emails it.
PATCH/v1/members/{id}member.manageChange role. Cannot exceed your own rank.
DELETE/v1/members/{id}member.manageRevokes membership and every session under it.
GET/v1/vendor-accountsvendor.manageMetadata only — never the secret.
POST/v1/vendor-accountsvendor.manageSeals the secret; verifies it against the vendor.

Full request and response shapes are in the API reference.

  • Argon2id for passwords, SHA-256 for session tokens. Not an inconsistency — a 256-bit uniform random token is not brute-forcible, so a slow hash buys nothing and costs ~50 ms per request. A human-chosen password is the opposite case.
  • The pepper is application config, never a database column. A stolen dump alone cannot be used to forge session lookups.
  • Login responses are uniform. Unknown email and wrong password produce the same body, status, and timing. Login is rate limited per IP and per email.
  • Revoking a membership revokes its sessions in the same transaction. Anything less leaves a fired employee logged in.
  • Role changes take effect on the next request, because permissions are resolved per request rather than baked into a token.
  • 403 never explains itself. Telling an attacker which permission they lack is telling them what to go get.
  • SSO via OIDC — password_hash is already nullable for it.
  • TOTP second factor, with recovery codes in app.tokens under a new purpose.
  • An audit log of membership and role changes.
  • Scoped API keys for shop-built integrations, as a distinct principal type rather than a session.