From d435322f9ccb645629b49933c1a4c745fe0728cf Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 26 May 2026 21:33:40 +0200 Subject: [PATCH] feat(manager-core): add 0006 users_authz migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../migrations/0006_users_authz.sql | 112 ++++++++++++++++++ crates/manager-core/tests/expected_schema.txt | 46 +++++++ 2 files changed, 158 insertions(+) create mode 100644 crates/manager-core/migrations/0006_users_authz.sql diff --git a/crates/manager-core/migrations/0006_users_authz.sql b/crates/manager-core/migrations/0006_users_authz.sql new file mode 100644 index 0000000..f8501b2 --- /dev/null +++ b/crates/manager-core/migrations/0006_users_authz.sql @@ -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_`. +-- 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() +-- ); diff --git a/crates/manager-core/tests/expected_schema.txt b/crates/manager-core/tests/expected_schema.txt index 88c3171..ff7c50f 100644 --- a/crates/manager-core/tests/expected_schema.txt +++ b/crates/manager-core/tests/expected_schema.txt @@ -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