forked from wangziqi/gongxue-base
feat: add platform admin permissions
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user