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:
112
crates/manager-core/migrations/0006_users_authz.sql
Normal file
112
crates/manager-core/migrations/0006_users_authz.sql
Normal 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()
|
||||
-- );
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user