@soulbatical/tetra-dev-toolkit 1.20.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +235 -238
- package/bin/tetra-setup.js +2 -172
- package/lib/checks/health/index.js +0 -1
- package/lib/checks/health/scanner.js +1 -3
- package/lib/checks/health/types.js +1 -1
- package/lib/checks/hygiene/stella-compliance.js +2 -2
- package/lib/checks/security/deprecated-supabase-admin.js +6 -15
- package/lib/checks/security/direct-supabase-client.js +4 -22
- package/lib/checks/security/frontend-supabase-queries.js +1 -1
- package/lib/checks/security/hardcoded-secrets.js +2 -5
- package/lib/checks/security/systemdb-whitelist.js +27 -116
- package/lib/config.js +1 -7
- package/lib/runner.js +7 -120
- package/package.json +2 -7
- package/bin/tetra-check-peers.js +0 -359
- package/bin/tetra-db-push.js +0 -91
- package/bin/tetra-migration-lint.js +0 -317
- package/bin/tetra-security-gate.js +0 -293
- package/bin/tetra-smoke.js +0 -532
- package/lib/checks/health/smoke-readiness.js +0 -150
- package/lib/checks/security/config-rls-alignment.js +0 -637
- package/lib/checks/security/mixed-db-usage.js +0 -204
- package/lib/checks/security/rls-live-audit.js +0 -255
- package/lib/checks/security/route-config-alignment.js +0 -342
- package/lib/checks/security/rpc-security-mode.js +0 -175
- package/lib/checks/security/tetra-core-compliance.js +0 -197
|
@@ -1,637 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Config ↔ RLS Alignment Check — HARD BLOCK
|
|
3
|
-
*
|
|
4
|
-
* Verifies that feature config files and RLS policies are in 1:1 alignment:
|
|
5
|
-
*
|
|
6
|
-
* For each feature config:
|
|
7
|
-
* 1. tableName must have RLS enabled
|
|
8
|
-
* 2. accessLevel must match RLS policy patterns:
|
|
9
|
-
* - 'admin' → USING(organization_id = auth_org_id()) — org-scoped
|
|
10
|
-
* - 'user' → USING(user_id = auth.uid()) — user-scoped
|
|
11
|
-
* - 'creator' → USING(user_id = auth.uid() OR visibility...) — sandboxed
|
|
12
|
-
* - 'public' → USING(true) or USING(is_published = true) — open read
|
|
13
|
-
* - 'system' → No frontend access, backend-only (must be in backendOnlyTables)
|
|
14
|
-
* 3. RPC functions must be SECURITY INVOKER (not DEFINER) unless whitelisted
|
|
15
|
-
* 4. Every config table must have SELECT, INSERT, UPDATE, DELETE policies
|
|
16
|
-
* 5. No USING(true) on non-public tables
|
|
17
|
-
*
|
|
18
|
-
* This is the "golden check" — if config says admin, RLS MUST enforce org isolation.
|
|
19
|
-
*
|
|
20
|
-
* Reference: stella_howto_get slug="tetra-architecture-guide"
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { readFileSync, existsSync, readdirSync } from 'fs'
|
|
24
|
-
import { join } from 'path'
|
|
25
|
-
import { globSync } from 'glob'
|
|
26
|
-
|
|
27
|
-
export const meta = {
|
|
28
|
-
id: 'config-rls-alignment',
|
|
29
|
-
name: 'Config ↔ RLS Alignment',
|
|
30
|
-
category: 'security',
|
|
31
|
-
severity: 'critical',
|
|
32
|
-
description: 'Verifies that feature config accessLevel matches actual RLS policies on each table. Uses live DB when available, falls back to migration parsing.'
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Parse all feature configs to extract table → accessLevel mapping
|
|
37
|
-
*/
|
|
38
|
-
function parseFeatureConfigs(projectRoot) {
|
|
39
|
-
const configs = new Map() // tableName → { accessLevel, configFile, organizationIdField, rpcFunctions }
|
|
40
|
-
|
|
41
|
-
const configFiles = [
|
|
42
|
-
...findFiles(projectRoot, 'backend/src/features/**/config/*.config.ts'),
|
|
43
|
-
...findFiles(projectRoot, 'src/features/**/config/*.config.ts')
|
|
44
|
-
]
|
|
45
|
-
|
|
46
|
-
for (const file of configFiles) {
|
|
47
|
-
let content
|
|
48
|
-
try { content = readFileSync(file, 'utf-8') } catch { continue }
|
|
49
|
-
|
|
50
|
-
// Extract tableName
|
|
51
|
-
const tableMatch = content.match(/tableName:\s*['"]([^'"]+)['"]/)
|
|
52
|
-
if (!tableMatch) continue
|
|
53
|
-
|
|
54
|
-
const tableName = tableMatch[1]
|
|
55
|
-
|
|
56
|
-
// Extract accessLevel (default: 'admin')
|
|
57
|
-
const accessMatch = content.match(/accessLevel:\s*['"]([^'"]+)['"]/)
|
|
58
|
-
const accessLevel = accessMatch ? accessMatch[1] : 'admin'
|
|
59
|
-
|
|
60
|
-
// Extract organizationIdField
|
|
61
|
-
const orgFieldMatch = content.match(/organizationIdField:\s*['"]([^'"]+)['"]/)
|
|
62
|
-
const organizationIdField = orgFieldMatch ? orgFieldMatch[1] : 'organization_id'
|
|
63
|
-
|
|
64
|
-
// Extract RPC function names
|
|
65
|
-
const rpcFunctions = []
|
|
66
|
-
const countsRpc = content.match(/countsRpcName:\s*['"]([^'"]+)['"]/)
|
|
67
|
-
const resultsRpc = content.match(/resultsRpcName:\s*['"]([^'"]+)['"]/)
|
|
68
|
-
const detailRpc = content.match(/detailRpcName:\s*['"]([^'"]+)['"]/)
|
|
69
|
-
if (countsRpc) rpcFunctions.push(countsRpc[1])
|
|
70
|
-
if (resultsRpc) rpcFunctions.push(resultsRpc[1])
|
|
71
|
-
if (detailRpc) rpcFunctions.push(detailRpc[1])
|
|
72
|
-
|
|
73
|
-
// Extract creatorVisibility
|
|
74
|
-
const creatorVis = content.match(/creatorVisibility:\s*\{/)
|
|
75
|
-
const hasCreatorVisibility = !!creatorVis
|
|
76
|
-
|
|
77
|
-
configs.set(tableName, {
|
|
78
|
-
accessLevel,
|
|
79
|
-
configFile: file.replace(projectRoot + '/', ''),
|
|
80
|
-
organizationIdField,
|
|
81
|
-
rpcFunctions,
|
|
82
|
-
hasCreatorVisibility
|
|
83
|
-
})
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return configs
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Parse all SQL migrations to extract RLS info per table
|
|
91
|
-
*/
|
|
92
|
-
function parseMigrations(projectRoot) {
|
|
93
|
-
const tables = new Map() // tableName → { rlsEnabled, policies: [], rpcFunctions: Map }
|
|
94
|
-
|
|
95
|
-
const migrationDirs = [
|
|
96
|
-
join(projectRoot, 'supabase/migrations'),
|
|
97
|
-
join(projectRoot, 'backend/supabase/migrations')
|
|
98
|
-
]
|
|
99
|
-
|
|
100
|
-
for (const dir of migrationDirs) {
|
|
101
|
-
if (!existsSync(dir)) continue
|
|
102
|
-
|
|
103
|
-
let sqlFiles
|
|
104
|
-
try {
|
|
105
|
-
sqlFiles = findFiles(projectRoot, dir.replace(projectRoot + '/', '') + '/*.sql')
|
|
106
|
-
} catch { continue }
|
|
107
|
-
|
|
108
|
-
// Sort migrations by filename (timestamp order) so later migrations override earlier ones
|
|
109
|
-
sqlFiles.sort()
|
|
110
|
-
|
|
111
|
-
for (const file of sqlFiles) {
|
|
112
|
-
let content
|
|
113
|
-
try { content = readFileSync(file, 'utf-8') } catch { continue }
|
|
114
|
-
|
|
115
|
-
const relFile = file.replace(projectRoot + '/', '')
|
|
116
|
-
|
|
117
|
-
// Handle DROP POLICY — removes policy from earlier migration
|
|
118
|
-
const dropPolicyMatches = content.matchAll(/DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)?"?([^";\s]+)"?\s+ON\s+(?:public\.)?(\w+)/gi)
|
|
119
|
-
for (const m of dropPolicyMatches) {
|
|
120
|
-
const policyName = m[1]
|
|
121
|
-
const table = m[2]
|
|
122
|
-
if (tables.has(table)) {
|
|
123
|
-
tables.get(table).policies = tables.get(table).policies.filter(p => p.name !== policyName)
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Handle ALTER FUNCTION ... SECURITY INVOKER/DEFINER — overrides earlier CREATE FUNCTION
|
|
128
|
-
const alterFuncMatches = content.matchAll(/ALTER\s+FUNCTION\s+(?:public\.)?(\w+)(?:\s*\([^)]*\))?\s+SECURITY\s+(INVOKER|DEFINER)/gi)
|
|
129
|
-
for (const m of alterFuncMatches) {
|
|
130
|
-
const funcName = m[1]
|
|
131
|
-
const securityMode = m[2].toUpperCase()
|
|
132
|
-
// Update all tables that reference this function
|
|
133
|
-
for (const [, tableInfo] of tables) {
|
|
134
|
-
if (tableInfo.rpcFunctions.has(funcName)) {
|
|
135
|
-
tableInfo.rpcFunctions.get(funcName).securityMode = securityMode
|
|
136
|
-
tableInfo.rpcFunctions.get(funcName).file = relFile
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Handle DISABLE RLS — overrides earlier ENABLE
|
|
142
|
-
const disableRlsMatches = content.matchAll(/ALTER\s+TABLE\s+(?:public\.)?(\w+)\s+DISABLE\s+ROW\s+LEVEL\s+SECURITY/gi)
|
|
143
|
-
for (const m of disableRlsMatches) {
|
|
144
|
-
const table = m[1]
|
|
145
|
-
if (tables.has(table)) tables.get(table).rlsEnabled = false
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Find RLS enables
|
|
149
|
-
const rlsMatches = content.matchAll(/ALTER\s+TABLE\s+(?:public\.)?(\w+)\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY/gi)
|
|
150
|
-
for (const m of rlsMatches) {
|
|
151
|
-
const table = m[1]
|
|
152
|
-
if (!tables.has(table)) tables.set(table, { rlsEnabled: true, policies: [], rpcFunctions: new Map() })
|
|
153
|
-
else tables.get(table).rlsEnabled = true
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Find policies
|
|
157
|
-
const policyRegex = /CREATE\s+POLICY\s+"?([^"]+)"?\s+ON\s+(?:public\.)?(\w+)\s*([\s\S]*?)(?=CREATE\s+POLICY|CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION|ALTER\s+TABLE|CREATE\s+(?:UNIQUE\s+)?INDEX|GRANT|$)/gi
|
|
158
|
-
for (const m of content.matchAll(policyRegex)) {
|
|
159
|
-
const policyName = m[1]
|
|
160
|
-
const table = m[2]
|
|
161
|
-
const body = m[3] || ''
|
|
162
|
-
|
|
163
|
-
if (!tables.has(table)) tables.set(table, { rlsEnabled: false, policies: [], rpcFunctions: new Map() })
|
|
164
|
-
|
|
165
|
-
// Determine operation
|
|
166
|
-
let operation = 'ALL'
|
|
167
|
-
const forMatch = body.match(/FOR\s+(SELECT|INSERT|UPDATE|DELETE|ALL)/i)
|
|
168
|
-
if (forMatch) operation = forMatch[1].toUpperCase()
|
|
169
|
-
|
|
170
|
-
// Extract USING clause
|
|
171
|
-
const usingMatch = body.match(/USING\s*\(([\s\S]*?)(?:\)\s*(?:WITH|;|$)|\)$)/i)
|
|
172
|
-
const using = usingMatch ? usingMatch[1].trim() : ''
|
|
173
|
-
|
|
174
|
-
// Extract WITH CHECK
|
|
175
|
-
const withCheckMatch = body.match(/WITH\s+CHECK\s*\(([\s\S]*?)(?:\)\s*;|\)$)/i)
|
|
176
|
-
const withCheck = withCheckMatch ? withCheckMatch[1].trim() : ''
|
|
177
|
-
|
|
178
|
-
tables.get(table).policies.push({
|
|
179
|
-
name: policyName,
|
|
180
|
-
operation,
|
|
181
|
-
using,
|
|
182
|
-
withCheck,
|
|
183
|
-
file: relFile
|
|
184
|
-
})
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Find PL/pgSQL loop-based CREATE POLICY patterns (DO blocks with EXECUTE format)
|
|
188
|
-
// Handles two patterns:
|
|
189
|
-
// Pattern A: FOR t IN SELECT unnest(ARRAY['table1','table2']) LOOP ... EXECUTE format('CREATE POLICY ...')
|
|
190
|
-
// Pattern B: tables TEXT[] := ARRAY['table1','table2']; FOREACH tbl IN ARRAY tables LOOP ... EXECUTE format('CREATE POLICY ...')
|
|
191
|
-
if (/DO\s+\$/.test(content) && /EXECUTE\s+format\s*\(\s*'CREATE\s+POLICY/i.test(content)) {
|
|
192
|
-
// Try unnest pattern first, then FOREACH/variable pattern
|
|
193
|
-
const arrayMatch = content.match(/unnest\s*\(\s*ARRAY\s*\[\s*'([^[\]]+)'\s*\]/i)
|
|
194
|
-
|| content.match(/(?:TEXT\[\]|text\[\])\s*:=\s*ARRAY\s*\[\s*'([^[\]]+)'\s*\]/i)
|
|
195
|
-
if (arrayMatch) {
|
|
196
|
-
const loopTables = arrayMatch[1].split(/'\s*,\s*'/).map(t => t.trim())
|
|
197
|
-
// Match EXECUTE format('CREATE POLICY ...', ...) — multi-line, with escaped quotes ('')
|
|
198
|
-
// PL/pgSQL escapes single quotes as '' inside strings, so we must allow '' within the match
|
|
199
|
-
// The format string ends with a single ' (not '') followed by , or ;
|
|
200
|
-
const execLines = [...content.matchAll(/EXECUTE\s+format\s*\(\s*'(CREATE\s+POLICY(?:[^']|'')*?)'\s*,/gi)]
|
|
201
|
-
for (const exec of execLines) {
|
|
202
|
-
// Unescape PL/pgSQL doubled quotes back to single quotes for analysis
|
|
203
|
-
const stmt = exec[1].replace(/''/g, "'")
|
|
204
|
-
const forOp = stmt.match(/FOR\s+(SELECT|INSERT|UPDATE|DELETE|ALL)/i)
|
|
205
|
-
const operation = forOp ? forOp[1].toUpperCase() : 'ALL'
|
|
206
|
-
const hasUsing = /\bUSING\b/i.test(stmt)
|
|
207
|
-
const hasWithCheck = /WITH\s+CHECK/i.test(stmt)
|
|
208
|
-
|
|
209
|
-
// Extract the full USING/WITH CHECK clause (may be multi-line with nested parens)
|
|
210
|
-
let usingCondition = ''
|
|
211
|
-
let withCheckCondition = ''
|
|
212
|
-
if (hasUsing) {
|
|
213
|
-
const uMatch = stmt.match(/USING\s*\(\s*([\s\S]*?)\s*\)\s*(?:WITH\s+CHECK|$)/i)
|
|
214
|
-
|| stmt.match(/USING\s*\(\s*([\s\S]*?)\s*\)\s*$/i)
|
|
215
|
-
usingCondition = uMatch ? uMatch[1].trim() : ''
|
|
216
|
-
}
|
|
217
|
-
if (hasWithCheck) {
|
|
218
|
-
const wcMatch = stmt.match(/WITH\s+CHECK\s*\(\s*([\s\S]*?)\s*\)\s*$/i)
|
|
219
|
-
withCheckCondition = wcMatch ? wcMatch[1].trim() : ''
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
for (const table of loopTables) {
|
|
223
|
-
if (!tables.has(table)) tables.set(table, { rlsEnabled: false, policies: [], rpcFunctions: new Map() })
|
|
224
|
-
const policyName = `${table}_${operation.toLowerCase()}_org`
|
|
225
|
-
if (!tables.get(table).policies.find(p => p.name === policyName)) {
|
|
226
|
-
tables.get(table).policies.push({
|
|
227
|
-
name: policyName,
|
|
228
|
-
operation,
|
|
229
|
-
using: usingCondition,
|
|
230
|
-
withCheck: withCheckCondition,
|
|
231
|
-
file: relFile
|
|
232
|
-
})
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Find RPC functions and their security mode
|
|
240
|
-
const funcRegex = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(?:public\.)?(\w+)\s*\(([\s\S]*?)\)\s*RETURNS\s+([\s\S]*?)(?:LANGUAGE|AS)/gi
|
|
241
|
-
for (const m of content.matchAll(funcRegex)) {
|
|
242
|
-
const funcName = m[1]
|
|
243
|
-
const funcBody = content.substring(m.index, m.index + 2000)
|
|
244
|
-
|
|
245
|
-
const isDefiner = /SECURITY\s+DEFINER/i.test(funcBody)
|
|
246
|
-
const isInvoker = /SECURITY\s+INVOKER/i.test(funcBody)
|
|
247
|
-
|
|
248
|
-
// Find which tables this function touches
|
|
249
|
-
for (const [table] of tables) {
|
|
250
|
-
if (funcBody.includes(table)) {
|
|
251
|
-
tables.get(table).rpcFunctions.set(funcName, {
|
|
252
|
-
securityMode: isDefiner ? 'DEFINER' : isInvoker ? 'INVOKER' : 'DEFAULT',
|
|
253
|
-
file: relFile
|
|
254
|
-
})
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
return tables
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function findFiles(projectRoot, pattern) {
|
|
266
|
-
try {
|
|
267
|
-
return globSync(pattern, { cwd: projectRoot, absolute: true, ignore: ['**/node_modules/**'] })
|
|
268
|
-
} catch {
|
|
269
|
-
return []
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Check if a USING clause enforces org isolation
|
|
275
|
-
*/
|
|
276
|
-
function isOrgIsolation(using) {
|
|
277
|
-
const hasOrgFunction = /auth_org_id\(\)|auth_admin_organizations\(\)/i.test(using)
|
|
278
|
-
// Legacy pattern: auth.jwt() -> 'app_metadata' ->> 'organization_id'
|
|
279
|
-
const hasLegacyJwtOrg = /auth\.jwt\(\)\s*->\s*'app_metadata'\s*->>\s*'organization_id'/i.test(using)
|
|
280
|
-
return (hasOrgFunction || hasLegacyJwtOrg) && /organization_id/i.test(using)
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Check if a USING clause enforces user isolation
|
|
285
|
-
*/
|
|
286
|
-
function isUserIsolation(using) {
|
|
287
|
-
return /auth\.uid\(\)/i.test(using) &&
|
|
288
|
-
/user_id|created_by|owner_id/i.test(using)
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Check if a USING clause is wide open
|
|
293
|
-
*/
|
|
294
|
-
function isWideOpen(using) {
|
|
295
|
-
return using.trim() === 'true' || using.trim() === '(true)'
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Allowed RLS policy patterns — whitelist approach.
|
|
300
|
-
*
|
|
301
|
-
* Derived from sparkbuddy-live production DB (562 policies analyzed).
|
|
302
|
-
* These are the ONLY structural patterns allowed in USING/WITH CHECK clauses.
|
|
303
|
-
* Everything else is rejected. To add a new pattern: add it here with justification.
|
|
304
|
-
*
|
|
305
|
-
* Categories:
|
|
306
|
-
* 1. Org isolation: auth_admin_organizations(), auth_user_organizations(), auth_org_id()
|
|
307
|
-
* 2. User isolation: auth.uid(), auth_current_user_id()
|
|
308
|
-
* 3. Role gates: auth.role() = 'authenticated' or 'anon' (NOT 'service_role')
|
|
309
|
-
* 4. Data filters: column = literal, IS NULL, boolean checks
|
|
310
|
-
* 5. Parent checks: IN (SELECT ...), EXISTS (SELECT ...)
|
|
311
|
-
* 6. Open access: true, false
|
|
312
|
-
* 7. Legacy: auth.jwt() -> 'app_metadata', current_setting('app.*')
|
|
313
|
-
*/
|
|
314
|
-
const ALLOWED_RLS_PATTERNS = [
|
|
315
|
-
// 1. Org isolation helper functions
|
|
316
|
-
{ pattern: /auth_admin_organizations\s*\(\)/i, label: 'org-admin isolation' },
|
|
317
|
-
{ pattern: /auth_user_organizations\s*\(\)/i, label: 'org-user isolation' },
|
|
318
|
-
{ pattern: /auth_org_id\s*\(\)/i, label: 'org isolation' },
|
|
319
|
-
|
|
320
|
-
// 2. User isolation
|
|
321
|
-
{ pattern: /auth\.uid\s*\(\)/i, label: 'user isolation' },
|
|
322
|
-
{ pattern: /auth_current_user_id\s*\(\)/i, label: 'user isolation' },
|
|
323
|
-
|
|
324
|
-
// 3. Role gates (ONLY authenticated and anon — never service_role)
|
|
325
|
-
{ pattern: /auth\.role\s*\(\)\s*=\s*'authenticated'/i, label: 'authenticated role gate' },
|
|
326
|
-
{ pattern: /auth\.role\s*\(\)\s*=\s*'anon'/i, label: 'anon role gate' },
|
|
327
|
-
|
|
328
|
-
// 4. Column comparisons and data filters (any column = any value is fine)
|
|
329
|
-
// This is inherently safe — it filters data, doesn't bypass auth
|
|
330
|
-
{ pattern: /\w+\s*=\s*/i, label: 'column comparison' },
|
|
331
|
-
{ pattern: /\w+\s+IS\s+(NOT\s+)?NULL/i, label: 'null check' },
|
|
332
|
-
{ pattern: /\w+\s+IN\s*\(/i, label: 'IN check' },
|
|
333
|
-
{ pattern: /\w+\s*=\s*ANY\s*\(/i, label: 'ANY check' },
|
|
334
|
-
|
|
335
|
-
// 5. Parent table checks
|
|
336
|
-
{ pattern: /EXISTS\s*\(\s*SELECT/i, label: 'exists subquery' },
|
|
337
|
-
|
|
338
|
-
// 6. Open access
|
|
339
|
-
{ pattern: /^\s*true\s*$/i, label: 'public access' },
|
|
340
|
-
{ pattern: /^\s*\(true\)\s*$/i, label: 'public access' },
|
|
341
|
-
{ pattern: /^\s*false\s*$/i, label: 'deny all' },
|
|
342
|
-
|
|
343
|
-
// 7. Legacy JWT and app context
|
|
344
|
-
{ pattern: /auth\.jwt\s*\(\)/i, label: 'legacy JWT' },
|
|
345
|
-
{ pattern: /current_setting\s*\(\s*'app\./i, label: 'app context' },
|
|
346
|
-
|
|
347
|
-
// 8. Custom helper functions (e.g. is_org_member(), is_product_publicly_accessible())
|
|
348
|
-
// These are project-specific SECURITY DEFINER helpers — safe as long as
|
|
349
|
-
// the function itself is audited (which is done by the RPC Security Mode check)
|
|
350
|
-
{ pattern: /\b\w+\s*\([^)]*\)/i, label: 'function call' },
|
|
351
|
-
]
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* Patterns BANNED from RLS policies — these bypass tenant isolation.
|
|
355
|
-
* Service role already bypasses RLS at the Supabase layer automatically.
|
|
356
|
-
* Adding these to policies creates a false sense of security and opens
|
|
357
|
-
* cross-tenant data leakage vectors.
|
|
358
|
-
*
|
|
359
|
-
* Derived from sparkbuddy-live analysis: 2 policies with auth.role()='service_role'
|
|
360
|
-
* were identified as tech debt (redirects, translations) — not a pattern to follow.
|
|
361
|
-
*/
|
|
362
|
-
const BANNED_RLS_PATTERNS = [
|
|
363
|
-
{ pattern: /service_role/i, label: 'service_role bypass — service role already bypasses RLS at the Supabase layer' },
|
|
364
|
-
{ pattern: /auth\.role\s*\(\)\s*=\s*'service_role'/i, label: 'auth.role() service_role bypass — service role already bypasses RLS automatically' },
|
|
365
|
-
{ pattern: /current_setting\s*\(\s*'role'/i, label: 'PostgreSQL role check — bypasses tenant isolation' },
|
|
366
|
-
{ pattern: /current_setting\s*\(\s*'request\.jwt\.claims'/i, label: 'JWT claims role check — bypasses tenant isolation' },
|
|
367
|
-
{ pattern: /session_user/i, label: 'session_user check — bypasses tenant isolation' },
|
|
368
|
-
{ pattern: /current_user\s*=/i, label: 'current_user check — bypasses tenant isolation' },
|
|
369
|
-
{ pattern: /pg_has_role/i, label: 'pg_has_role — bypasses tenant isolation' },
|
|
370
|
-
]
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* Validate an RLS clause against the whitelist.
|
|
374
|
-
* Returns null if valid, or a description string if banned/unrecognized.
|
|
375
|
-
*/
|
|
376
|
-
function validateRlsClause(clause) {
|
|
377
|
-
if (!clause || !clause.trim()) return null
|
|
378
|
-
|
|
379
|
-
// First: check for explicitly banned patterns (these are always wrong)
|
|
380
|
-
for (const { pattern, label } of BANNED_RLS_PATTERNS) {
|
|
381
|
-
if (pattern.test(clause)) return label
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// Second: verify clause contains at least one allowed pattern
|
|
385
|
-
const hasAllowedPattern = ALLOWED_RLS_PATTERNS.some(({ pattern }) => pattern.test(clause))
|
|
386
|
-
if (!hasAllowedPattern) {
|
|
387
|
-
return `Unrecognized RLS clause: "${clause.substring(0, 150)}". Only whitelisted patterns are allowed (org/user isolation, role gates, data filters, subqueries). See ALLOWED_RLS_PATTERNS in config-rls-alignment.js.`
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
return null
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
export async function run(config, projectRoot, options = {}) {
|
|
394
|
-
const results = {
|
|
395
|
-
passed: true,
|
|
396
|
-
skipped: false,
|
|
397
|
-
findings: [],
|
|
398
|
-
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
399
|
-
details: { tablesChecked: 0, configsFound: 0, alignmentErrors: 0, source: 'migrations' }
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const featureConfigs = parseFeatureConfigs(projectRoot)
|
|
403
|
-
|
|
404
|
-
// Use live DB state if available (passed from rls-live-audit via runner),
|
|
405
|
-
// otherwise fall back to migration file parsing
|
|
406
|
-
const rlsData = options.liveState || parseMigrations(projectRoot)
|
|
407
|
-
if (options.liveState) {
|
|
408
|
-
results.details.source = 'live-db'
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
results.details.configsFound = featureConfigs.size
|
|
412
|
-
|
|
413
|
-
if (featureConfigs.size === 0) {
|
|
414
|
-
results.skipped = true
|
|
415
|
-
results.skipReason = 'No feature config files found'
|
|
416
|
-
return results
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// For each feature config, verify RLS alignment
|
|
420
|
-
for (const [tableName, cfg] of featureConfigs) {
|
|
421
|
-
results.details.tablesChecked++
|
|
422
|
-
const tableRls = rlsData.get(tableName)
|
|
423
|
-
|
|
424
|
-
// CHECK 1: Table must have RLS enabled
|
|
425
|
-
if (!tableRls || !tableRls.rlsEnabled) {
|
|
426
|
-
results.passed = false
|
|
427
|
-
results.findings.push({
|
|
428
|
-
file: cfg.configFile,
|
|
429
|
-
line: 1,
|
|
430
|
-
type: 'no-rls-on-config-table',
|
|
431
|
-
severity: 'critical',
|
|
432
|
-
message: `Config declares table "${tableName}" with accessLevel "${cfg.accessLevel}" but table has NO RLS enabled. Any authenticated user can access ALL data.`,
|
|
433
|
-
fix: `Add to migrations: ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY; then add appropriate policies.`
|
|
434
|
-
})
|
|
435
|
-
results.summary.critical++
|
|
436
|
-
results.summary.total++
|
|
437
|
-
continue
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
const policies = tableRls.policies
|
|
441
|
-
const selectPolicies = policies.filter(p => p.operation === 'SELECT' || p.operation === 'ALL')
|
|
442
|
-
const insertPolicies = policies.filter(p => p.operation === 'INSERT' || p.operation === 'ALL')
|
|
443
|
-
const updatePolicies = policies.filter(p => p.operation === 'UPDATE' || p.operation === 'ALL')
|
|
444
|
-
const deletePolicies = policies.filter(p => p.operation === 'DELETE' || p.operation === 'ALL')
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
// CHECK 2: Must have policies for all operations
|
|
448
|
-
const missingOps = []
|
|
449
|
-
if (selectPolicies.length === 0) missingOps.push('SELECT')
|
|
450
|
-
if (insertPolicies.length === 0) missingOps.push('INSERT')
|
|
451
|
-
if (updatePolicies.length === 0) missingOps.push('UPDATE')
|
|
452
|
-
if (deletePolicies.length === 0) missingOps.push('DELETE')
|
|
453
|
-
|
|
454
|
-
if (missingOps.length > 0 && cfg.accessLevel !== 'system') {
|
|
455
|
-
results.passed = false
|
|
456
|
-
results.findings.push({
|
|
457
|
-
file: cfg.configFile,
|
|
458
|
-
line: 1,
|
|
459
|
-
type: 'missing-operation-policies',
|
|
460
|
-
severity: 'high',
|
|
461
|
-
message: `Table "${tableName}" is missing RLS policies for: ${missingOps.join(', ')}. RLS is enabled but incomplete — these operations are blocked for everyone.`,
|
|
462
|
-
fix: `Add policies for ${missingOps.join(', ')} operations on table ${tableName}.`
|
|
463
|
-
})
|
|
464
|
-
results.summary.high++
|
|
465
|
-
results.summary.total++
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// CHECK 3: accessLevel ↔ RLS policy alignment
|
|
469
|
-
if (cfg.accessLevel === 'admin') {
|
|
470
|
-
// Admin tables MUST have org_isolation on SELECT
|
|
471
|
-
const hasOrgIsolation = selectPolicies.some(p => isOrgIsolation(p.using))
|
|
472
|
-
if (!hasOrgIsolation && selectPolicies.length > 0) {
|
|
473
|
-
// Check if any policy enforces org scoping
|
|
474
|
-
const hasAnyOrgScope = selectPolicies.some(p =>
|
|
475
|
-
p.using.includes('organization_id') || p.using.includes('auth_org_id')
|
|
476
|
-
)
|
|
477
|
-
if (!hasAnyOrgScope) {
|
|
478
|
-
results.passed = false
|
|
479
|
-
results.findings.push({
|
|
480
|
-
file: cfg.configFile,
|
|
481
|
-
line: 1,
|
|
482
|
-
type: 'admin-without-org-isolation',
|
|
483
|
-
severity: 'critical',
|
|
484
|
-
message: `Config says accessLevel "admin" for table "${tableName}" but SELECT policy does NOT enforce organization isolation. Users from org A can see org B's data.`,
|
|
485
|
-
fix: `RLS SELECT policy must include: USING (${cfg.organizationIdField} = auth_org_id())`
|
|
486
|
-
})
|
|
487
|
-
results.summary.critical++
|
|
488
|
-
results.summary.total++
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// Admin tables must NOT have USING(true) on SELECT
|
|
493
|
-
const hasWideOpen = selectPolicies.some(p => isWideOpen(p.using))
|
|
494
|
-
if (hasWideOpen) {
|
|
495
|
-
results.passed = false
|
|
496
|
-
results.findings.push({
|
|
497
|
-
file: cfg.configFile,
|
|
498
|
-
line: 1,
|
|
499
|
-
type: 'admin-with-using-true',
|
|
500
|
-
severity: 'critical',
|
|
501
|
-
message: `Config says accessLevel "admin" for table "${tableName}" but has a SELECT policy with USING(true). ALL data is visible to ALL authenticated users.`,
|
|
502
|
-
fix: `Replace USING(true) with USING (${cfg.organizationIdField} = auth_org_id())`
|
|
503
|
-
})
|
|
504
|
-
results.summary.critical++
|
|
505
|
-
results.summary.total++
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
if (cfg.accessLevel === 'user') {
|
|
510
|
-
// User tables MUST have user isolation
|
|
511
|
-
const hasUserIsolation = selectPolicies.some(p => isUserIsolation(p.using))
|
|
512
|
-
if (!hasUserIsolation && selectPolicies.length > 0) {
|
|
513
|
-
results.passed = false
|
|
514
|
-
results.findings.push({
|
|
515
|
-
file: cfg.configFile,
|
|
516
|
-
line: 1,
|
|
517
|
-
type: 'user-without-user-isolation',
|
|
518
|
-
severity: 'critical',
|
|
519
|
-
message: `Config says accessLevel "user" for table "${tableName}" but SELECT policy does NOT enforce user isolation. Users can see other users' data.`,
|
|
520
|
-
fix: `RLS SELECT policy must include: USING (user_id = auth.uid())`
|
|
521
|
-
})
|
|
522
|
-
results.summary.critical++
|
|
523
|
-
results.summary.total++
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
if (cfg.accessLevel === 'creator') {
|
|
528
|
-
// Creator tables MUST have both user isolation AND visibility rules
|
|
529
|
-
if (!cfg.hasCreatorVisibility) {
|
|
530
|
-
results.findings.push({
|
|
531
|
-
file: cfg.configFile,
|
|
532
|
-
line: 1,
|
|
533
|
-
type: 'creator-without-visibility-config',
|
|
534
|
-
severity: 'medium',
|
|
535
|
-
message: `Config says accessLevel "creator" for table "${tableName}" but no creatorVisibility config defined. Creators may see all org data instead of only their own + shared.`,
|
|
536
|
-
fix: `Add creatorVisibility: { column: 'visibility_level', publicValues: ['shared', 'public'] } to the config.`
|
|
537
|
-
})
|
|
538
|
-
results.summary.medium++
|
|
539
|
-
results.summary.total++
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// CHECK 4: RPC functions must be SECURITY INVOKER (unless whitelisted)
|
|
544
|
-
const definerWhitelist = config?.supabase?.securityDefinerWhitelist || []
|
|
545
|
-
for (const rpcName of cfg.rpcFunctions) {
|
|
546
|
-
// Skip if explicitly whitelisted in config
|
|
547
|
-
if (definerWhitelist.includes(rpcName)) continue
|
|
548
|
-
|
|
549
|
-
const rpcInfo = tableRls.rpcFunctions.get(rpcName)
|
|
550
|
-
if (rpcInfo && rpcInfo.securityMode === 'DEFINER') {
|
|
551
|
-
results.passed = false
|
|
552
|
-
results.findings.push({
|
|
553
|
-
file: rpcInfo.file,
|
|
554
|
-
line: 1,
|
|
555
|
-
type: 'rpc-security-definer',
|
|
556
|
-
severity: 'critical',
|
|
557
|
-
message: `RPC function "${rpcName}" for table "${tableName}" uses SECURITY DEFINER — this BYPASSES RLS completely. Any authenticated user can see ALL data.`,
|
|
558
|
-
fix: `Change to SECURITY INVOKER or add "${rpcName}" to supabase.securityDefinerWhitelist in .tetra-quality.json.`
|
|
559
|
-
})
|
|
560
|
-
results.summary.critical++
|
|
561
|
-
results.summary.total++
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
// CHECK 4b: All policy clauses must match whitelisted patterns only
|
|
566
|
-
for (const p of policies) {
|
|
567
|
-
for (const [clauseType, clause] of [['USING', p.using], ['WITH CHECK', p.withCheck]]) {
|
|
568
|
-
if (!clause) continue
|
|
569
|
-
const violation = validateRlsClause(clause)
|
|
570
|
-
if (violation) {
|
|
571
|
-
results.passed = false
|
|
572
|
-
results.findings.push({
|
|
573
|
-
file: p.file,
|
|
574
|
-
line: 1,
|
|
575
|
-
type: 'rls-invalid-clause',
|
|
576
|
-
severity: 'critical',
|
|
577
|
-
message: `Policy "${p.name}" on table "${tableName}" has invalid ${clauseType} clause: ${violation}`,
|
|
578
|
-
fix: `Only whitelisted patterns are allowed. Valid: auth_admin_organizations(), auth.uid(), org/user column checks, parent-table subqueries, boolean column filters. See ALLOWED_RLS_ATOMS in config-rls-alignment.js.`
|
|
579
|
-
})
|
|
580
|
-
results.summary.critical++
|
|
581
|
-
results.summary.total++
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
// CHECK 5: Write policies (INSERT/UPDATE) must have WITH CHECK for org isolation
|
|
587
|
-
if (cfg.accessLevel === 'admin' || cfg.accessLevel === 'user') {
|
|
588
|
-
const writePoilciesWithoutCheck = [
|
|
589
|
-
...insertPolicies.filter(p => !p.withCheck && p.operation === 'INSERT'),
|
|
590
|
-
...updatePolicies.filter(p => !p.withCheck && p.operation === 'UPDATE')
|
|
591
|
-
]
|
|
592
|
-
|
|
593
|
-
for (const p of writePoilciesWithoutCheck) {
|
|
594
|
-
// INSERT policies without WITH CHECK allow inserting into any org
|
|
595
|
-
results.findings.push({
|
|
596
|
-
file: p.file,
|
|
597
|
-
line: 1,
|
|
598
|
-
type: 'write-without-check',
|
|
599
|
-
severity: 'high',
|
|
600
|
-
message: `${p.operation} policy "${p.name}" on table "${tableName}" has no WITH CHECK clause. Users can write data outside their ${cfg.accessLevel === 'admin' ? 'organization' : 'own records'}.`,
|
|
601
|
-
fix: `Add WITH CHECK (${cfg.organizationIdField} = auth_org_id()) to the ${p.operation} policy.`
|
|
602
|
-
})
|
|
603
|
-
results.summary.high++
|
|
604
|
-
results.summary.total++
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// CHECK 6: Tables with RLS that are NOT in any config (orphan tables)
|
|
610
|
-
// These might be missing config files or intentionally backend-only
|
|
611
|
-
const backendOnlyTables = (config.supabase?.backendOnlyTables || [])
|
|
612
|
-
const configTables = new Set(featureConfigs.keys())
|
|
613
|
-
|
|
614
|
-
for (const [tableName, info] of rlsData) {
|
|
615
|
-
if (configTables.has(tableName)) continue
|
|
616
|
-
if (backendOnlyTables.includes(tableName)) continue
|
|
617
|
-
if (tableName.startsWith('_') || tableName.startsWith('pg_')) continue
|
|
618
|
-
// Skip common system tables
|
|
619
|
-
if (['organization_members', 'org_members', 'auth_tokens'].includes(tableName)) continue
|
|
620
|
-
|
|
621
|
-
if (info.policies.length === 0 && info.rlsEnabled) {
|
|
622
|
-
results.findings.push({
|
|
623
|
-
file: 'supabase/migrations',
|
|
624
|
-
line: 1,
|
|
625
|
-
type: 'orphan-table-no-policies',
|
|
626
|
-
severity: 'medium',
|
|
627
|
-
message: `Table "${tableName}" has RLS enabled but no policies AND no feature config. It's either dead or missing its config file.`,
|
|
628
|
-
fix: `Either add a feature config, add to backendOnlyTables in .tetra-quality.json, or add RLS policies.`
|
|
629
|
-
})
|
|
630
|
-
results.summary.medium++
|
|
631
|
-
results.summary.total++
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
results.details.alignmentErrors = results.findings.filter(f => f.severity === 'critical').length
|
|
636
|
-
return results
|
|
637
|
-
}
|