begin; alter table public.admin_users add column if not exists updated_at timestamptz not null default now(); create table if not exists public.admin_roles ( id uuid primary key default gen_random_uuid(), code text not null unique check (code ~ '^[a-z][a-z0-9_]{1,39}$'), name text not null check (char_length(name) between 1 and 80), description text not null default '' check (char_length(description) <= 500), requires_mfa boolean not null default false, system_role boolean not null default true, created_at timestamptz not null default now() ); create table if not exists public.admin_permissions ( id uuid primary key default gen_random_uuid(), permission_key text not null unique check (permission_key ~ '^[a-z][a-z0-9_.]{2,79}$'), description text not null default '' check (char_length(description) <= 500) ); create table if not exists public.admin_user_roles ( admin_user_id uuid not null references public.admin_users(user_id) on delete cascade, role_id uuid not null references public.admin_roles(id) on delete restrict, assigned_by uuid not null references auth.users(id) on delete restrict, assigned_at timestamptz not null default now(), primary key (admin_user_id, role_id) ); create table if not exists public.admin_role_permissions ( role_id uuid not null references public.admin_roles(id) on delete cascade, permission_id uuid not null references public.admin_permissions(id) on delete cascade, primary key (role_id, permission_id) ); create table if not exists public.admin_session_revocations ( id uuid primary key default gen_random_uuid(), admin_user_id uuid not null references public.admin_users(user_id) on delete cascade, revoked_by uuid not null references auth.users(id) on delete restrict, reason text not null check (char_length(btrim(reason)) between 1 and 500), revoked_at timestamptz not null default clock_timestamp() ); create index if not exists admin_session_revocations_user_idx on public.admin_session_revocations (admin_user_id, revoked_at desc); insert into public.admin_permissions (permission_key, description) values ('admin.access', '进入管理后台'), ('admin.customers.read', '查看客户基本资料'), ('admin.customers.birth_data.read', '查看客户出生资料'), ('admin.users.read', '查看管理员'), ('admin.users.manage_roles', '管理管理员角色'), ('billing.products.read', '查看商品'), ('billing.products.write', '编辑商品草稿'), ('billing.products.publish', '发布商品版本'), ('billing.orders.read', '查看订单'), ('billing.adjustments.write', '调整订阅和补偿'), ('models.read', '查看模型配置'), ('models.write', '编辑模型草稿'), ('models.test', '测试模型连接'), ('models.publish', '发布模型配置'), ('models.rollback', '回滚模型配置'), ('ops.flags.write', '修改功能开关'), ('audit.read', '查看审计日志') on conflict (permission_key) do update set description = excluded.description; insert into public.admin_roles (code, name, description, requires_mfa) values ('owner', 'Owner', '管理员、角色、模型、账务、配置和审计', true), ('model_admin', 'Model Admin', '模型草稿、测试、发布和回滚', true), ('billing_admin', 'Billing Admin', '商品、订单、补偿和订阅调整', true), ('operations', 'Operations', '监控、熔断、切流和任务重试', true), ('support', 'Support', '用户查询和有限补偿', false), ('auditor', 'Auditor', '只读账本、配置版本和审计', false) on conflict (code) do update set name = excluded.name, description = excluded.description, requires_mfa = excluded.requires_mfa; with role_grants(role_code, permission_key) as (values ('owner', 'admin.access'), ('owner', 'admin.customers.read'), ('owner', 'admin.customers.birth_data.read'), ('owner', 'admin.users.read'), ('owner', 'admin.users.manage_roles'), ('owner', 'billing.products.read'), ('owner', 'billing.products.write'), ('owner', 'billing.products.publish'), ('owner', 'billing.orders.read'), ('owner', 'billing.adjustments.write'), ('owner', 'models.read'), ('owner', 'models.write'), ('owner', 'models.test'), ('owner', 'models.publish'), ('owner', 'models.rollback'), ('owner', 'ops.flags.write'), ('owner', 'audit.read'), ('model_admin', 'admin.access'), ('model_admin', 'models.read'), ('model_admin', 'models.write'), ('model_admin', 'models.test'), ('model_admin', 'models.publish'), ('model_admin', 'models.rollback'), ('billing_admin', 'admin.access'), ('billing_admin', 'admin.customers.read'), ('billing_admin', 'billing.products.read'), ('billing_admin', 'billing.products.write'), ('billing_admin', 'billing.products.publish'), ('billing_admin', 'billing.orders.read'), ('billing_admin', 'billing.adjustments.write'), ('operations', 'admin.access'), ('operations', 'admin.customers.read'), ('operations', 'billing.orders.read'), ('operations', 'models.read'), ('operations', 'ops.flags.write'), ('operations', 'audit.read'), ('support', 'admin.access'), ('support', 'admin.customers.read'), ('support', 'billing.orders.read'), ('auditor', 'admin.access'), ('auditor', 'admin.customers.read'), ('auditor', 'billing.products.read'), ('auditor', 'billing.orders.read'), ('auditor', 'models.read'), ('auditor', 'audit.read') ) insert into public.admin_role_permissions (role_id, permission_id) select r.id, p.id from role_grants g join public.admin_roles r on r.code = g.role_code join public.admin_permissions p on p.permission_key = g.permission_key on conflict do nothing; -- Existing self-hosted administrators are the one-time bootstrap source. Runtime -- authorization below uses only public.admin_users + RBAC, never ADMIN_EMAILS. insert into public.admin_users (user_id, created_by) select u.id, u.id from identity.users u where 'admin' = any(regexp_split_to_array(u.role, '\\s*,\\s*')) and exists (select 1 from auth.users a where a.id = u.id) on conflict on constraint admin_users_pkey do update set revoked_at = null, revoked_by = null, updated_at = now(); insert into public.admin_user_roles (admin_user_id, role_id, assigned_by) select a.user_id, r.id, a.user_id from public.admin_users a cross join public.admin_roles r where a.revoked_at is null and r.code = 'owner' and not exists (select 1 from public.admin_user_roles existing where existing.admin_user_id = a.user_id) on conflict do nothing; create or replace function public.admin_has_permission(p_user_id uuid, p_permission_key text) returns boolean language sql stable security definer set search_path = '' as $$ select exists ( select 1 from public.admin_users au join public.admin_user_roles aur on aur.admin_user_id = au.user_id join public.admin_role_permissions arp on arp.role_id = aur.role_id join public.admin_permissions ap on ap.id = arp.permission_id where au.user_id = p_user_id and au.revoked_at is null and ap.permission_key = p_permission_key ); $$; create or replace function public.admin_permission_keys(p_user_id uuid) returns table (permission_key text, role_code text, requires_mfa boolean) language sql stable security definer set search_path = '' as $$ select distinct ap.permission_key, ar.code, ar.requires_mfa from public.admin_users au join public.admin_user_roles aur on aur.admin_user_id = au.user_id join public.admin_roles ar on ar.id = aur.role_id join public.admin_role_permissions arp on arp.role_id = ar.id join public.admin_permissions ap on ap.id = arp.permission_id where au.user_id = p_user_id and au.revoked_at is null order by ar.code, ap.permission_key; $$; create or replace function public.assert_active_admin_owner_exists() returns trigger language plpgsql security definer set search_path = '' as $$ declare v_removes_active_owner boolean := false; begin if tg_table_name = 'admin_users' then v_removes_active_owner := old.revoked_at is null and (tg_op = 'DELETE' or new.revoked_at is not null) and exists ( select 1 from public.admin_user_roles aur join public.admin_roles ar on ar.id = aur.role_id where aur.admin_user_id = old.user_id and ar.code = 'owner' ); elsif tg_table_name = 'admin_user_roles' then v_removes_active_owner := exists ( select 1 from public.admin_roles ar where ar.id = old.role_id and ar.code = 'owner' ) and ( tg_op = 'DELETE' or new.admin_user_id is distinct from old.admin_user_id or new.role_id is distinct from old.role_id ); elsif tg_table_name = 'admin_roles' then v_removes_active_owner := old.code = 'owner' and (tg_op = 'DELETE' or new.code is distinct from old.code); end if; if v_removes_active_owner then perform pg_catalog.pg_advisory_xact_lock(1096040772, 1); if not exists ( select 1 from public.admin_users au join public.admin_user_roles aur on aur.admin_user_id = au.user_id join public.admin_roles ar on ar.id = aur.role_id where au.revoked_at is null and ar.code = 'owner' ) then raise exception 'last_owner_protected' using errcode = '23514'; end if; end if; if tg_op = 'DELETE' then return old; end if; return new; end; $$; revoke all on function public.assert_active_admin_owner_exists() from public, anon, authenticated, service_role; drop trigger if exists admin_users_require_active_owner on public.admin_users; create constraint trigger admin_users_require_active_owner after update of revoked_at or delete on public.admin_users deferrable initially immediate for each row execute function public.assert_active_admin_owner_exists(); drop trigger if exists admin_user_roles_require_active_owner on public.admin_user_roles; create constraint trigger admin_user_roles_require_active_owner after update of admin_user_id, role_id or delete on public.admin_user_roles deferrable initially immediate for each row execute function public.assert_active_admin_owner_exists(); drop trigger if exists admin_roles_require_active_owner on public.admin_roles; create constraint trigger admin_roles_require_active_owner after update of code or delete on public.admin_roles deferrable initially immediate for each row execute function public.assert_active_admin_owner_exists(); alter table audit.admin_audit_logs drop constraint if exists admin_audit_logs_action_check, drop constraint if exists admin_audit_logs_target_type_check, drop constraint if exists admin_audit_logs_actor_role_check; alter table audit.admin_audit_logs add column if not exists permission_used text, add column if not exists reason text; create or replace function public.admin_manage_role( p_actor_user_id uuid, p_target_user_id uuid, p_role_code text, p_assign boolean, p_reason text, p_request_id text ) returns table (user_id uuid, role_code text, assigned boolean) language plpgsql security definer set search_path = '' as $$ declare v_role_id uuid; v_actor_email text; v_target_email text; v_owner_count integer; begin if not public.admin_has_permission(p_actor_user_id, 'admin.users.manage_roles') then raise exception 'admin_permission_denied' using errcode = '42501'; end if; if char_length(btrim(coalesce(p_reason, ''))) not between 1 and 500 then raise exception 'admin_reason_required' using errcode = '22023'; end if; select id into v_role_id from public.admin_roles where code = p_role_code; if v_role_id is null then raise exception 'admin_role_not_found' using errcode = '22023'; end if; select lower(btrim(email)) into v_actor_email from identity.users where id = p_actor_user_id; select lower(btrim(email)) into v_target_email from identity.users where id = p_target_user_id; if v_target_email is null then raise exception 'admin_user_not_found' using errcode = '22023'; end if; insert into public.admin_users (user_id, created_by) values (p_target_user_id, p_actor_user_id) on conflict on constraint admin_users_pkey do update set revoked_at = null, revoked_by = null, updated_at = now(); if p_assign then insert into public.admin_user_roles (admin_user_id, role_id, assigned_by) values (p_target_user_id, v_role_id, p_actor_user_id) on conflict do nothing; else if p_role_code = 'owner' then -- Fixed transaction-level lock serializes every last-Owner decision. perform pg_catalog.pg_advisory_xact_lock(1096040772, 1); select count(*) into v_owner_count from public.admin_users au join public.admin_user_roles aur on aur.admin_user_id = au.user_id join public.admin_roles ar on ar.id = aur.role_id where au.revoked_at is null and ar.code = 'owner'; if v_owner_count <= 1 and exists ( select 1 from public.admin_user_roles where admin_user_id = p_target_user_id and role_id = v_role_id ) then raise exception 'last_owner_protected' using errcode = '23514'; end if; end if; delete from public.admin_user_roles where admin_user_id = p_target_user_id and role_id = v_role_id; delete from identity.sessions where user_id = p_target_user_id; insert into public.admin_session_revocations (admin_user_id, revoked_by, reason) values (p_target_user_id, p_actor_user_id, btrim(p_reason)); end if; insert into audit.admin_audit_logs ( actor_user_id, actor_email, actor_role, action, target_type, target_id, before_value, after_value, request_id, permission_used, reason ) values ( p_actor_user_id, v_actor_email, 'admin', case when p_assign then 'admin.role.assign' else 'admin.role.revoke' end, 'administrator', p_target_user_id, null, jsonb_build_object('email', v_target_email, 'role', p_role_code, 'assigned', p_assign), p_request_id, 'admin.users.manage_roles', btrim(p_reason) ) on conflict do nothing; return query select p_target_user_id, p_role_code, p_assign; end; $$; create or replace function public.admin_read_customer_birth_data( p_actor_user_id uuid, p_target_user_ids uuid[], p_request_id text ) returns table ( user_id uuid, birth_date date, birth_time_status text, birth_place_label text ) language plpgsql security definer set search_path = '' as $$ declare v_actor_email text; begin if not public.admin_has_permission(p_actor_user_id, 'admin.customers.birth_data.read') then raise exception 'admin_permission_denied' using errcode = '42501'; end if; if p_target_user_ids is null or cardinality(p_target_user_ids) <> 1 then raise exception 'admin_customer_scope_invalid' using errcode = '22023'; end if; if char_length(btrim(coalesce(p_request_id, ''))) not between 1 and 200 then raise exception 'admin_request_id_invalid' using errcode = '22023'; end if; select lower(btrim(email)) into v_actor_email from identity.users where id = p_actor_user_id; if v_actor_email is null then raise exception 'admin_user_not_found' using errcode = '22023'; end if; insert into audit.admin_audit_logs ( actor_user_id, actor_email, actor_role, action, target_type, target_id, after_value, request_id, permission_used, reason ) select p_actor_user_id, v_actor_email, 'admin', 'admin.customer.birth_data.read', 'customer', p.id, jsonb_build_object('fields', array['birth_date', 'birth_time_status', 'birth_place_label']), btrim(p_request_id), 'admin.customers.birth_data.read', 'sensitive customer birth data view' from public.profiles p where p.id = any(p_target_user_ids) on conflict do nothing; return query select p.id, p.birth_date, p.birth_time_status, p.birth_place_label from public.profiles p where p.id = any(p_target_user_ids); end; $$; revoke all on table public.admin_roles, public.admin_permissions, public.admin_user_roles, public.admin_role_permissions, public.admin_session_revocations from public, anon, authenticated; grant select on table public.admin_roles, public.admin_permissions, public.admin_user_roles, public.admin_role_permissions, public.admin_session_revocations to service_role; grant select, insert, update on table public.admin_users to service_role; revoke all on function public.admin_has_permission(uuid, text), public.admin_permission_keys(uuid), public.admin_manage_role(uuid, uuid, text, boolean, text, text), public.admin_read_customer_birth_data(uuid, uuid[], text) from public, anon, authenticated; grant execute on function public.admin_has_permission(uuid, text), public.admin_permission_keys(uuid), public.admin_manage_role(uuid, uuid, text, boolean, text, text), public.admin_read_customer_birth_data(uuid, uuid[], text) to service_role; do $$ begin if exists (select 1 from pg_roles where rolname = 'admin_runtime') then if pg_catalog.pg_has_role('admin_runtime', 'service_role', 'MEMBER') then raise exception 'admin_runtime_service_role_membership_must_be_revoked_by_bootstrap'; end if; revoke select (birth_date, birth_time_status, birth_place_label) on table public.profiles from admin_runtime; grant select on table public.admin_users, public.admin_roles, public.admin_permissions, public.admin_user_roles, public.admin_role_permissions, public.admin_session_revocations to admin_runtime; grant execute on function public.admin_has_permission(uuid, text), public.admin_permission_keys(uuid), public.admin_manage_role(uuid, uuid, text, boolean, text, text), public.admin_read_customer_birth_data(uuid, uuid[], text) to admin_runtime; end if; end; $$; commit;