@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 = [
|