@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
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulbatical/tetra-dev-toolkit",
3
- "version": "1.12.1",
3
+ "version": "1.13.1",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },