@soulbatical/tetra-dev-toolkit 1.12.1 → 1.13.1
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.
|
@@ -0,0 +1,421 @@
|
|
|
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. The golden 1:1 check.'
|
|
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
|
+
for (const file of sqlFiles) {
|
|
109
|
+
let content
|
|
110
|
+
try { content = readFileSync(file, 'utf-8') } catch { continue }
|
|
111
|
+
|
|
112
|
+
const relFile = file.replace(projectRoot + '/', '')
|
|
113
|
+
|
|
114
|
+
// Find RLS enables
|
|
115
|
+
const rlsMatches = content.matchAll(/ALTER\s+TABLE\s+(?:public\.)?(\w+)\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY/gi)
|
|
116
|
+
for (const m of rlsMatches) {
|
|
117
|
+
const table = m[1]
|
|
118
|
+
if (!tables.has(table)) tables.set(table, { rlsEnabled: true, policies: [], rpcFunctions: new Map() })
|
|
119
|
+
else tables.get(table).rlsEnabled = true
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Find policies
|
|
123
|
+
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
|
|
124
|
+
for (const m of content.matchAll(policyRegex)) {
|
|
125
|
+
const policyName = m[1]
|
|
126
|
+
const table = m[2]
|
|
127
|
+
const body = m[3] || ''
|
|
128
|
+
|
|
129
|
+
if (!tables.has(table)) tables.set(table, { rlsEnabled: false, policies: [], rpcFunctions: new Map() })
|
|
130
|
+
|
|
131
|
+
// Determine operation
|
|
132
|
+
let operation = 'ALL'
|
|
133
|
+
const forMatch = body.match(/FOR\s+(SELECT|INSERT|UPDATE|DELETE|ALL)/i)
|
|
134
|
+
if (forMatch) operation = forMatch[1].toUpperCase()
|
|
135
|
+
|
|
136
|
+
// Extract USING clause
|
|
137
|
+
const usingMatch = body.match(/USING\s*\(([\s\S]*?)(?:\)\s*(?:WITH|;|$)|\)$)/i)
|
|
138
|
+
const using = usingMatch ? usingMatch[1].trim() : ''
|
|
139
|
+
|
|
140
|
+
// Extract WITH CHECK
|
|
141
|
+
const withCheckMatch = body.match(/WITH\s+CHECK\s*\(([\s\S]*?)(?:\)\s*;|\)$)/i)
|
|
142
|
+
const withCheck = withCheckMatch ? withCheckMatch[1].trim() : ''
|
|
143
|
+
|
|
144
|
+
tables.get(table).policies.push({
|
|
145
|
+
name: policyName,
|
|
146
|
+
operation,
|
|
147
|
+
using,
|
|
148
|
+
withCheck,
|
|
149
|
+
file: relFile
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Find RPC functions and their security mode
|
|
154
|
+
const funcRegex = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(?:public\.)?(\w+)\s*\(([\s\S]*?)\)\s*RETURNS\s+([\s\S]*?)(?:LANGUAGE|AS)/gi
|
|
155
|
+
for (const m of content.matchAll(funcRegex)) {
|
|
156
|
+
const funcName = m[1]
|
|
157
|
+
const funcBody = content.substring(m.index, m.index + 2000)
|
|
158
|
+
|
|
159
|
+
const isDefiner = /SECURITY\s+DEFINER/i.test(funcBody)
|
|
160
|
+
const isInvoker = /SECURITY\s+INVOKER/i.test(funcBody)
|
|
161
|
+
|
|
162
|
+
// Find which tables this function touches
|
|
163
|
+
for (const [table] of tables) {
|
|
164
|
+
if (funcBody.includes(table)) {
|
|
165
|
+
tables.get(table).rpcFunctions.set(funcName, {
|
|
166
|
+
securityMode: isDefiner ? 'DEFINER' : isInvoker ? 'INVOKER' : 'DEFAULT',
|
|
167
|
+
file: relFile
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return tables
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function findFiles(projectRoot, pattern) {
|
|
179
|
+
try {
|
|
180
|
+
return globSync(pattern, { cwd: projectRoot, absolute: true, ignore: ['**/node_modules/**'] })
|
|
181
|
+
} catch {
|
|
182
|
+
return []
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Check if a USING clause enforces org isolation
|
|
188
|
+
*/
|
|
189
|
+
function isOrgIsolation(using) {
|
|
190
|
+
return /auth_org_id\(\)|auth_admin_organizations\(\)/i.test(using) &&
|
|
191
|
+
/organization_id/i.test(using)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Check if a USING clause enforces user isolation
|
|
196
|
+
*/
|
|
197
|
+
function isUserIsolation(using) {
|
|
198
|
+
return /auth\.uid\(\)/i.test(using) &&
|
|
199
|
+
/user_id|created_by|owner_id/i.test(using)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Check if a USING clause is wide open
|
|
204
|
+
*/
|
|
205
|
+
function isWideOpen(using) {
|
|
206
|
+
return using.trim() === 'true' || using.trim() === '(true)'
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function run(config, projectRoot) {
|
|
210
|
+
const results = {
|
|
211
|
+
passed: true,
|
|
212
|
+
skipped: false,
|
|
213
|
+
findings: [],
|
|
214
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
215
|
+
details: { tablesChecked: 0, configsFound: 0, alignmentErrors: 0 }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const featureConfigs = parseFeatureConfigs(projectRoot)
|
|
219
|
+
const rlsData = parseMigrations(projectRoot)
|
|
220
|
+
|
|
221
|
+
results.details.configsFound = featureConfigs.size
|
|
222
|
+
|
|
223
|
+
if (featureConfigs.size === 0) {
|
|
224
|
+
results.skipped = true
|
|
225
|
+
results.skipReason = 'No feature config files found'
|
|
226
|
+
return results
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// For each feature config, verify RLS alignment
|
|
230
|
+
for (const [tableName, cfg] of featureConfigs) {
|
|
231
|
+
results.details.tablesChecked++
|
|
232
|
+
const tableRls = rlsData.get(tableName)
|
|
233
|
+
|
|
234
|
+
// CHECK 1: Table must have RLS enabled
|
|
235
|
+
if (!tableRls || !tableRls.rlsEnabled) {
|
|
236
|
+
results.passed = false
|
|
237
|
+
results.findings.push({
|
|
238
|
+
file: cfg.configFile,
|
|
239
|
+
line: 1,
|
|
240
|
+
type: 'no-rls-on-config-table',
|
|
241
|
+
severity: 'critical',
|
|
242
|
+
message: `Config declares table "${tableName}" with accessLevel "${cfg.accessLevel}" but table has NO RLS enabled. Any authenticated user can access ALL data.`,
|
|
243
|
+
fix: `Add to migrations: ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY; then add appropriate policies.`
|
|
244
|
+
})
|
|
245
|
+
results.summary.critical++
|
|
246
|
+
results.summary.total++
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const policies = tableRls.policies
|
|
251
|
+
const selectPolicies = policies.filter(p => p.operation === 'SELECT' || p.operation === 'ALL')
|
|
252
|
+
const insertPolicies = policies.filter(p => p.operation === 'INSERT' || p.operation === 'ALL')
|
|
253
|
+
const updatePolicies = policies.filter(p => p.operation === 'UPDATE' || p.operation === 'ALL')
|
|
254
|
+
const deletePolicies = policies.filter(p => p.operation === 'DELETE' || p.operation === 'ALL')
|
|
255
|
+
|
|
256
|
+
// CHECK 2: Must have policies for all operations
|
|
257
|
+
const missingOps = []
|
|
258
|
+
if (selectPolicies.length === 0) missingOps.push('SELECT')
|
|
259
|
+
if (insertPolicies.length === 0) missingOps.push('INSERT')
|
|
260
|
+
if (updatePolicies.length === 0) missingOps.push('UPDATE')
|
|
261
|
+
if (deletePolicies.length === 0) missingOps.push('DELETE')
|
|
262
|
+
|
|
263
|
+
if (missingOps.length > 0 && cfg.accessLevel !== 'system') {
|
|
264
|
+
results.passed = false
|
|
265
|
+
results.findings.push({
|
|
266
|
+
file: cfg.configFile,
|
|
267
|
+
line: 1,
|
|
268
|
+
type: 'missing-operation-policies',
|
|
269
|
+
severity: 'high',
|
|
270
|
+
message: `Table "${tableName}" is missing RLS policies for: ${missingOps.join(', ')}. RLS is enabled but incomplete — these operations are blocked for everyone.`,
|
|
271
|
+
fix: `Add policies for ${missingOps.join(', ')} operations on table ${tableName}.`
|
|
272
|
+
})
|
|
273
|
+
results.summary.high++
|
|
274
|
+
results.summary.total++
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// CHECK 3: accessLevel ↔ RLS policy alignment
|
|
278
|
+
if (cfg.accessLevel === 'admin') {
|
|
279
|
+
// Admin tables MUST have org_isolation on SELECT
|
|
280
|
+
const hasOrgIsolation = selectPolicies.some(p => isOrgIsolation(p.using))
|
|
281
|
+
if (!hasOrgIsolation && selectPolicies.length > 0) {
|
|
282
|
+
// Check if any policy enforces org scoping
|
|
283
|
+
const hasAnyOrgScope = selectPolicies.some(p =>
|
|
284
|
+
p.using.includes('organization_id') || p.using.includes('auth_org_id')
|
|
285
|
+
)
|
|
286
|
+
if (!hasAnyOrgScope) {
|
|
287
|
+
results.passed = false
|
|
288
|
+
results.findings.push({
|
|
289
|
+
file: cfg.configFile,
|
|
290
|
+
line: 1,
|
|
291
|
+
type: 'admin-without-org-isolation',
|
|
292
|
+
severity: 'critical',
|
|
293
|
+
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.`,
|
|
294
|
+
fix: `RLS SELECT policy must include: USING (${cfg.organizationIdField} = auth_org_id())`
|
|
295
|
+
})
|
|
296
|
+
results.summary.critical++
|
|
297
|
+
results.summary.total++
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Admin tables must NOT have USING(true) on SELECT
|
|
302
|
+
const hasWideOpen = selectPolicies.some(p => isWideOpen(p.using))
|
|
303
|
+
if (hasWideOpen) {
|
|
304
|
+
results.passed = false
|
|
305
|
+
results.findings.push({
|
|
306
|
+
file: cfg.configFile,
|
|
307
|
+
line: 1,
|
|
308
|
+
type: 'admin-with-using-true',
|
|
309
|
+
severity: 'critical',
|
|
310
|
+
message: `Config says accessLevel "admin" for table "${tableName}" but has a SELECT policy with USING(true). ALL data is visible to ALL authenticated users.`,
|
|
311
|
+
fix: `Replace USING(true) with USING (${cfg.organizationIdField} = auth_org_id())`
|
|
312
|
+
})
|
|
313
|
+
results.summary.critical++
|
|
314
|
+
results.summary.total++
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (cfg.accessLevel === 'user') {
|
|
319
|
+
// User tables MUST have user isolation
|
|
320
|
+
const hasUserIsolation = selectPolicies.some(p => isUserIsolation(p.using))
|
|
321
|
+
if (!hasUserIsolation && selectPolicies.length > 0) {
|
|
322
|
+
results.passed = false
|
|
323
|
+
results.findings.push({
|
|
324
|
+
file: cfg.configFile,
|
|
325
|
+
line: 1,
|
|
326
|
+
type: 'user-without-user-isolation',
|
|
327
|
+
severity: 'critical',
|
|
328
|
+
message: `Config says accessLevel "user" for table "${tableName}" but SELECT policy does NOT enforce user isolation. Users can see other users' data.`,
|
|
329
|
+
fix: `RLS SELECT policy must include: USING (user_id = auth.uid())`
|
|
330
|
+
})
|
|
331
|
+
results.summary.critical++
|
|
332
|
+
results.summary.total++
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (cfg.accessLevel === 'creator') {
|
|
337
|
+
// Creator tables MUST have both user isolation AND visibility rules
|
|
338
|
+
if (!cfg.hasCreatorVisibility) {
|
|
339
|
+
results.findings.push({
|
|
340
|
+
file: cfg.configFile,
|
|
341
|
+
line: 1,
|
|
342
|
+
type: 'creator-without-visibility-config',
|
|
343
|
+
severity: 'medium',
|
|
344
|
+
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.`,
|
|
345
|
+
fix: `Add creatorVisibility: { column: 'visibility_level', publicValues: ['shared', 'public'] } to the config.`
|
|
346
|
+
})
|
|
347
|
+
results.summary.medium++
|
|
348
|
+
results.summary.total++
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// CHECK 4: RPC functions must be SECURITY INVOKER
|
|
353
|
+
for (const rpcName of cfg.rpcFunctions) {
|
|
354
|
+
const rpcInfo = tableRls.rpcFunctions.get(rpcName)
|
|
355
|
+
if (rpcInfo && rpcInfo.securityMode === 'DEFINER') {
|
|
356
|
+
results.passed = false
|
|
357
|
+
results.findings.push({
|
|
358
|
+
file: rpcInfo.file,
|
|
359
|
+
line: 1,
|
|
360
|
+
type: 'rpc-security-definer',
|
|
361
|
+
severity: 'critical',
|
|
362
|
+
message: `RPC function "${rpcName}" for table "${tableName}" uses SECURITY DEFINER — this BYPASSES RLS completely. Any authenticated user can see ALL data.`,
|
|
363
|
+
fix: `Change to SECURITY INVOKER or remove the SECURITY DEFINER clause.`
|
|
364
|
+
})
|
|
365
|
+
results.summary.critical++
|
|
366
|
+
results.summary.total++
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// CHECK 5: Write policies (INSERT/UPDATE) must have WITH CHECK for org isolation
|
|
371
|
+
if (cfg.accessLevel === 'admin' || cfg.accessLevel === 'user') {
|
|
372
|
+
const writePoilciesWithoutCheck = [
|
|
373
|
+
...insertPolicies.filter(p => !p.withCheck && p.operation === 'INSERT'),
|
|
374
|
+
...updatePolicies.filter(p => !p.withCheck && p.operation === 'UPDATE')
|
|
375
|
+
]
|
|
376
|
+
|
|
377
|
+
for (const p of writePoilciesWithoutCheck) {
|
|
378
|
+
// INSERT policies without WITH CHECK allow inserting into any org
|
|
379
|
+
results.findings.push({
|
|
380
|
+
file: p.file,
|
|
381
|
+
line: 1,
|
|
382
|
+
type: 'write-without-check',
|
|
383
|
+
severity: 'high',
|
|
384
|
+
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'}.`,
|
|
385
|
+
fix: `Add WITH CHECK (${cfg.organizationIdField} = auth_org_id()) to the ${p.operation} policy.`
|
|
386
|
+
})
|
|
387
|
+
results.summary.high++
|
|
388
|
+
results.summary.total++
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// CHECK 6: Tables with RLS that are NOT in any config (orphan tables)
|
|
394
|
+
// These might be missing config files or intentionally backend-only
|
|
395
|
+
const backendOnlyTables = (config.supabase?.backendOnlyTables || [])
|
|
396
|
+
const configTables = new Set(featureConfigs.keys())
|
|
397
|
+
|
|
398
|
+
for (const [tableName, info] of rlsData) {
|
|
399
|
+
if (configTables.has(tableName)) continue
|
|
400
|
+
if (backendOnlyTables.includes(tableName)) continue
|
|
401
|
+
if (tableName.startsWith('_') || tableName.startsWith('pg_')) continue
|
|
402
|
+
// Skip common system tables
|
|
403
|
+
if (['organization_members', 'org_members', 'auth_tokens'].includes(tableName)) continue
|
|
404
|
+
|
|
405
|
+
if (info.policies.length === 0 && info.rlsEnabled) {
|
|
406
|
+
results.findings.push({
|
|
407
|
+
file: 'supabase/migrations',
|
|
408
|
+
line: 1,
|
|
409
|
+
type: 'orphan-table-no-policies',
|
|
410
|
+
severity: 'medium',
|
|
411
|
+
message: `Table "${tableName}" has RLS enabled but no policies AND no feature config. It's either dead or missing its config file.`,
|
|
412
|
+
fix: `Either add a feature config, add to backendOnlyTables in .tetra-quality.json, or add RLS policies.`
|
|
413
|
+
})
|
|
414
|
+
results.summary.medium++
|
|
415
|
+
results.summary.total++
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
results.details.alignmentErrors = results.findings.filter(f => f.severity === 'critical').length
|
|
420
|
+
return results
|
|
421
|
+
}
|
package/lib/runner.js
CHANGED
|
@@ -14,6 +14,7 @@ import * as directSupabaseClient from './checks/security/direct-supabase-client.
|
|
|
14
14
|
import * as frontendSupabaseQueries from './checks/security/frontend-supabase-queries.js'
|
|
15
15
|
import * as tetraCoreCompliance from './checks/security/tetra-core-compliance.js'
|
|
16
16
|
import * as mixedDbUsage from './checks/security/mixed-db-usage.js'
|
|
17
|
+
import * as configRlsAlignment from './checks/security/config-rls-alignment.js'
|
|
17
18
|
import * as systemdbWhitelist from './checks/security/systemdb-whitelist.js'
|
|
18
19
|
import * as huskyHooks from './checks/stability/husky-hooks.js'
|
|
19
20
|
import * as ciPipeline from './checks/stability/ci-pipeline.js'
|
|
@@ -39,6 +40,7 @@ const ALL_CHECKS = {
|
|
|
39
40
|
frontendSupabaseQueries,
|
|
40
41
|
tetraCoreCompliance,
|
|
41
42
|
mixedDbUsage,
|
|
43
|
+
configRlsAlignment,
|
|
42
44
|
systemdbWhitelist,
|
|
43
45
|
gitignoreValidation
|
|
44
46
|
],
|