@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.
@@ -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
- }