feat: add platform admin permissions

This commit is contained in:
Codex
2026-06-30 07:50:02 +08:00
parent 51f8cbdec8
commit 780feee4f8
20 changed files with 392 additions and 63 deletions

View File

@@ -21,6 +21,7 @@ const PARTNER_TENANT_ADMIN_USER_ID = '00000000-0000-0000-0000-000000000907';
const AUTH_USER_ID = '00000000-0000-0000-0000-00000000a101';
const AUTH_TENANT_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a102';
const AUTH_PLATFORM_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a999';
const AUTH_RESTRICTED_PLATFORM_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a998';
const AUTH_JWT_SECRET = 'development-jwt-secret-change-me';
const START_SERVER = process.argv.includes('--start-server');
const ENABLE_REAL_STORAGE_SIGN_TESTS = process.env.ENABLE_REAL_STORAGE_SIGN_TESTS === 'true';
@@ -1063,6 +1064,121 @@ async function testSupabaseJwtIdentity() {
assert.equal(studentPlatformDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student Supabase JWT must not access platform APIs');
}
async function testPlatformAdminPermissions() {
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
const restrictedUserId = crypto.randomUUID();
try {
await pool.query(
`
insert into auth.users (
id, aud, role, phone, phone_confirmed_at, raw_app_meta_data, raw_user_meta_data,
created_at, updated_at
)
values (
$1, 'authenticated', 'authenticated', '13999999998', now(),
'{"provider":"phone","providers":["phone"],"app_role":"platform_admin"}'::jsonb,
'{}'::jsonb, now(), now()
)
on conflict (id)
do update set phone = excluded.phone,
raw_app_meta_data = excluded.raw_app_meta_data,
updated_at = now()
`,
[AUTH_RESTRICTED_PLATFORM_ADMIN_USER_ID],
);
await pool.query(
`
insert into public.platform_users (
id, auth_user_id, username, phone, name, primary_role, platform_permissions, raw_profile
)
values (
$1, $2, 'restricted_platform_reader', '13999999998', 'Restricted Platform Reader',
'platform_admin',
'{"platform:overview:read":true,"platform:tenant:read":true,"platform:plan:read":true}'::jsonb,
'{"source":"api-integration-test"}'::jsonb
)
on conflict (auth_user_id)
do update set username = excluded.username,
phone = excluded.phone,
name = excluded.name,
primary_role = excluded.primary_role,
platform_permissions = excluded.platform_permissions,
updated_at = now()
`,
[restrictedUserId, AUTH_RESTRICTED_PLATFORM_ADMIN_USER_ID],
);
} finally {
await pool.end();
}
const restrictedJwt = await createSupabaseJwt(AUTH_RESTRICTED_PLATFORM_ADMIN_USER_ID, {
phone: '13999999998',
appRole: 'platform_admin',
tenantId: false,
});
const restrictedHeaders = { authorization: `Bearer ${restrictedJwt}` };
const permissionSummary = await request('/api/platform-admin/permissions', {
tenantId: false,
userId: false,
headers: restrictedHeaders,
});
assert.equal(permissionSummary.item?.effective?.['platform:tenant:read'], true, 'restricted platform admin should expose granted tenant read permission');
assert.equal(permissionSummary.item?.effective?.['platform:tenant:write'], false, 'restricted platform admin should not expose tenant write permission');
assert.equal(permissionSummary.item?.effective?.['platform:audit:export'], false, 'restricted platform admin should not expose audit export permission');
assert.ok(!JSON.stringify(permissionSummary).includes('local-platform-admin-key'), 'platform permission summary must not leak platform admin key');
const overview = await request('/api/platform-admin/overview', {
tenantId: false,
userId: false,
headers: restrictedHeaders,
});
assert.ok(overview.item?.tenants?.total >= 1, 'restricted platform admin should access explicitly granted overview');
const tenants = await request('/api/platform-admin/tenants', {
tenantId: false,
userId: false,
headers: restrictedHeaders,
});
assert.ok(Array.isArray(tenants.items), 'restricted platform admin should list tenants with tenant read permission');
const createDenied = await request('/api/platform-admin/tenants', {
tenantId: false,
userId: false,
headers: restrictedHeaders,
method: 'POST',
body: {
slug: `restricted-denied-${Date.now().toString(36)}`,
name: 'Restricted Denied Tenant',
},
expectStatus: 403,
});
assert.equal(createDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'tenant write must require platform tenant write permission');
const auditExportDenied = await request('/api/platform-admin/audit-logs/export', {
tenantId: false,
userId: false,
headers: restrictedHeaders,
query: { format: 'csv' },
expectStatus: 403,
});
assert.equal(auditExportDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'audit export must require explicit platform audit export permission');
const paymentDenied = await request('/api/platform-admin/invoices/payments/manual-confirm', {
tenantId: false,
userId: false,
headers: restrictedHeaders,
method: 'POST',
body: {
tenantId: PARTNER_TENANT_ID,
invoiceId: ids.platformOverdueInvoice,
amountCents: 1,
},
expectStatus: 403,
});
assert.equal(paymentDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'manual service-fee payment must require billing payment permission');
}
async function testSupabaseJwksIdentity() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const publicJwk = await exportJWK(publicKey);
@@ -8897,6 +9013,7 @@ async function main() {
await check('trusted session identity', testTrustedSessionIdentity);
await check('phone binding', testPhoneBinding);
await check('Supabase JWT identity', testSupabaseJwtIdentity);
await check('platform admin permissions', testPlatformAdminPermissions);
await check('Supabase JWKS JWT identity', testSupabaseJwksIdentity);
await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled);
await check('platform tenant operations and audit', testPlatformTenantOperationsAndAudit);

View File

@@ -832,11 +832,11 @@ async function normalizeUsers(records: JsonRecord[]) {
`
insert into public.platform_users (
legacy_id, username, email, phone, name, avatar_url, primary_role, score,
last_seen_at, password_migration_required, raw_profile, created_at, updated_at
last_seen_at, password_migration_required, platform_permissions, raw_profile, created_at, updated_at
)
values ($1,$2,$3,$4,$5,$6,$7,$8,nullif($9::text,'')::timestamptz,true,$10,
coalesce(nullif($11::text,'')::timestamptz, now()),
coalesce(nullif($12::text,'')::timestamptz, now())
values ($1,$2,$3,$4,$5,$6,$7,$8,nullif($9::text,'')::timestamptz,true,$10::jsonb,$11,
coalesce(nullif($12::text,'')::timestamptz, now()),
coalesce(nullif($13::text,'')::timestamptz, now())
)
on conflict (legacy_id) do update set
username = excluded.username,
@@ -846,6 +846,7 @@ async function normalizeUsers(records: JsonRecord[]) {
avatar_url = excluded.avatar_url,
primary_role = excluded.primary_role,
score = excluded.score,
platform_permissions = excluded.platform_permissions,
raw_profile = excluded.raw_profile,
updated_at = excluded.updated_at
`,
@@ -856,9 +857,10 @@ async function normalizeUsers(records: JsonRecord[]) {
text(r.phone),
text(r.name) || text(r.username),
text(r.avatar),
text(r.role) || 'student',
normalizeTenantRole(r.role),
intValue(r.score),
dateText(r.lastSeenAt),
normalizeTenantRole(r.role) === 'platform_admin' ? JSON.stringify({ '*': true }) : JSON.stringify({}),
JSON.stringify(safeProfile),
dateText(r.created),
dateText(r.updated),

View File

@@ -394,6 +394,27 @@ async function validateDatabase() {
pass('db.provider_public_config', 'Active provider public configs do not contain secret-like keys');
}
const platformAdminsWithoutPermissions = await pool.query(`
select id, username, phone
from public.platform_users
where primary_role = 'platform_admin'
and (platform_permissions is null or platform_permissions = '{}'::jsonb)
order by created_at asc
limit 20
`);
if (platformAdminsWithoutPermissions.rowCount > 0) {
block('db.platform_admin_permissions', 'Platform admin users must have explicit platform_permissions', {
count: platformAdminsWithoutPermissions.rowCount,
samples: platformAdminsWithoutPermissions.rows.map(row => ({
id: row.id,
username: row.username,
phone: row.phone ? `${String(row.phone).slice(0, 3)}****${String(row.phone).slice(-4)}` : null,
})),
});
} else {
pass('db.platform_admin_permissions', 'Platform admin users have explicit permission maps');
}
const missingAuthSecretRows = await pool.query(`
select p.tenant_id, p.provider
from public.tenant_auth_providers p

View File

@@ -847,14 +847,15 @@ async function main() {
await client.query(
`
insert into public.platform_users (id, auth_user_id, username, phone, name, primary_role, raw_profile)
values ($1, $2, 'smoke_platform_admin', '13999999999', 'Smoke Platform Admin', 'platform_admin', '{"source":"smoke-seed"}'::jsonb)
insert into public.platform_users (id, auth_user_id, username, phone, name, primary_role, platform_permissions, raw_profile)
values ($1, $2, 'smoke_platform_admin', '13999999999', 'Smoke Platform Admin', 'platform_admin', '{"*":true}'::jsonb, '{"source":"smoke-seed"}'::jsonb)
on conflict (id)
do update set username = excluded.username,
auth_user_id = excluded.auth_user_id,
phone = excluded.phone,
name = excluded.name,
primary_role = excluded.primary_role,
platform_permissions = excluded.platform_permissions,
updated_at = now()
`,
[ids.platformAdminUser, ids.authPlatformAdminUser],