@soulbatical/tetra-dev-toolkit 1.17.0 → 1.17.2

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.
@@ -280,6 +280,123 @@ function isWideOpen(using) {
280
280
  return using.trim() === 'true' || using.trim() === '(true)'
281
281
  }
282
282
 
283
+ /**
284
+ * Whitelist-based RLS policy validation.
285
+ *
286
+ * ONLY these building blocks are allowed in USING/WITH CHECK clauses.
287
+ * Derived from sparkbuddy-live (the reference implementation).
288
+ * Everything that doesn't match is rejected as unrecognized.
289
+ *
290
+ * To add a new pattern: add it here AND document why it's safe.
291
+ */
292
+ const ALLOWED_RLS_ATOMS = [
293
+ // Org isolation via helper functions
294
+ /auth_admin_organizations\s*\(\)/i,
295
+ /auth_user_organizations\s*\(\)/i,
296
+ /auth_org_id\s*\(\)/i,
297
+ /auth_current_user_id\s*\(\)/i,
298
+ // User isolation
299
+ /auth\.uid\s*\(\)/i,
300
+ // Column comparisons (org_id, user_id, etc.)
301
+ /organization_id/i,
302
+ /organizationid/i,
303
+ /active_organization_id/i,
304
+ /user_id/i,
305
+ /userid/i,
306
+ /owner_id/i,
307
+ /created_by/i,
308
+ /creator_id/i,
309
+ /shared_by/i,
310
+ /sparkbuddy_user_id/i,
311
+ /user_public_id/i,
312
+ // Boolean/status column checks (for public/active filtering)
313
+ /is_active/i,
314
+ /is_public/i,
315
+ /is_template/i,
316
+ /is_published/i,
317
+ /anonymous_access/i,
318
+ /allow_cross_org_usage/i,
319
+ /visibility_level/i,
320
+ /published_status/i,
321
+ /post_type/i,
322
+ /status/i,
323
+ /active/i,
324
+ /invitation_token/i,
325
+ /original_testimonial_id/i,
326
+ /expires_at/i,
327
+ /session_id/i,
328
+ // Subqueries to parent tables
329
+ /\bIN\s*\(\s*SELECT\b/i,
330
+ /\bEXISTS\s*\(\s*SELECT\b/i,
331
+ // Literals and operators
332
+ /true/i,
333
+ /false/i,
334
+ /null/i,
335
+ /now\s*\(\)/i,
336
+ /\bAND\b/i,
337
+ /\bOR\b/i,
338
+ /\bNOT\b/i,
339
+ /\bIS\b/i,
340
+ /\bANY\b/i,
341
+ /\bARRAY\b/i,
342
+ // Legacy JWT pattern (sparkbuddy initial schema)
343
+ /auth\.jwt\s*\(\)/i,
344
+ /app_metadata/i,
345
+ // current_setting for app context (NOT role/jwt.claims bypass)
346
+ /current_setting\s*\(\s*'app\./i,
347
+ ]
348
+
349
+ /**
350
+ * Patterns that are NEVER allowed in RLS policies, regardless of context.
351
+ * These bypass RLS and defeat the purpose of having policies at all.
352
+ */
353
+ const BANNED_RLS_PATTERNS = [
354
+ { pattern: /service_role/i, label: 'service_role bypass — service role already bypasses RLS at the Supabase layer' },
355
+ { pattern: /current_setting\s*\(\s*'role'\s*(?:,\s*true\s*)?\)/i, label: 'PostgreSQL role check — bypasses tenant isolation' },
356
+ { pattern: /current_setting\s*\(\s*'request\.jwt\.claims'/i, label: 'JWT claims role check — bypasses tenant isolation' },
357
+ { pattern: /session_user/i, label: 'session_user check — bypasses tenant isolation' },
358
+ { pattern: /current_user\s*=/i, label: 'current_user check — bypasses tenant isolation' },
359
+ { pattern: /auth\.role\s*\(\)/i, label: 'auth.role() check — bypasses tenant isolation' },
360
+ { pattern: /pg_has_role/i, label: 'pg_has_role — bypasses tenant isolation' },
361
+ ]
362
+
363
+ /**
364
+ * Validate an RLS clause against the whitelist.
365
+ * Returns null if valid, or a description of what's wrong.
366
+ */
367
+ function validateRlsClause(clause) {
368
+ if (!clause || !clause.trim()) return null
369
+
370
+ // First check for explicitly banned patterns
371
+ for (const { pattern, label } of BANNED_RLS_PATTERNS) {
372
+ if (pattern.test(clause)) return label
373
+ }
374
+
375
+ // Strip known-safe tokens and see if anything suspicious remains
376
+ let stripped = clause
377
+ // Remove string literals
378
+ stripped = stripped.replace(/'[^']*'/g, '')
379
+ // Remove numbers
380
+ stripped = stripped.replace(/\b\d+\b/g, '')
381
+ // Remove known-safe identifiers and functions
382
+ for (const atom of ALLOWED_RLS_ATOMS) {
383
+ stripped = stripped.replace(new RegExp(atom.source, 'gi'), '')
384
+ }
385
+ // Remove SQL syntax noise (parens, commas, operators, quotes, casts, aliases)
386
+ stripped = stripped.replace(/[(),"=<>!:.|{}\-\+\*\/\s]/g, '')
387
+ stripped = stripped.replace(/\b(AS|FROM|WHERE|SELECT|JOIN|ON|LIMIT|uuid|text|boolean|integer|jsonb|json|public|ARRAY|FOR)\b/gi, '')
388
+ // Remove table/column qualifiers that look like identifiers (lowercase + underscore)
389
+ stripped = stripped.replace(/\b[a-z_][a-z0-9_]*\b/gi, '')
390
+ stripped = stripped.trim()
391
+
392
+ // If anything substantial remains, it's an unrecognized pattern
393
+ if (stripped.length > 0) {
394
+ return `Unrecognized RLS clause content: "${clause.substring(0, 120)}". Only whitelisted patterns are allowed.`
395
+ }
396
+
397
+ return null
398
+ }
399
+
283
400
  export async function run(config, projectRoot) {
284
401
  const results = {
285
402
  passed: true,
@@ -446,6 +563,27 @@ export async function run(config, projectRoot) {
446
563
  }
447
564
  }
448
565
 
566
+ // CHECK 4b: All policy clauses must match whitelisted patterns only
567
+ for (const p of policies) {
568
+ for (const [clauseType, clause] of [['USING', p.using], ['WITH CHECK', p.withCheck]]) {
569
+ if (!clause) continue
570
+ const violation = validateRlsClause(clause)
571
+ if (violation) {
572
+ results.passed = false
573
+ results.findings.push({
574
+ file: p.file,
575
+ line: 1,
576
+ type: 'rls-invalid-clause',
577
+ severity: 'critical',
578
+ message: `Policy "${p.name}" on table "${tableName}" has invalid ${clauseType} clause: ${violation}`,
579
+ 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.`
580
+ })
581
+ results.summary.critical++
582
+ results.summary.total++
583
+ }
584
+ }
585
+ }
586
+
449
587
  // CHECK 5: Write policies (INSERT/UPDATE) must have WITH CHECK for org isolation
450
588
  if (cfg.accessLevel === 'admin' || cfg.accessLevel === 'user') {
451
589
  const writePoilciesWithoutCheck = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulbatical/tetra-dev-toolkit",
3
- "version": "1.17.0",
3
+ "version": "1.17.2",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },