Files
Jyotisha/frontend/supabase/migrations/20260806010000_admin_rbac.sql
T
2026-08-06 20:15:08 +08:00

403 lines
17 KiB
PL/PgSQL

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;