forked from wangziqi/gongxue-base
feat: add platform staff management
This commit is contained in:
@@ -22,6 +22,7 @@ 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_PLATFORM_STAFF_USER_ID = '00000000-0000-4000-8000-00000000a997';
|
||||
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';
|
||||
@@ -1179,6 +1180,247 @@ async function testPlatformAdminPermissions() {
|
||||
assert.equal(paymentDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'manual service-fee payment must require billing payment permission');
|
||||
}
|
||||
|
||||
async function testPlatformStaffManagement() {
|
||||
const adminHeaders = { 'x-platform-admin-key': 'local-platform-admin-key' };
|
||||
const staffSuffix = Date.now().toString(36);
|
||||
const staffEmail = `platform.staff.${staffSuffix}@example.test`;
|
||||
const staffPhone = `139${String(Date.now()).slice(-8)}`;
|
||||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||||
try {
|
||||
await pool.query(
|
||||
`
|
||||
insert into auth.users (
|
||||
id, aud, role, phone, email, phone_confirmed_at, email_confirmed_at,
|
||||
raw_app_meta_data, raw_user_meta_data, created_at, updated_at
|
||||
)
|
||||
values (
|
||||
$1, 'authenticated', 'authenticated', $2, $3,
|
||||
now(), now(),
|
||||
'{"provider":"phone","providers":["phone"],"app_role":"platform_admin"}'::jsonb,
|
||||
'{}'::jsonb, now(), now()
|
||||
)
|
||||
on conflict (id)
|
||||
do update set phone = excluded.phone,
|
||||
email = excluded.email,
|
||||
raw_app_meta_data = excluded.raw_app_meta_data,
|
||||
updated_at = now()
|
||||
`,
|
||||
[AUTH_PLATFORM_STAFF_USER_ID, staffPhone, staffEmail],
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
const created = await request('/api/platform-admin/staff', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
authUserId: AUTH_PLATFORM_STAFF_USER_ID,
|
||||
username: 'platform_staff_operator',
|
||||
email: staffEmail,
|
||||
phone: staffPhone,
|
||||
name: 'Platform Staff Operator',
|
||||
status: 'active',
|
||||
platformPermissions: {
|
||||
'platform:staff:read': true,
|
||||
'platform:overview:read': true,
|
||||
'platform:tenant:read': true,
|
||||
},
|
||||
metadata: {
|
||||
title: 'operations',
|
||||
secretToken: 'should-not-be-returned-through-audit',
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.ok(created.item?.id, 'platform admin should create platform staff');
|
||||
assert.equal(created.item?.platformPermissions?.['platform:tenant:read'], true, 'platform staff response should include granted permissions');
|
||||
assert.ok(!JSON.stringify(created).includes('local-platform-admin-key'), 'platform staff response must not leak platform key');
|
||||
|
||||
const invalidPermission = await request('/api/platform-admin/staff', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
authUserId: AUTH_PLATFORM_STAFF_USER_ID,
|
||||
username: 'invalid_platform_staff',
|
||||
name: 'Invalid Platform Staff',
|
||||
platformPermissions: {
|
||||
'platform:unknown:write': true,
|
||||
},
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(invalidPermission.code, 'INVALID_PLATFORM_PERMISSION', 'platform staff permissions should reject unknown permission keys');
|
||||
|
||||
const missingAuthUser = await request('/api/platform-admin/staff', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
authUserId: '00000000-0000-4000-8000-00000000dead',
|
||||
username: 'missing_auth_platform_staff',
|
||||
name: 'Missing Auth Platform Staff',
|
||||
platformPermissions: {
|
||||
'platform:overview:read': true,
|
||||
},
|
||||
},
|
||||
expectStatus: 404,
|
||||
});
|
||||
assert.equal(missingAuthUser.code, 'AUTH_USER_NOT_FOUND', 'platform staff must bind an existing Supabase Auth user');
|
||||
|
||||
const missingAuthBinding = await request('/api/platform-admin/staff', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
username: 'unbound_platform_staff',
|
||||
name: 'Unbound Platform Staff',
|
||||
platformPermissions: {
|
||||
'platform:overview:read': true,
|
||||
},
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(missingAuthBinding.code, 'REQUIRED_FIELD', 'platform staff authUserId should be required');
|
||||
|
||||
const emptyPermissionDenied = await request('/api/platform-admin/staff', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
authUserId: AUTH_PLATFORM_STAFF_USER_ID,
|
||||
username: 'empty_permission_platform_staff',
|
||||
name: 'Empty Permission Platform Staff',
|
||||
status: 'active',
|
||||
platformPermissions: {},
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(emptyPermissionDenied.code, 'PLATFORM_PERMISSION_EMPTY', 'active platform staff should require explicit permissions');
|
||||
|
||||
const staffList = await request('/api/platform-admin/staff', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
query: { q: 'platform_staff_operator' },
|
||||
});
|
||||
assert.ok(staffList.items?.some(item => item.id === created.item.id), 'platform staff list should include created staff');
|
||||
|
||||
const staffJwt = await createSupabaseJwt(AUTH_PLATFORM_STAFF_USER_ID, {
|
||||
phone: staffPhone,
|
||||
appRole: 'platform_admin',
|
||||
tenantId: false,
|
||||
});
|
||||
const staffHeaders = { authorization: `Bearer ${staffJwt}` };
|
||||
|
||||
const staffPermissions = await request('/api/platform-admin/permissions', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: staffHeaders,
|
||||
});
|
||||
assert.equal(staffPermissions.item?.effective?.['platform:staff:read'], true, 'staff should expose its staff read permission');
|
||||
assert.equal(staffPermissions.item?.effective?.['platform:tenant:write'], false, 'staff should not expose ungranted tenant write permission');
|
||||
|
||||
const staffCanListTenants = await request('/api/platform-admin/tenants', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: staffHeaders,
|
||||
});
|
||||
assert.ok(Array.isArray(staffCanListTenants.items), 'staff should list tenants with tenant read permission');
|
||||
|
||||
const staffCreateTenantDenied = await request('/api/platform-admin/tenants', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: staffHeaders,
|
||||
method: 'POST',
|
||||
body: {
|
||||
slug: `staff-denied-${Date.now().toString(36)}`,
|
||||
name: 'Staff Denied Tenant',
|
||||
},
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(staffCreateTenantDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'platform staff should not create tenants without tenant write permission');
|
||||
|
||||
const staffWriteDenied = await request('/api/platform-admin/staff', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: staffHeaders,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
username: 'staff_cannot_create_staff',
|
||||
name: 'Staff Cannot Create Staff',
|
||||
platformPermissions: { 'platform:overview:read': true },
|
||||
},
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(staffWriteDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'staff write should require explicit platform staff write permission');
|
||||
|
||||
const platformAdminJwt = await createSupabaseJwt(AUTH_PLATFORM_ADMIN_USER_ID, {
|
||||
phone: '13999999999',
|
||||
appRole: 'platform_admin',
|
||||
tenantId: false,
|
||||
});
|
||||
const platformAdminHeaders = { authorization: `Bearer ${platformAdminJwt}` };
|
||||
|
||||
const selfDowngradeDenied = await request('/api/platform-admin/staff', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: platformAdminHeaders,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
id: '00000000-0000-0000-0000-000000000999',
|
||||
authUserId: AUTH_PLATFORM_ADMIN_USER_ID,
|
||||
username: 'smoke_platform_admin',
|
||||
phone: '13999999999',
|
||||
name: 'Smoke Platform Admin',
|
||||
status: 'active',
|
||||
platformPermissions: { 'platform:overview:read': true },
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(selfDowngradeDenied.code, 'CANNOT_DOWNGRADE_SELF', 'platform admin should not remove its own super permission');
|
||||
|
||||
const disabled = await request('/api/platform-admin/staff/status', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
staffId: created.item.id,
|
||||
status: 'disabled',
|
||||
reason: 'integration disable test',
|
||||
revokeSessions: true,
|
||||
},
|
||||
});
|
||||
assert.equal(disabled.item?.status, 'disabled', 'platform admin should disable platform staff');
|
||||
|
||||
const disabledStaffDenied = await request('/api/platform-admin/overview', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: staffHeaders,
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(disabledStaffDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'disabled platform staff JWT should no longer authenticate as platform admin');
|
||||
|
||||
const audit = await request('/api/platform-admin/audit-logs', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
query: { targetType: 'platform_user', q: 'platform.staff', limit: 20 },
|
||||
});
|
||||
assert.ok(
|
||||
audit.items?.some(item => item.targetId === created.item.id && item.action === 'platform.staff.status_updated'),
|
||||
'platform staff status changes should be audited',
|
||||
);
|
||||
assert.ok(!JSON.stringify(audit).includes('should-not-be-returned-through-audit'), 'platform staff audit should not leak token-like metadata');
|
||||
}
|
||||
|
||||
async function testSupabaseJwksIdentity() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const publicJwk = await exportJWK(publicKey);
|
||||
@@ -9014,6 +9256,7 @@ async function main() {
|
||||
await check('phone binding', testPhoneBinding);
|
||||
await check('Supabase JWT identity', testSupabaseJwtIdentity);
|
||||
await check('platform admin permissions', testPlatformAdminPermissions);
|
||||
await check('platform staff management', testPlatformStaffManagement);
|
||||
await check('Supabase JWKS JWT identity', testSupabaseJwksIdentity);
|
||||
await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled);
|
||||
await check('platform tenant operations and audit', testPlatformTenantOperationsAndAudit);
|
||||
|
||||
@@ -83,11 +83,44 @@ function runWorkerOnce() {
|
||||
}
|
||||
|
||||
async function cleanup(pool) {
|
||||
await pool.query(
|
||||
`
|
||||
delete from public.platform_audit_notification_events
|
||||
where channel_id in (
|
||||
select id
|
||||
from public.platform_audit_notification_channels
|
||||
where channel_code in ('worker_platform_audit_test', 'integration_platform_audit')
|
||||
)
|
||||
or alert_id in (
|
||||
select id
|
||||
from public.platform_audit_alerts
|
||||
where details->>'source' in ('worker-test', 'api-integration-test')
|
||||
or details->>'workerId' = 'platform-audit-alert-worker-test'
|
||||
)
|
||||
`,
|
||||
);
|
||||
await pool.query("delete from public.platform_audit_notification_events where alert_id = $1", [ids.alert]);
|
||||
await pool.query("delete from public.platform_audit_notification_channels where channel_code = 'worker_platform_audit_test'");
|
||||
await pool.query("delete from app_private.platform_secrets where secret_scope = 'webhook' and secret_key = 'worker_platform_audit_test'");
|
||||
await pool.query('delete from public.platform_audit_alerts where id = $1 or audit_log_id = $2', [ids.alert, ids.auditLog]);
|
||||
await pool.query('delete from public.audit_logs where id = $1 or tenant_id = $2', [ids.auditLog, ids.tenant]);
|
||||
await pool.query(
|
||||
`
|
||||
delete from public.platform_audit_alerts
|
||||
where id = $1
|
||||
or audit_log_id = $2
|
||||
or details->>'source' in ('worker-test', 'api-integration-test')
|
||||
or details->>'workerId' = 'platform-audit-alert-worker-test'
|
||||
`,
|
||||
[ids.alert, ids.auditLog],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
delete from public.audit_logs
|
||||
where id = $1
|
||||
or tenant_id = $2
|
||||
or user_agent in ('platform-audit-notification-worker-test', 'platform-audit-alert-worker-test')
|
||||
`,
|
||||
[ids.auditLog, ids.tenant],
|
||||
);
|
||||
await pool.query('delete from public.tenant_billing_profiles where tenant_id = $1', [ids.tenant]);
|
||||
await pool.query('delete from public.tenant_domains where tenant_id = $1', [ids.tenant]);
|
||||
await pool.query('delete from public.tenants where id = $1', [ids.tenant]);
|
||||
@@ -108,7 +141,7 @@ async function seed(pool, webhookUrl) {
|
||||
details, ip_address, user_agent, created_at
|
||||
)
|
||||
values (
|
||||
$1, $2::uuid, null, 'platform.tenant.status_updated', 'tenant', $3,
|
||||
$1, $2::uuid, null, 'platform.worker_test.status_updated', 'tenant', $3,
|
||||
'{"status":"suspended","apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb,
|
||||
'127.0.0.1', 'platform-audit-notification-worker-test', now()
|
||||
)
|
||||
@@ -125,7 +158,7 @@ async function seed(pool, webhookUrl) {
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, $4::uuid, 'high', 'open',
|
||||
'platform.tenant.status_updated', 'tenant', $4,
|
||||
'platform.worker_test.status_updated', 'tenant', $4,
|
||||
'租户状态变更告警', 'platform audit notification integration alert',
|
||||
'{"source":"worker-test","apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb,
|
||||
now(), now()
|
||||
@@ -153,7 +186,7 @@ async function seed(pool, webhookUrl) {
|
||||
values (
|
||||
'worker_platform_audit_test', 'Worker 平台审计通知', true, 'generic',
|
||||
$1, 'app_private.platform_secrets:webhook:worker_platform_audit_test',
|
||||
'medium', array['open']::text[], array['platform.tenant.*']::text[], 5
|
||||
'medium', array['open']::text[], array['platform.worker_test.*']::text[], 5
|
||||
)
|
||||
on conflict (channel_code)
|
||||
do update set enabled = excluded.enabled,
|
||||
|
||||
@@ -394,10 +394,47 @@ async function validateDatabase() {
|
||||
pass('db.provider_public_config', 'Active provider public configs do not contain secret-like keys');
|
||||
}
|
||||
|
||||
const activePlatformAdminRows = await pool.query(`
|
||||
select id, username, phone, auth_user_id
|
||||
from public.platform_users
|
||||
where primary_role = 'platform_admin'
|
||||
and status = 'active'
|
||||
order by created_at asc
|
||||
limit 20
|
||||
`);
|
||||
if (activePlatformAdminRows.rowCount === 0) {
|
||||
block('db.platform_admin_active', 'At least one active platform admin user is required');
|
||||
} else {
|
||||
pass('db.platform_admin_active', 'Active platform admin users found', { count: activePlatformAdminRows.rowCount });
|
||||
}
|
||||
|
||||
const activePlatformAdminsWithoutAuth = await pool.query(`
|
||||
select id, username, phone
|
||||
from public.platform_users
|
||||
where primary_role = 'platform_admin'
|
||||
and status = 'active'
|
||||
and auth_user_id is null
|
||||
order by created_at asc
|
||||
limit 20
|
||||
`);
|
||||
if (activePlatformAdminsWithoutAuth.rowCount > 0) {
|
||||
block('db.platform_admin_auth_binding', 'Active platform admin users must be bound to Supabase Auth users', {
|
||||
count: activePlatformAdminsWithoutAuth.rowCount,
|
||||
samples: activePlatformAdminsWithoutAuth.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_auth_binding', 'Active platform admin users are bound to Supabase Auth users');
|
||||
}
|
||||
|
||||
const platformAdminsWithoutPermissions = await pool.query(`
|
||||
select id, username, phone
|
||||
from public.platform_users
|
||||
where primary_role = 'platform_admin'
|
||||
and status = 'active'
|
||||
and (platform_permissions is null or platform_permissions = '{}'::jsonb)
|
||||
order by created_at asc
|
||||
limit 20
|
||||
@@ -415,6 +452,32 @@ async function validateDatabase() {
|
||||
pass('db.platform_admin_permissions', 'Platform admin users have explicit permission maps');
|
||||
}
|
||||
|
||||
const disabledPlatformAdminsWithActiveSessions = await pool.query(`
|
||||
select u.id, u.username, u.phone, count(s.id)::int as active_session_count
|
||||
from public.platform_users u
|
||||
join app_private.auth_sessions s on s.user_id = u.id
|
||||
where u.primary_role = 'platform_admin'
|
||||
and u.status = 'disabled'
|
||||
and s.revoked_at is null
|
||||
and s.expires_at > now()
|
||||
group by u.id, u.username, u.phone
|
||||
order by active_session_count desc
|
||||
limit 20
|
||||
`);
|
||||
if (disabledPlatformAdminsWithActiveSessions.rowCount > 0) {
|
||||
block('db.platform_admin_disabled_sessions', 'Disabled platform admin users must not have active legacy sessions', {
|
||||
count: disabledPlatformAdminsWithActiveSessions.rowCount,
|
||||
samples: disabledPlatformAdminsWithActiveSessions.rows.map(row => ({
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
phone: row.phone ? `${String(row.phone).slice(0, 3)}****${String(row.phone).slice(-4)}` : null,
|
||||
activeSessionCount: row.active_session_count,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
pass('db.platform_admin_disabled_sessions', 'Disabled platform admin users have no active legacy sessions');
|
||||
}
|
||||
|
||||
const missingAuthSecretRows = await pool.query(`
|
||||
select p.tenant_id, p.provider
|
||||
from public.tenant_auth_providers p
|
||||
|
||||
Reference in New Issue
Block a user