forked from wangziqi/gongxue-base
feat: add rls readiness gates
This commit is contained in:
@@ -83,6 +83,8 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小
|
|||||||
- 数据库 RLS 要按 `tenant_id` 拦截。
|
- 数据库 RLS 要按 `tenant_id` 拦截。
|
||||||
- API SQL 必须显式带 `tenant_id`。
|
- API SQL 必须显式带 `tenant_id`。
|
||||||
- 测试必须覆盖跨租户读取、写入、下载、后台权限越权。
|
- 测试必须覆盖跨租户读取、写入、下载、后台权限越权。
|
||||||
|
- `npm run readiness:production:db` 会阻断带 `tenant_id` 但未启用 RLS、没有 policy、或 public policy 未包含 `app.current_tenant_id()` 的表。
|
||||||
|
- 新增租户表时必须同时提交 migration、RLS policy、API 权限测试或明确说明只允许平台级访问的原因。
|
||||||
|
|
||||||
## 前端必须遵守
|
## 前端必须遵守
|
||||||
|
|
||||||
@@ -174,7 +176,7 @@ provider event id 幂等
|
|||||||
- `npm run audit:runtime` 为 0 high/critical 漏洞;Taro 构建工具链 audit 单独跟踪,不能用破坏性降级绕过。
|
- `npm run audit:runtime` 为 0 high/critical 漏洞;Taro 构建工具链 audit 单独跟踪,不能用破坏性降级绕过。
|
||||||
- `npm run check:refactor` 通过。
|
- `npm run check:refactor` 通过。
|
||||||
- `npm run readiness:production` 没有 blocker。
|
- `npm run readiness:production` 没有 blocker。
|
||||||
- `npm run readiness:production:db` 没有 blocker。
|
- `npm run readiness:production:db` 没有 blocker,尤其是 `db.rls.tenant_tables_enabled`、`db.rls.tenant_tables_policy`、`db.rls.public_tenant_context` 必须通过。
|
||||||
- 生产环境启动时默认密钥 fail-fast 生效。
|
- 生产环境启动时默认密钥 fail-fast 生效。
|
||||||
- 跨租户学生读取题目/订单/资料返回拒绝。
|
- 跨租户学生读取题目/订单/资料返回拒绝。
|
||||||
- 销售只能查看自己权限范围内客资。
|
- 销售只能查看自己权限范围内客资。
|
||||||
|
|||||||
@@ -141,6 +141,10 @@ function safeProviderName(provider) {
|
|||||||
return String(provider || '').replace(/[^a-zA-Z0-9_.:-]/g, '_');
|
return String(provider || '').replace(/[^a-zA-Z0-9_.:-]/g, '_');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTableName(row) {
|
||||||
|
return `${row.table_schema}.${row.table_name}`;
|
||||||
|
}
|
||||||
|
|
||||||
function validateEnv() {
|
function validateEnv() {
|
||||||
const nodeEnv = env('NODE_ENV', 'development');
|
const nodeEnv = env('NODE_ENV', 'development');
|
||||||
if (nodeEnv !== 'production') block('env.node_env', 'NODE_ENV must be production for production readiness checks');
|
if (nodeEnv !== 'production') block('env.node_env', 'NODE_ENV must be production for production readiness checks');
|
||||||
@@ -418,6 +422,82 @@ async function validateDatabase() {
|
|||||||
const unverifiedDomains = Number(unverifiedDomainRows.rows[0]?.count || 0);
|
const unverifiedDomains = Number(unverifiedDomainRows.rows[0]?.count || 0);
|
||||||
if (unverifiedDomains > 0) warn('db.tenant_domains', 'Some tenant domains are not active/verified', { count: unverifiedDomains });
|
if (unverifiedDomains > 0) warn('db.tenant_domains', 'Some tenant domains are not active/verified', { count: unverifiedDomains });
|
||||||
else pass('db.tenant_domains', 'Tenant domains are active/verified or not configured');
|
else pass('db.tenant_domains', 'Tenant domains are active/verified or not configured');
|
||||||
|
|
||||||
|
const tenantTablesWithoutRls = await pool.query(`
|
||||||
|
select c.table_schema, c.table_name
|
||||||
|
from information_schema.columns c
|
||||||
|
join pg_class cls on cls.relname = c.table_name
|
||||||
|
join pg_namespace ns on ns.oid = cls.relnamespace and ns.nspname = c.table_schema
|
||||||
|
where c.column_name = 'tenant_id'
|
||||||
|
and c.table_schema in ('public', 'app_private')
|
||||||
|
and cls.relkind in ('r', 'p')
|
||||||
|
and not cls.relrowsecurity
|
||||||
|
order by c.table_schema, c.table_name
|
||||||
|
`);
|
||||||
|
if (tenantTablesWithoutRls.rowCount > 0) {
|
||||||
|
block('db.rls.tenant_tables_enabled', 'Every tenant-scoped table must have row level security enabled', {
|
||||||
|
count: tenantTablesWithoutRls.rowCount,
|
||||||
|
samples: tenantTablesWithoutRls.rows.slice(0, 10).map(formatTableName),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pass('db.rls.tenant_tables_enabled', 'All tenant-scoped tables have RLS enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantTablesWithoutPolicies = await pool.query(`
|
||||||
|
select c.table_schema, c.table_name
|
||||||
|
from information_schema.columns c
|
||||||
|
join pg_class cls on cls.relname = c.table_name
|
||||||
|
join pg_namespace ns on ns.oid = cls.relnamespace and ns.nspname = c.table_schema
|
||||||
|
where c.column_name = 'tenant_id'
|
||||||
|
and c.table_schema in ('public', 'app_private')
|
||||||
|
and cls.relkind in ('r', 'p')
|
||||||
|
and not exists (
|
||||||
|
select 1
|
||||||
|
from pg_policy p
|
||||||
|
where p.polrelid = cls.oid
|
||||||
|
)
|
||||||
|
order by c.table_schema, c.table_name
|
||||||
|
`);
|
||||||
|
if (tenantTablesWithoutPolicies.rowCount > 0) {
|
||||||
|
block('db.rls.tenant_tables_policy', 'Every tenant-scoped table must have at least one RLS policy', {
|
||||||
|
count: tenantTablesWithoutPolicies.rowCount,
|
||||||
|
samples: tenantTablesWithoutPolicies.rows.slice(0, 10).map(formatTableName),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pass('db.rls.tenant_tables_policy', 'All tenant-scoped tables have at least one RLS policy');
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicTenantTablesWithoutTenantContextPolicy = await pool.query(`
|
||||||
|
select c.table_schema, c.table_name
|
||||||
|
from information_schema.columns c
|
||||||
|
join pg_class cls on cls.relname = c.table_name
|
||||||
|
join pg_namespace ns on ns.oid = cls.relnamespace and ns.nspname = c.table_schema
|
||||||
|
where c.column_name = 'tenant_id'
|
||||||
|
and c.table_schema = 'public'
|
||||||
|
and cls.relkind in ('r', 'p')
|
||||||
|
and not exists (
|
||||||
|
select 1
|
||||||
|
from pg_policy p
|
||||||
|
where p.polrelid = cls.oid
|
||||||
|
and (
|
||||||
|
lower(pg_get_expr(p.polqual, p.polrelid)) like '%app.current_tenant_id()%'
|
||||||
|
or lower(pg_get_expr(p.polwithcheck, p.polrelid)) like '%app.current_tenant_id()%'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order by c.table_schema, c.table_name
|
||||||
|
`);
|
||||||
|
if (publicTenantTablesWithoutTenantContextPolicy.rowCount > 0) {
|
||||||
|
block(
|
||||||
|
'db.rls.public_tenant_context',
|
||||||
|
'Every public tenant-scoped table must include app.current_tenant_id() in an RLS policy',
|
||||||
|
{
|
||||||
|
count: publicTenantTablesWithoutTenantContextPolicy.rowCount,
|
||||||
|
samples: publicTenantTablesWithoutTenantContextPolicy.rows.slice(0, 10).map(formatTableName),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
pass('db.rls.public_tenant_context', 'Public tenant-scoped tables include tenant context in RLS policies');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await pool.end();
|
await pool.end();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user