-- Phase 3.5 users, roles, and bearer-token auth — see blueprint §11.6. -- -- Lays down the schema that the unified can(principal, capability) gate -- runs against, plus the api_keys table that backs `Authorization: Bearer -- pic_…` credentials. No data-plane impact; Phase 4 SDKs (KV, docs, HTTP, -- cron) will plug into this same authz pipeline. -- -- Three changes: -- 1. admin_users gains instance_role ('owner'/'admin'/'member') plus a -- reserved email column and mfa_secret slot (neither is read yet). -- Every pre-existing row becomes 'owner' via the DEFAULT — Phase 3a -- had no role concept, so promoting all current admins to owner is -- the only safe interpretation (and matches the spec). The Rust -- startup path logs a warning when more than one active owner -- exists, so operators can demote extras via the admin PATCH. -- 2. app_members records explicit per-app grants for 'member' users. -- Owners and admins get implicit grants in code (owner→app_admin -- everywhere, admin→editor everywhere); no rows here. -- 3. api_keys holds Argon2id-hashed bearer credentials. Lookup is -- prefix-indexed (first 8 chars after `pic_`) then hash-verified; -- raw token only ever exists in the POST response. Optional -- expires_at / app_id implement TTL and app-binding respectively. ALTER TABLE admin_users -- DEFAULT 'owner' so the Phase 3a bootstrap admin (and any other -- pre-existing rows) become full owners without a backfill step. -- Multi-owner installs are flagged at startup; demotion is a -- deliberate PATCH, not an automatic migration choice. ADD COLUMN instance_role TEXT NOT NULL DEFAULT 'owner' CHECK (instance_role IN ('owner', 'admin', 'member')), -- Reserved for the eventual invite flow + Phase 4 user-management -- SDK. UNIQUE so we never end up with two rows claiming the same -- contact. Nullable because pre-existing admins have no email on -- file and we don't want to force a backfill. ADD COLUMN email TEXT UNIQUE, -- Reserved slot for TOTP secrets. Not read in Phase 3.5 — present -- now only to avoid a schema bump when MFA lands. ADD COLUMN mfa_secret TEXT; CREATE INDEX admin_users_instance_role_idx ON admin_users (instance_role); -- Per-(user, app) explicit grant. Owners and admins do NOT appear here; -- their app authority is implicit in their instance_role and resolved in -- code. Only 'member' users need rows in this table — without one, a -- member has no access to the app at all. CREATE TABLE app_members ( app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK (role IN ('app_admin', 'editor', 'viewer')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (app_id, user_id) ); -- Lookup pattern is "what apps can this user see?" — needed for the -- membership-filtered GET /admin/apps and GET /admin/scripts. CREATE INDEX app_members_user_id_idx ON app_members (user_id); -- Bearer API keys. Format on the wire: `pic_`. -- prefix = first 8 chars after `pic_` (indexed for O(1) candidate lookup) -- hash = Argon2id PHC of the full body after `pic_` -- Raw value is returned exactly once at mint time and never persisted. -- -- Optional fields: -- expires_at: TTL. Lookup always filters `expires_at IS NULL OR > NOW()`. -- app_id : "bound key" — capability checks deny any App*(other_app), -- regardless of the owning user's role. Cannot combine with -- instance:* scopes (validated in the mint handler, not SQL). CREATE TABLE api_keys ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, hash TEXT NOT NULL, prefix TEXT NOT NULL, name TEXT NOT NULL, -- TEXT[] keeps the scope set open to additions without a migration; -- the seven legal values are validated at mint time in Rust, not by -- a CHECK constraint here (so new scopes can land without a schema -- bump). scopes TEXT[] NOT NULL, app_id UUID NULL REFERENCES apps(id) ON DELETE CASCADE, expires_at TIMESTAMPTZ NULL, last_used_at TIMESTAMPTZ NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX api_keys_prefix_idx ON api_keys (prefix); CREATE INDEX api_keys_user_id_idx ON api_keys (user_id); -- --------------------------------------------------------------------- -- Reserved schema room (not built in Phase 3.5) -- --------------------------------------------------------------------- -- These tables are deliberately commented out, not created. They are -- listed here so the design intent is visible at the migration boundary -- and future authors don't reinvent the shape. Each lands in its own -- numbered migration when the corresponding flow ships. -- -- CREATE TABLE invites ( -- token TEXT PRIMARY KEY, -- raw at email-link time, hashed at rest -- email TEXT NOT NULL, -- instance_role TEXT NULL CHECK (instance_role IN ('owner','admin','member')), -- app_id UUID NULL REFERENCES apps(id) ON DELETE CASCADE, -- app_role TEXT NULL CHECK (app_role IN ('app_admin','editor','viewer')), -- invited_by UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, -- expires_at TIMESTAMPTZ NOT NULL, -- consumed_at TIMESTAMPTZ NULL -- ); -- -- CREATE TABLE service_accounts ( -- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- name TEXT NOT NULL, -- owning_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE RESTRICT, -- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -- );