Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions data/repo-awareness-snapshot.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"version": "repo-awareness-snapshot-v2",
"captured_revision": {
"sha": "cdfb3fdda5533c126e76fecaf4e0352a6757249f",
"committed_at": "2026-09-02T11:12:52+00:00"
"sha": "265cfd2bd52c3e163b318acca891b1d0f71f56f8",
"committed_at": "2026-09-02T09:16:58+00:00"
},
"routes": {
"modes": [
Expand Down
171 changes: 160 additions & 11 deletions docs/audit/tenancy-defense-in-depth-review.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/scripts-index.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Scripts index

Curated map of `scripts/` (285 files) and the `package.json` script surface (289 entries),
Curated map of `scripts/` (286 files) and the `package.json` script surface (289 entries),
grouped by purpose. This is orientation, not an exhaustive per-file listing — the authoritative
command list is `package.json`, and `npm run docs:check-scripts` verifies every `npm run <x>`
referenced in docs resolves to a real script. `npm run docs:update` refreshes the exact counts above.
Expand Down
121 changes: 106 additions & 15 deletions scripts/check-owner-scope-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,35 @@
// 0/33 route gaps, but flagged (§6 item 2) that a *future* handler dropping the owner
// filter is the single regression class this design is exposed to.
//
// This guard closes that class statically: it fails when a `src/app/api/**` handler
// queries an OWNER-SCOPED table (any table with an `owner_id` column in
// This guard closes that class statically. It runs in TWO phases, and both must pass:
//
// PHASE 1 (this file) — the handler-level regex sweep. It fails when a `src/app/api/**`
// handler queries an OWNER-SCOPED table (any table with an `owner_id` column in
// supabase/schema.sql) without a recognised owner-scoping construct in the enclosing
// handler — `.eq("owner_id"...)`, `withOwnerReadScope`, `requireOwnerScope`,
// `requireOwnedDocument`/`loadOwnedDocument`/`ownedDocumentId`, a `documents!inner`
// + `documents.owner_id` join, or an `owner_id:` write payload. Intentional
// exceptions (indirect scoping the reviewer confirmed safe) live in
// OWNER_SCOPE_ALLOWLIST with a reason.
// OWNER_SCOPE_ALLOWLIST with a reason. This phase is deliberately coarse: it does not
// parse TypeScript, it considers only owner_id-bearing tables, and it attributes scope
// per HANDLER rather than per query. It is kept as a cheap, independent second opinion.
//
// PHASE 2 (scripts/lib/tenancy-scan.mjs) — the mechanical AST scan, shared verbatim with
// tests/retrieval-owner-filter-guard.test.ts so the two guards cannot drift apart. It is
// strictly stronger than phase 1 on every axis phase 1 covers, and it closes five things
// phase 1 structurally cannot see (see that module's header for the full rationale):
// A join-through tables (`document_chunks`, `document_pages`, `document_images`,
// `ingestion_jobs`, …) have no `owner_id` column, so phase 1 ignores them entirely —
// including in its own self-test below, which still asserts `document_chunks` is not
// flagged. Phase 2 gives them a declared, reviewed inventory instead.
// B `user_id` tenancy (`user_favourites`, `user_favourite_sets`, `user_preferences`).
// C scope attributed per QUERY CHAIN, not per function.
// D a wider file set: every `.ts` under `src/app/api` plus a named list of server-side
// read modules outside it.
// E the one dynamic `.rpc()` dispatcher, `callVersionedRetrievalRpc`.
// The two phases share no code and no allowlist; a table tier in one must never contradict
// the other, which is why phase 2 derives its tiers from src/lib/supabase/database.types.ts
// rather than restating them here.
//
// Usage:
// node scripts/check-owner-scope-api.mjs scan the repo; exit 1 on any violation
Expand All @@ -24,6 +45,8 @@ import { readFileSync, realpathSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";

import { emptyTierNames, scanRpcDispatch, scanTenancy } from "./lib/tenancy-scan.mjs";

// Recognised owner-scoping constructs. If any appears in the enclosing handler of an
// owner-scoped `.from(...)`, that query is considered scoped. `owner_id` (as a substring)
// covers `.eq("owner_id"...)`, `.is("owner_id"...)`, `.or("owner_id.eq...")`, insert/update
Expand Down Expand Up @@ -269,7 +292,10 @@ function runSelfTest() {
"nested helper / sibling handler must not mask an unscoped query",
);

// A non-owner-scoped table is not the guard's concern.
// A non-owner-scoped table is not PHASE 1's concern — it has no owner_id column to filter
// on. This is blind spot A, and it is closed by phase 2's derived-tier inventory, not by
// widening this regex sweep. Do not "fix" this assertion; changing it would only make
// phase 1 flag every join-through query with no way to describe why one is safe.
const otherTable = `export async function GET() {
const { data } = await supabase.from("document_chunks").select("*");
return data;
Expand All @@ -281,12 +307,34 @@ function runSelfTest() {
const parsed = ownerScopedTablesFromSchema(schema);
expect(parsed.has("documents") && !parsed.has("document_images"), "schema parse: owner_id tables only");

// Phase 2 sanity: the shared scanner must classify the three tiers and must not have been
// reduced to a no-op. Its per-rule pass/fail fixtures live in
// tests/retrieval-owner-filter-guard.test.ts, which drives the same exported functions.
try {
const scan = scanTenancy(process.cwd());
expect(scan.counts.direct > 0, "phase 2: found no direct-tier queries");
expect(scan.counts.userKeyed > 0, "phase 2: found no user-keyed queries");
expect(scan.counts.derived > 0, "phase 2: found no derived-tier queries");
expect(scan.tiers.derived.has("document_chunks"), "phase 2: document_chunks must be a derived-tier table");
expect(!scan.tiers.direct.has("document_chunks"), "phase 2: document_chunks must not be a direct-tier table");
expect(scanRpcDispatch(process.cwd()).dispatcherCallSites.length > 0, "phase 2: found no versioned-RPC call sites");
// The anti-vacuous rule main() applies: an emptied tier (a database.types.ts reformat
// defeats the indentation-anchored parse) must be reported, not exited 0 on.
expect(emptyTierNames(scan.counts).length === 0, "phase 2: live scan has an empty tier");
expect(
emptyTierNames({ direct: 0, userKeyed: 0, derived: 0 }).length === 3,
"phase 2: an empty scan must be reported as vacuous, not clean",
);
} catch (error) {
failures.push(`phase 2 self-test threw: ${error instanceof Error ? error.message : String(error)}`);
}

if (failures.length > 0) {
console.error("✗ owner-scope guard self-test FAILED:");
for (const f of failures) console.error(` - ${f}`);
process.exit(1);
}
console.log("✓ owner-scope guard self-test passed.");
console.log("✓ owner-scope guard self-test passed (phase 1 fixtures + phase 2 scanner sanity).");
}

function main() {
Expand All @@ -298,24 +346,67 @@ function main() {
const files = readTrackedApiFiles();
const { ownerTables, violations } = scanRepo({ schemaText, files });

let failed = false;

if (violations.length === 0) {
console.log(
`✓ owner-scope: ${files.length} src/app/api files clean against ${ownerTables.size} owner-scoped tables.`,
`✓ owner-scope phase 1: ${files.length} src/app/api files clean against ${ownerTables.size} owner-scoped tables.`,
);
} else {
failed = true;
console.error(
`✗ owner-scope phase 1: ${violations.length} query(ies) on owner-scoped tables lack an owner filter:\n`,
);
for (const v of violations) {
console.error(
` ${v.file}:${v.line} .from("${v.table}") — no owner_id / withOwnerReadScope / owned-doc guard in this handler`,
);
}
console.error(
'\nScope the query (.eq("owner_id", …) or withOwnerReadScope/requireOwnedDocument), or, if ownership is enforced\n' +
"indirectly and reviewed, add a documented entry to OWNER_SCOPE_ALLOWLIST in scripts/check-owner-scope-api.mjs.",
);
process.exit(0);
}

console.error(`✗ owner-scope: ${violations.length} query(ies) on owner-scoped tables lack an owner filter:\n`);
for (const v of violations) {
// Phase 2: the shared mechanical scan (per-chain scope, three tiers, wider file set).
const tenancy = scanTenancy(process.cwd());
const rpc = scanRpcDispatch(process.cwd());

// ANTI-VACUOUS: the tier derivation parses src/lib/supabase/database.types.ts with
// indentation-anchored patterns, so a reformat of that generated file empties every tier —
// and an empty tier set means zero sites, zero violations and a green exit. Without these
// assertions the shipped gate could print "0 direct, 0 user-keyed and 0 derived-tier
// queries" and pass. A scan that finds nothing is a broken scan, never a clean repo.
const emptyTiers = emptyTierNames(tenancy.counts);
const vacuous = emptyTiers.length > 0;
if (vacuous) {
failed = true;
console.error(
` ${v.file}:${v.line} .from("${v.table}") — no owner_id / withOwnerReadScope / owned-doc guard in this handler`,
`\n✗ owner-scope phase 2: found NO ${emptyTiers.join(", ")} queries. The scan is vacuous — most likely the\n` +
" src/lib/supabase/database.types.ts tier parse stopped matching (its patterns are anchored to the\n" +
" generated file's exact indentation). Fix the parse; do not treat an empty scan as a clean repo.",
);
}
console.error(
'\nScope the query (.eq("owner_id", …) or withOwnerReadScope/requireOwnedDocument), or, if ownership is enforced\n' +
"indirectly and reviewed, add a documented entry to OWNER_SCOPE_ALLOWLIST in scripts/check-owner-scope-api.mjs.",
);
process.exit(1);

if (!vacuous && tenancy.violations.length === 0 && rpc.violations.length === 0) {
console.log(
`✓ owner-scope phase 2: ${tenancy.counts.direct} direct, ${tenancy.counts.userKeyed} user-keyed, ` +
`${tenancy.counts.derived} derived-tier, ${tenancy.counts.untiered} untiered-table and ` +
`${tenancy.counts.dynamicFrom} dynamic-table-dispatch queries scoped ` +
`on their chain or declared; ${rpc.dispatcherCallSites.length} versioned-RPC call sites, all literal.`,
);
} else if (tenancy.violations.length > 0 || rpc.violations.length > 0) {
failed = true;
console.error(`\n✗ owner-scope phase 2: ${tenancy.violations.length + rpc.violations.length} finding(s):\n`);
for (const v of [...tenancy.violations, ...rpc.violations]) console.error(` ${v}\n`);
console.error(
"Put the tenancy predicate on the query's own chain, or add a reviewed entry to SCOPE_EXEMPTIONS /\n" +
"DERIVED_QUERY_INVENTORY / UNTIERED_TABLE_DECLARATIONS in scripts/lib/tenancy-scan.mjs AND to the tables in\n" +
"docs/audit/tenancy-defense-in-depth-review.md §6 (a committed test checks both).",
);
}

process.exit(failed ? 1 : 0);
}

// Only run the scan when executed directly (not when imported by the test suite).
Expand Down
Loading
Loading