feat(manager-core): add 0006 users_authz migration

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>
This commit is contained in:
MechaCat02
2026-05-26 21:33:40 +02:00
parent 5546323cdc
commit d435322f9c
2 changed files with 158 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
-- 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()
-- );

View File

@@ -18,6 +18,21 @@ table: admin_users
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
last_login_at: timestamp with time zone NULL
instance_role: text NOT NULL default='owner'::text
email: text NULL
mfa_secret: text NULL
table: api_keys
id: uuid NOT NULL default=gen_random_uuid()
user_id: uuid NOT NULL
hash: text NOT NULL
prefix: text NOT NULL
name: text NOT NULL
scopes: ARRAY NOT NULL
app_id: uuid NULL
expires_at: timestamp with time zone NULL
last_used_at: timestamp with time zone NULL
created_at: timestamp with time zone NOT NULL default=now()
table: app_domains
id: uuid NOT NULL default=gen_random_uuid()
@@ -27,6 +42,12 @@ table: app_domains
shape_key: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
table: app_members
app_id: uuid NOT NULL
user_id: uuid NOT NULL
role: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
table: app_slug_history
slug: text NOT NULL
current_app_id: uuid NOT NULL
@@ -88,14 +109,25 @@ indexes on admin_sessions:
admin_sessions_user_idx: public.admin_sessions USING btree (user_id)
indexes on admin_users:
admin_users_email_key: public.admin_users USING btree (email)
admin_users_instance_role_idx: public.admin_users USING btree (instance_role)
admin_users_pkey: public.admin_users USING btree (id)
admin_users_username_key: public.admin_users USING btree (username)
indexes on api_keys:
api_keys_pkey: public.api_keys USING btree (id)
api_keys_prefix_idx: public.api_keys USING btree (prefix)
api_keys_user_id_idx: public.api_keys USING btree (user_id)
indexes on app_domains:
app_domains_app_id_idx: public.app_domains USING btree (app_id)
app_domains_pkey: public.app_domains USING btree (id)
app_domains_shape_key_key: public.app_domains USING btree (shape_key)
indexes on app_members:
app_members_pkey: public.app_members USING btree (app_id, user_id)
app_members_user_id_idx: public.app_members USING btree (user_id)
indexes on app_slug_history:
app_slug_history_pkey: public.app_slug_history USING btree (slug)
@@ -127,15 +159,28 @@ constraints on admin_sessions:
[PRIMARY KEY] admin_sessions_pkey: PRIMARY KEY (token_hash)
constraints on admin_users:
[CHECK] admin_users_instance_role_check: CHECK ((instance_role = ANY (ARRAY['owner'::text, 'admin'::text, 'member'::text])))
[PRIMARY KEY] admin_users_pkey: PRIMARY KEY (id)
[UNIQUE] admin_users_email_key: UNIQUE (email)
[UNIQUE] admin_users_username_key: UNIQUE (username)
constraints on api_keys:
[FOREIGN KEY] api_keys_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] api_keys_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
[PRIMARY KEY] api_keys_pkey: PRIMARY KEY (id)
constraints on app_domains:
[CHECK] app_domains_shape_check: CHECK ((shape = ANY (ARRAY['exact'::text, 'wildcard'::text, 'parameterized'::text])))
[FOREIGN KEY] app_domains_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] app_domains_pkey: PRIMARY KEY (id)
[UNIQUE] app_domains_shape_key_key: UNIQUE (shape_key)
constraints on app_members:
[CHECK] app_members_role_check: CHECK ((role = ANY (ARRAY['app_admin'::text, 'editor'::text, 'viewer'::text])))
[FOREIGN KEY] app_members_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] app_members_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
[PRIMARY KEY] app_members_pkey: PRIMARY KEY (app_id, user_id)
constraints on app_slug_history:
[FOREIGN KEY] app_slug_history_current_app_id_fkey: FOREIGN KEY (current_app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] app_slug_history_pkey: PRIMARY KEY (slug)
@@ -169,3 +214,4 @@ constraints on scripts:
0003: routes
0004: admin auth
0005: apps
0006: users authz