Adds instance_role + reserved email/mfa_secret columns to admin_users, creates app_members for per-app role grants, and creates api_keys for bearer-token credentials. Schema snapshot re-blessed. Reserves invites and service_accounts shapes in a trailing comment block — both land in their own migrations when those flows ship. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
113 lines
5.7 KiB
SQL
113 lines
5.7 KiB
SQL
-- 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_<base32(32 random bytes)>`.
|
|
-- 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()
|
|
-- );
|