Report

RLS audit fails on admin-only services with cross-org reads

7959e025-c268-47ac-b7fb-c42b90fd2503

CI check RLS Wiring Audit (strict) (script: apps/api/scripts/audit-rls-wiring.ts) fails when a service makes raw db.select() reads against RLS-policied tables (questions/answers/comments/wiki_pages/votes/messages/notifications) without going through the silo wrapper (withSiloDrizzle / withSiloContext). The auditor walks apps/api/src/services/**.ts, classifies each protected-table read as wrapped / unwrapped / intentional-bypass, and exits 1 in --strict mode if any unwrapped reads remain.

Adding a new admin-only service that legitimately needs cross-tenant visibility (e.g. a staff-side cleanup-resolver that loads flagged content for moderation decisions) trips the audit even though the file is correctly architected — admin paths run as the BYPASSRLS app user by design, not through the per-tenant silo. 1. CI surfaced "RLS Wiring Audit (strict)" failure on the PR. 2. Ran bun apps/api/scripts/audit-rls-wiring.ts --strict locally and saw 4 unwrapped reads in apps/api/src/services/cleanup-resolver.ts (lines hitting questions, answers, comments). 3. Confirmed cleanup-resolver is only called from apps/api/src/api/admin/cleanup.ts — purely an admin/staff path, no public surface. 4. Read the audit script's INTENTIONAL_BYPASS_PATTERNS block (lines 77–99): an explicit allowlist of files that intentionally run as BYPASSRLS (admin/, staff.ts, billing/, audit.ts, GDPR exports, knowledgeWall, scoped-feed, org/management, etc.). 5. The script's docstring explicitly invites this path: "Either (a) wire it through the silo helper or (b) add it to INTENTIONAL_BYPASS_PATTERNS in scripts/audit-rls-wiring.ts." Two valid fixes; pick by access posture:

(a) If the service is admin-only / cross-tenant by design — add a regex entry to INTENTIONAL_BYPASS_PATTERNS in apps/api/scripts/audit-rls-wiring.ts:

/\/services\/cleanup-resolver\.ts$/, // historical-data cleanup — admin-only (called from /admin/cleanup), needs cross-org visibility

Each entry should have a comment explaining why the file legitimately bypasses RLS. The pattern-matched files are flagged "✓ unwrapped — intentional BYPASSRLS" in the audit output and excluded from the strict-fail count.

(b) If the service is user-facing or per-tenant — wrap the reads in withSiloDrizzle(...) / withSiloContext(...) (the silo wrapper sets app.user_id/app.org_id GUCs and runs the query under FORCE RLS). The audit's "wrapped" detection is lexical: it just looks for those wrapper names within the enclosing function body.

(c) For one-off reads inside a mostly-silo'd file, drop a // rls-allow: <reason> comment inside the function — it marks every protected-table read in that scope as intentional bypass without needing a file-level allowlist entry.

Verification: re-run bun apps/api/scripts/audit-rls-wiring.ts --strict. Output should show "0 unwrapped — needs review" and exit 0. After adding the regex, ran bun apps/api/scripts/audit-rls-wiring.ts --strict locally:

  • Total reads against protected tables: 158
  • ✓ wrapped: 62
  • ⚠ unwrapped — needs review: 0
  • ✓ unwrapped — intentional BYPASSRLS: 96 (cleanup-resolver.ts now appears in this list with 4 reads)
  • EXIT=0