forked from wangziqi/gongxue-base
170 lines
6.7 KiB
JavaScript
170 lines
6.7 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
const repoRoot = process.cwd();
|
|
const migrationPath = path.join(
|
|
repoRoot,
|
|
'supabase',
|
|
'migrations',
|
|
'202607110001_data_api_acl_rls_hardening.sql',
|
|
);
|
|
const authBoundaryMigrationPath = path.join(
|
|
repoRoot,
|
|
'supabase',
|
|
'migrations',
|
|
'202607120018_auth_user_reference_boundary.sql',
|
|
);
|
|
const readinessPath = path.join(repoRoot, 'scripts', 'production-readiness-check.js');
|
|
const privilegedBootstrapPath = path.join(
|
|
repoRoot,
|
|
'scripts',
|
|
'deploy',
|
|
'sql',
|
|
'bootstrap-backend-runtime-roles.sql',
|
|
);
|
|
const packagePath = path.join(repoRoot, 'package.json');
|
|
const taroSourceRoot = path.join(repoRoot, 'apps', 'taro', 'src');
|
|
|
|
function read(filePath) {
|
|
return fs.readFileSync(filePath, 'utf8');
|
|
}
|
|
|
|
function walk(dir) {
|
|
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
|
const filePath = path.join(dir, entry.name);
|
|
return entry.isDirectory() ? walk(filePath) : [filePath];
|
|
});
|
|
}
|
|
|
|
const migration = read(migrationPath);
|
|
const authBoundaryMigration = read(authBoundaryMigrationPath);
|
|
const privilegedBootstrap = read(privilegedBootstrapPath);
|
|
assert.match(
|
|
privilegedBootstrap,
|
|
/revoke execute on all functions in schema public[\s\S]*from public, anon, authenticated, tiku_api, tiku_worker/i,
|
|
'the privileged bootstrap must close Supabase base-image extension RPC execution',
|
|
);
|
|
assert.match(
|
|
privilegedBootstrap,
|
|
/alter default privileges for role %I revoke execute on functions from public, anon, authenticated, tiku_api, tiku_worker/i,
|
|
'the privileged bootstrap must keep future extension-owner functions closed',
|
|
);
|
|
assert.match(
|
|
migration,
|
|
/revoke all privileges on all tables in schema public from public, anon, authenticated/i,
|
|
'existing public tables must not be exposed to client Data API roles',
|
|
);
|
|
assert.match(
|
|
authBoundaryMigration,
|
|
/revoke all on function app\.auth_user_exists\(uuid\)[\s\S]*from public, anon, authenticated, service_role, tiku_api, tiku_worker/i,
|
|
'the Auth existence boundary must be denied to every Data API role before the API-only grant',
|
|
);
|
|
assert.doesNotMatch(
|
|
authBoundaryMigration,
|
|
/grant execute on function app\.auth_user_exists\(uuid\) to (?:anon|authenticated|service_role)/i,
|
|
'the Auth existence boundary must never be exposed through PostgREST roles',
|
|
);
|
|
assert.match(
|
|
migration,
|
|
/revoke all privileges on all sequences in schema public from public, anon, authenticated/i,
|
|
'existing public sequences must not be exposed to client Data API roles',
|
|
);
|
|
assert.match(
|
|
migration,
|
|
/revoke all privileges on all functions in schema public from public, anon, authenticated/i,
|
|
'existing public RPC functions must not be exposed to client Data API roles',
|
|
);
|
|
assert.match(
|
|
migration,
|
|
/public functions remain executable by anon\/authenticated; run the privileged backend runtime role bootstrap before migrations/i,
|
|
'normal migrations must fail closed when Supabase-owned extension functions remain exposed',
|
|
);
|
|
assert.match(
|
|
migration,
|
|
/alter default privileges for role %I in schema public revoke all privileges on tables from public, anon, authenticated/i,
|
|
'future public tables must default to no client Data API grant',
|
|
);
|
|
assert.match(
|
|
migration,
|
|
/alter default privileges for role %I in schema public revoke all privileges on sequences from public, anon, authenticated/i,
|
|
'future public sequences must default to no client Data API grant',
|
|
);
|
|
assert.match(
|
|
migration,
|
|
/revoke create on schema public from public, anon, authenticated/i,
|
|
'client roles must not create objects in the exposed public schema',
|
|
);
|
|
assert.match(
|
|
migration,
|
|
/alter default privileges for role %I revoke execute on functions from public, anon, authenticated/i,
|
|
'future public RPC functions must require an explicit execute grant',
|
|
);
|
|
assert.match(
|
|
migration,
|
|
/select distinct owner_role\.rolname[\s\S]*pg_has_role\(current_user, owner_role\.oid, 'MEMBER'\)/i,
|
|
'default privileges must cover every public owner the migration role is authorized to manage',
|
|
);
|
|
assert.match(migration, /drop policy if exists platform_admin_platform_users/i);
|
|
assert.match(
|
|
migration,
|
|
/create policy platform_users_self_read[\s\S]*for select[\s\S]*to authenticated[\s\S]*auth_user_id\s*=\s*\(select auth\.uid\(\)\)/i,
|
|
'platform_users may expose only an explicit self-read policy to authenticated users',
|
|
);
|
|
assert.doesNotMatch(
|
|
migration,
|
|
/create policy [^;]+ on public\.platform_users[\s\S]*?for\s+(?:all|insert|update|delete)/i,
|
|
'platform_users must not have a client write policy',
|
|
);
|
|
assert.match(
|
|
migration,
|
|
/create or replace function app\.is_platform_admin\(\)[\s\S]*security definer[\s\S]*set search_path = ''[\s\S]*from public\.platform_users[\s\S]*status = 'active'/i,
|
|
'RLS platform authority must come from an active database identity',
|
|
);
|
|
assert.doesNotMatch(
|
|
migration,
|
|
/select\s+app\.current_role\(\)\s+in\s*\([^)]*platform_admin/i,
|
|
'a platform_admin JWT role claim must not be sufficient for RLS authority',
|
|
);
|
|
|
|
const readiness = read(readinessPath);
|
|
for (const gateId of [
|
|
'db.data_api.public_table_acl',
|
|
'db.data_api.public_sequence_acl',
|
|
'db.data_api.public_function_acl',
|
|
'db.data_api.public_default_acl',
|
|
'db.data_api.platform_users_write_policy',
|
|
'db.rls.platform_admin_authority',
|
|
]) {
|
|
assert.ok(readiness.includes(gateId), `production database readiness must include ${gateId}`);
|
|
}
|
|
|
|
const sdkImportViolations = [];
|
|
const dataApiCallViolations = [];
|
|
for (const filePath of walk(taroSourceRoot).filter(file => /\.(?:ts|tsx)$/.test(file))) {
|
|
const source = read(filePath);
|
|
const relative = path.relative(repoRoot, filePath).replace(/\\/g, '/');
|
|
if (source.includes('@supabase/supabase-js') && relative !== 'apps/taro/src/services/supabase.ts') {
|
|
sdkImportViolations.push(relative);
|
|
}
|
|
if (!source.includes('ensureSupabaseClient') && !source.includes('getSupabaseClient') && !source.includes('@supabase/supabase-js')) {
|
|
continue;
|
|
}
|
|
for (const match of source.matchAll(/\b([A-Za-z_$][\w$]*)\.(from|rpc)\s*\(/g)) {
|
|
if (match[1] !== 'Array' && match[1] !== 'Buffer') {
|
|
dataApiCallViolations.push(`${relative}:${match[0]}`);
|
|
}
|
|
}
|
|
}
|
|
assert.deepEqual(sdkImportViolations, [], 'Supabase SDK ownership must stay centralized in services/supabase.ts');
|
|
assert.deepEqual(dataApiCallViolations, [], 'Taro must not access business tables or RPCs through the Data API');
|
|
|
|
const rootPackage = JSON.parse(read(packagePath));
|
|
assert.ok(rootPackage.scripts?.['test:data-api:security'], 'root package must expose the Data API security contract test');
|
|
assert.ok(
|
|
rootPackage.scripts?.['test:readiness']?.includes('data-api-security-contract-test.js'),
|
|
'the production readiness contract suite must run the Data API security test',
|
|
);
|
|
|
|
console.log('[PASS] Supabase Data API deny-by-default security contract');
|