-- ============================================================================= -- 2026-05-10 Audit / HIGH-10 closure -- ============================================================================= -- -- Per-actor scope override on role grants. Pre-fix, actor_roles had -- expires_at (already shipped) but no scope_type/scope_id columns, so -- "give Alice operator over profile X only" wasn't expressible at the -- grant layer — the only path was creating a scoped role and granting -- that. This migration adds the per-grant scope tuple so an operator -- can attach Alice to the standing r-operator role but scope the -- grant to profile X. -- -- scope_type defaults to 'global' to preserve existing rows; scope_id -- stays NULL when scope_type='global'. Authorizer.CheckPermission -- already understands the tuple shape (role_permissions carries the -- same columns); the actor-role addition gives operators a second -- knob without forcing them to fork roles. -- -- Idempotency note (2026-05-12 cold-DB smoke closure): the -- certctl-server boot sequence runs every *.up.sql on every start -- (internal/repository/postgres/db.go::RunMigrations has no -- schema_migrations tracker — see CLAUDE.md "Idempotent migrations" -- architecture decision). All non-IF-NOT-EXISTS operations below -- must be wrapped in DO blocks that skip when the object already -- exists. The canonical pattern lives in -- migrations/000033_approval_kinds.up.sql. -- ============================================================================= BEGIN; ALTER TABLE actor_roles ADD COLUMN IF NOT EXISTS scope_type TEXT NOT NULL DEFAULT 'global', ADD COLUMN IF NOT EXISTS scope_id TEXT; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'actor_roles_scope_type_enum') THEN ALTER TABLE actor_roles ADD CONSTRAINT actor_roles_scope_type_enum CHECK (scope_type IN ('global', 'profile', 'issuer')); END IF; END$$; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'actor_roles_scope_id_required_when_not_global') THEN ALTER TABLE actor_roles ADD CONSTRAINT actor_roles_scope_id_required_when_not_global CHECK ( (scope_type = 'global' AND scope_id IS NULL) OR (scope_type IN ('profile', 'issuer') AND scope_id IS NOT NULL) ); END IF; END$$; -- The (actor_id, actor_type, role_id, tenant_id) uniqueness must -- relax: an operator can grant the same role to the same actor at -- different scopes (e.g. r-operator on profile-A AND on profile-B). ALTER TABLE actor_roles DROP CONSTRAINT IF EXISTS actor_roles_actor_id_actor_type_role_id_tenant_id_key; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'actor_roles_actor_role_scope_unique') THEN ALTER TABLE actor_roles ADD CONSTRAINT actor_roles_actor_role_scope_unique UNIQUE (actor_id, actor_type, role_id, scope_type, scope_id, tenant_id); END IF; END$$; CREATE INDEX IF NOT EXISTS idx_actor_roles_scope ON actor_roles(scope_type, scope_id) WHERE scope_id IS NOT NULL; COMMIT;