From 8eed0603f9c986da28084b62ef3db9b15a435c24 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 11 Aug 2026 00:10:24 +0800 Subject: [PATCH] fix(admin): allow administrator list reads --- ..._admin_users_admin_runtime_read_policy.sql | 13 ++++++++++++ frontend/tests/admin-contracts.test.ts | 9 ++++++++ .../tests/database-admin-identity.test.ts | 21 +++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 frontend/supabase/migrations/20260810010000_admin_users_admin_runtime_read_policy.sql diff --git a/frontend/supabase/migrations/20260810010000_admin_users_admin_runtime_read_policy.sql b/frontend/supabase/migrations/20260810010000_admin_users_admin_runtime_read_policy.sql new file mode 100644 index 00000000..ae7f36b6 --- /dev/null +++ b/frontend/supabase/migrations/20260810010000_admin_users_admin_runtime_read_policy.sql @@ -0,0 +1,13 @@ +begin; + +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'admin_runtime') then + drop policy if exists admin_users_admin_read on public.admin_users; + create policy admin_users_admin_read on public.admin_users + for select to admin_runtime using (true); + end if; +end; +$$; + +commit; diff --git a/frontend/tests/admin-contracts.test.ts b/frontend/tests/admin-contracts.test.ts index b7ba9967..929dfca7 100644 --- a/frontend/tests/admin-contracts.test.ts +++ b/frontend/tests/admin-contracts.test.ts @@ -15,6 +15,10 @@ const authPolicy = readFileSync(new URL("../src/lib/admin/auth-policy.ts", impor const authFactory = readFileSync(new URL("../src/modules/identity/auth-factory.ts", import.meta.url), "utf8"); const adminHttp = readFileSync(new URL("../src/lib/admin/http.ts", import.meta.url), "utf8"); const rbacMigration = readFileSync(new URL("../supabase/migrations/20260806010000_admin_rbac.sql", import.meta.url), "utf8"); +const adminUsersReadPolicyMigration = readFileSync( + new URL("../supabase/migrations/20260810010000_admin_users_admin_runtime_read_policy.sql", import.meta.url), + "utf8", +); const ownerRecoveryMigration = readFileSync(new URL("../db/migrations/20260807010000_recover_initial_admin_owner.sql", import.meta.url), "utf8"); const bootstrapRoles = readFileSync(new URL("../../deploy/postgres/001-bootstrap-roles.sh", import.meta.url), "utf8"); const compatibilityRoles = readFileSync(new URL("../../deploy/postgres/002-ensure-business-compatibility-roles.sql", import.meta.url), "utf8"); @@ -281,6 +285,11 @@ test("admin runtime cannot assume service_role and keeps explicit RBAC grants", assert.match(rbacMigration, /public\.admin_read_customer_birth_data\(uuid, uuid\[\], text\)[\s\S]*to admin_runtime/); }); +test("admin runtime can read administrators through RLS", () => { + assert.match(adminUsersReadPolicyMigration, /admin_users_admin_read/); + assert.match(adminUsersReadPolicyMigration, /for select to admin_runtime using \(true\)/); +}); + test("last Owner revocations are serialized by one transaction advisory lock", () => { assert.match(rbacMigration, /pg_advisory_xact_lock\(1096040772, 1\)[\s\S]*v_owner_count/); }); diff --git a/frontend/tests/database-admin-identity.test.ts b/frontend/tests/database-admin-identity.test.ts index 5b256bfa..6e91704e 100644 --- a/frontend/tests/database-admin-identity.test.ts +++ b/frontend/tests/database-admin-identity.test.ts @@ -18,6 +18,7 @@ const compatibilitySql = readFileSync( "utf8", ); const unknownUserId = "00000000-0000-4000-8000-000000000001"; +const visibleAdminId = "00000000-0000-4000-8000-000000000002"; test("service and restricted admin database identities stay separated", async () => { const fixture = startPostgresFixture(); @@ -74,6 +75,26 @@ test("service and restricted admin database identities stay separated", async () assert.equal(fixture.psql("select pg_has_role('service_runtime','service_role','MEMBER')"), "t"); assert.equal(fixture.psql("select pg_has_role('admin_runtime','service_role','MEMBER')"), "f"); + fixture.psqlAs("identity_runtime", "identity-runtime-test-password", ` + insert into identity.users (id, name, email, email_verified, email_verified_at, role) + values ('${visibleAdminId}', 'Visible Admin', 'visible-admin@example.com', true, now(), 'admin') + `); + fixture.psql(` + insert into public.admin_users (user_id, created_by) + values ('${visibleAdminId}', '${visibleAdminId}'); + insert into public.admin_user_roles (admin_user_id, role_id, assigned_by) + select '${visibleAdminId}', id, '${visibleAdminId}' from public.admin_roles where code = 'support'; + `); + assert.equal( + fixture.psqlAs( + "admin_runtime", + "admin-runtime-test-password", + `select count(*) from public.admin_users where user_id = '${visibleAdminId}' and revoked_at is null`, + ), + "1", + "admin list reads must not be hidden by admin_users RLS", + ); + const previousAuthProvider = process.env.AUTH_PROVIDER; const previousServiceDatabaseUrl = process.env.SERVICE_DATABASE_URL; const previousAdminDatabaseUrl = process.env.ADMIN_DATABASE_URL;