diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2e04d57e..680b02f32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,6 +240,11 @@ jobs: - name: Design-system contract run: npm run check:design-system-contract + # Prevent hosted SQL/tooling from assuming a platform-reserved role while + # byte-pinning the single immutable historical migration exception. + - name: Hosted migration-role guard + run: npm run check:migration-role + # Fails if a SECURITY DEFINER public function is left executable by # PUBLIC/anon (privilege-escalation / cross-tenant read surface). - name: Function-grant guard diff --git a/AGENTS.md b/AGENTS.md index 85bc1edef..15a9245cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -214,6 +214,9 @@ action must perform one; a page that ships must be reachable. - This repo targets the live Supabase project `Clinical KB Database`. - Expected project ref: `sjrfecxgysukkwxsowpy`. - Older unused project ref `qjgitjyhxrwxsrydablr` belongs to `Database`; treat it as stale and do not use it. +- Hosted migrations, `supabase/schema.sql`, `supabase/roles.sql`, CI, and deployment tooling must target role `postgres`; never assume a platform-reserved role. The single older applied migration is immutable and pinned by `npm run check:migration-role`. +- Bare-image storage scaffolding must discover its local schema owner at runtime and must never be reused as hosted migration SQL. +- Run `npm run check:migration-role` after changing Supabase SQL, migration tooling, CI replay, or disaster-recovery instructions. - Run `npm run check:supabase-project` after changing Supabase env values. diff --git a/docs/disaster-recovery-runbook.md b/docs/disaster-recovery-runbook.md index 05943ef56..2ab890a0e 100644 --- a/docs/disaster-recovery-runbook.md +++ b/docs/disaster-recovery-runbook.md @@ -29,9 +29,16 @@ docker run -d --name kb-restore -e POSTGRES_PASSWORD=postgres -p 56543:5432 supa docker exec kb-restore pg_isready -U postgres # wait until ready # 2. Storage scaffold (bare image ships an empty storage schema; the hosted -# platform provisions the real one). Run as supabase_admin. +# platform provisions the real one). Discover the local image owner instead +# of hard-coding a platform-reserved role; never use this scaffold on hosted. docker cp scripts/sql/drift-replay-scaffold.sql kb-restore:/tmp/scaffold.sql -docker exec kb-restore psql -U supabase_admin -d postgres -v ON_ERROR_STOP=1 -f /tmp/scaffold.sql +storage_owner="$( + docker exec kb-restore psql -U postgres -d postgres -tAc \ + "select pg_catalog.pg_get_userbyid(nspowner) from pg_catalog.pg_namespace where nspname = 'storage'" +)" +test -n "${storage_owner}" +docker exec kb-restore psql -U "${storage_owner}" -d postgres -v ON_ERROR_STOP=1 -f /tmp/scaffold.sql +unset storage_owner # 3. Replay the canonical schema as postgres (matches how live is administered) docker cp supabase/schema.sql kb-restore:/tmp/schema.sql diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 0ffac0d6b..d3859d469 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -327,7 +327,7 @@ hybrid:10}`, all 10 forced-embedding vector cases passed (`force_embedding_failu ## 2026-07-13 audit remediation batch (branch claude/audit-remediation-2026-07-13) - **Lexical retrieval rewrite (audit finding 1):** `20260713100000_index_friendly_lexical_retrieval.sql` splits `match_document_chunks_text`'s OR-across-relations candidate search into two GIN-index probes unioned by chunk id (same contract, same scores; the `_v2` wrapper inherits the speedup). Parity + plan + timing harness: `scripts/sql/lexical-rpc-parity-check.sql` (scratch databases only; run it against the drift-manifest container kept with `--keep`). Re-run it whenever either lexical body changes. -- **Hosted migration-role default privileges (audit finding 7):** `supabase/roles.sql` establishes and verifies secure `postgres` defaults before fresh local migration replay. `20260717161000_assert_postgres_default_privileges.sql` evaluates effective defaults from `pg_default_acl` plus `acldefault`, so a missing catalog row cannot hide PostgreSQL's built-in `PUBLIC EXECUTE` on functions. `20260717173000_reassert_postgres_default_privileges.sql` repeats the fail-closed postcondition, and `20260719053532_repair_postgres_default_privileges.sql` is the forward-only hosted repair immediately before the final title-word scope migration. All active revokes and assertions target objects created by `postgres` in `public`; service-role grants remain least-privilege. Older applied migration history is immutable. After an authorized hosted apply, run the read-only provider check with `npm run check:default-acl -- --confirm-provider-read`. This command contacts the live Supabase project and therefore requires explicit provider approval. +- **Hosted migration-role default privileges (audit finding 7):** `supabase/roles.sql` establishes and verifies secure `postgres` defaults before fresh local migration replay. `20260717161000_assert_postgres_default_privileges.sql` evaluates effective defaults from `pg_default_acl` plus `acldefault`, so a missing catalog row cannot hide PostgreSQL's built-in `PUBLIC EXECUTE` on functions. `20260717173000_reassert_postgres_default_privileges.sql` repeats the fail-closed postcondition, and `20260719053532_repair_postgres_default_privileges.sql` is the forward-only hosted repair immediately before the final title-word scope migration. All active revokes and assertions target objects created by `postgres` in `public`; service-role grants remain least-privilege. Older applied migration history is immutable and byte-pinned by `npm run check:migration-role`; the same guard rejects the reserved role in every other hosted SQL/tooling surface. Bare Docker recovery discovers the storage-schema owner dynamically, keeping that local bootstrap concern out of hosted SQL. After an authorized hosted apply, run the read-only provider check with `npm run check:default-acl -- --confirm-provider-read`. This command contacts the live Supabase project and therefore requires explicit provider approval. - **Legacy rag query text scrub (audit finding 5):** `20260713103000_scrub_legacy_rag_query_text.sql` performs four redaction/deletion operations for pre-HMAC plaintext query text: (1) scrubs `rag_queries.query` rows not matching `redacted-query:%`, replacing them with salted `redacted-query:legacy:` placeholders; (2) scrubs both `rag_query_misses.query` and `rag_query_misses.normalized_query` not matching `redacted-query:%`; (3) scrubs both `rag_retrieval_logs.query` and `rag_retrieval_logs.normalized_query` not matching `redacted-query:%` (nullable); (4) deletes `rag_response_cache` rows where `normalized_query` does not match `redacted-cache:%` (cache entries, not re-keyed). **Operator verification after live apply:** for each affected table/operation, count rows not matching the expected redacted pattern (expect 0 unless `RAG_PERSIST_RAW_QUERY_TEXT` is deliberately enabled): `select count(*) from rag_queries where query not like 'redacted-query:%';` (expect 0), `select count(*) from rag_query_misses where query not like 'redacted-query:%' or normalized_query not like 'redacted-query:%';` (expect 0), `select count(*) from rag_retrieval_logs where query not like 'redacted-query:%' or (normalized_query is not null and normalized_query not like 'redacted-query:%');` (expect 0), `select count(*) from rag_response_cache where normalized_query not like 'redacted-cache:%';` (expect 0). The migration includes a post-apply assertion block that enforces these exact checks and fails the migration if any unscrubbed/undeleted rows remain. - Remaining operator items from the 2026-07-13 audit (confirmation-required, not automated): apply this batch's migrations live, re-run Supabase advisors, trigger the Eval Canary and require two consecutive green scheduled runs, repair the invalid `storage.idx_objects_bucket_id_name_lower` index via dashboard/support, and dedupe response-cache purge cron jobs if `cron.job` shows duplicates. diff --git a/package.json b/package.json index 30e81acc0..08f1e74a0 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "test:e2e:visual": "node scripts/run-playwright.mjs --config=playwright.visual.config.ts", "test:cross-tenant:staging": "node scripts/run-tsx.mjs scripts/test-cross-tenant-staging.ts", "verify:cheap": "node scripts/run-heavy.mjs --npm-script verify:cheap:internal", - "verify:cheap:internal": "npm run check:runtime && npm run check:github-actions && npm run check:ci-scope && npm run check:ci-triage && npm run check:pr-policy && npm run check:gate-manifest && npm run sitemap:check && npm run docs:check-index && npm run check:knip && npm run check:maintainability-budgets && npm run brand:check && npm run check:therapy-data-index && npm run check:type-scale && npm run check:icon-scale && npm run check:design-system-contract && npm run check:function-grants && npm run check:owner-scope && npm run lint && npm run typecheck && npm run test", + "verify:cheap:internal": "npm run check:runtime && npm run check:github-actions && npm run check:ci-scope && npm run check:ci-triage && npm run check:pr-policy && npm run check:gate-manifest && npm run sitemap:check && npm run docs:check-index && npm run check:knip && npm run check:maintainability-budgets && npm run brand:check && npm run check:therapy-data-index && npm run check:type-scale && npm run check:icon-scale && npm run check:design-system-contract && npm run check:migration-role && npm run check:function-grants && npm run check:owner-scope && npm run lint && npm run typecheck && npm run test", "verify:pr-local": "node scripts/verify-pr-local.mjs", "verify:ui": "npm run check:runtime && npm run test:e2e:pr", "verify:release": "npm run check:runtime && npm run lint && npm run typecheck && npm run test && npm run build && npm run test:e2e && npm run check:production-readiness && npm run governance:release && npm run eval:quality:release", @@ -110,6 +110,7 @@ "check:type-scale": "node scripts/check-type-scale.mjs --strict", "check:icon-scale": "node scripts/check-icon-scale.mjs --strict", "check:design-system-contract": "node scripts/check-design-system-contract.mjs", + "check:migration-role": "node scripts/check-hosted-migration-role.mjs", "check:function-grants": "node scripts/check-function-grants.mjs", "check:owner-scope": "node scripts/check-owner-scope-api.mjs", "recover:ingestion": "node scripts/run-tsx.mjs scripts/recover-ingestion-queue.ts", diff --git a/scripts/check-hosted-migration-role.mjs b/scripts/check-hosted-migration-role.mjs new file mode 100644 index 000000000..8519f7b09 --- /dev/null +++ b/scripts/check-hosted-migration-role.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const RESERVED_HOSTED_ROLE = ["supabase", "admin"].join("_"); +export const IMMUTABLE_HISTORICAL_MIGRATION = [ + "supabase", + "migrations", + `20260713102000_revoke_${RESERVED_HOSTED_ROLE}_default_privileges.sql`, +].join("/"); +export const IMMUTABLE_HISTORICAL_SHA256 = "39a5f310f2207aed473b92128dd414d4fd8903d56d5802a3d749f81850cca541"; + +const GUARDED_EXACT_PATHS = new Set([ + "AGENTS.md", + "package.json", + "supabase/schema.sql", + "supabase/roles.sql", + "docs/disaster-recovery-runbook.md", +]); +const GUARDED_PATH_PREFIXES = [".github/actions/", ".github/workflows/", "scripts/", "supabase/migrations/"]; +const RESERVED_ROLE_PATTERN = new RegExp( + `(^|[^A-Za-z0-9_])${RESERVED_HOSTED_ROLE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}($|[^A-Za-z0-9_])`, + "i", +); + +function normalizePath(filePath) { + return String(filePath).replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function sha256(content) { + return createHash("sha256").update(content).digest("hex"); +} + +export function isGuardedMigrationRolePath(filePath) { + const normalized = normalizePath(filePath); + return GUARDED_EXACT_PATHS.has(normalized) || GUARDED_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix)); +} + +function referenceLines(content) { + return content + .split(/\r?\n/) + .map((line, index) => ({ line, lineNumber: index + 1 })) + .filter(({ line }) => RESERVED_ROLE_PATTERN.test(line)) + .map(({ lineNumber }) => lineNumber); +} + +export function validateMigrationRoleEntries(entries, { requireHistorical = true } = {}) { + const failures = []; + let sawHistoricalMigration = false; + + for (const entry of entries) { + const filePath = normalizePath(entry.path); + + if (filePath === IMMUTABLE_HISTORICAL_MIGRATION) { + sawHistoricalMigration = true; + if (entry.content === null || entry.content === undefined) { + failures.push(`${filePath}: immutable applied migration is missing`); + continue; + } + const content = Buffer.isBuffer(entry.content) ? entry.content : Buffer.from(entry.content); + const actualHash = sha256(content); + if (actualHash !== IMMUTABLE_HISTORICAL_SHA256) { + failures.push( + `${filePath}: immutable applied migration changed (expected SHA-256 ${IMMUTABLE_HISTORICAL_SHA256}, got ${actualHash})`, + ); + } + continue; + } + + if (!isGuardedMigrationRolePath(filePath)) continue; + + if (filePath.toLowerCase().includes(RESERVED_HOSTED_ROLE)) { + failures.push(`${filePath}: active file name references the reserved hosted role`); + } + + if (entry.content === null || entry.content === undefined) continue; + const content = Buffer.isBuffer(entry.content) ? entry.content : Buffer.from(entry.content); + if (content.includes(0)) continue; + const lines = referenceLines(content.toString("utf8")); + if (lines.length > 0) { + failures.push( + `${filePath}:${lines.join(",")}: active content references the reserved hosted role; hosted SQL and tooling must use postgres`, + ); + } + } + + if (requireHistorical && !sawHistoricalMigration) { + failures.push(`${IMMUTABLE_HISTORICAL_MIGRATION}: immutable applied migration is missing`); + } + + return failures; +} + +export function repositoryEntries(repoRoot = process.cwd()) { + const output = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], { + cwd: repoRoot, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + }); + + return output + .split("\0") + .filter(Boolean) + .map((filePath) => ({ + path: normalizePath(filePath), + content: existsSync(path.join(repoRoot, filePath)) ? readFileSync(path.join(repoRoot, filePath)) : null, + })); +} + +export function validateRepository(repoRoot = process.cwd()) { + return validateMigrationRoleEntries(repositoryEntries(repoRoot)); +} + +function main() { + const failures = validateRepository(); + if (failures.length > 0) { + console.error("Hosted migration-role guard failed:"); + for (const failure of failures) console.error(`- ${failure}`); + console.error( + `Only ${IMMUTABLE_HISTORICAL_MIGRATION} may retain the legacy role reference, and its bytes are pinned. ` + + "Use role postgres for hosted migrations, schema snapshots, CI, and deployment tooling.", + ); + process.exit(1); + } + + console.log( + "Hosted migration-role guard passed: active hosted SQL/tooling uses postgres and immutable applied history is unchanged.", + ); +} + +const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ""; +if (invokedPath === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/generate-drift-manifest.ts b/scripts/generate-drift-manifest.ts index 632c4f72c..bc559a90d 100644 --- a/scripts/generate-drift-manifest.ts +++ b/scripts/generate-drift-manifest.ts @@ -97,8 +97,26 @@ async function main() { console.log("Applying role bootstrap (postgres)…"); psql("postgres", rolesSql); - console.log("Applying storage scaffold (supabase_admin)…"); - psql("supabase_admin", scaffoldSql); + const storageSchemaOwner = docker([ + "exec", + "-i", + container, + "psql", + "-U", + "postgres", + "-d", + "postgres", + "-tA", + "-v", + "ON_ERROR_STOP=1", + "-c", + "select pg_catalog.pg_get_userbyid(nspowner) from pg_catalog.pg_namespace where nspname = 'storage';", + ]).trim(); + if (!storageSchemaOwner) { + throw new Error("bare Supabase image does not expose an owner for the storage schema"); + } + console.log("Applying storage scaffold as the discovered bare-image storage owner…"); + psql(storageSchemaOwner, scaffoldSql); console.log("Replaying supabase/schema.sql from scratch (postgres)…"); psql("postgres", schemaSql); const replaySeconds = ((Date.now() - startedAt) / 1000).toFixed(0); diff --git a/scripts/sql/drift-replay-scaffold.sql b/scripts/sql/drift-replay-scaffold.sql index efbd8bad2..3c39c5c6f 100644 --- a/scripts/sql/drift-replay-scaffold.sql +++ b/scripts/sql/drift-replay-scaffold.sql @@ -14,11 +14,11 @@ -- has the real storage schema, and every statement here is create-if-missing, -- but there is no reason for it to touch live). -- --- Run as `supabase_admin` (the image's superuser): the bare image ships the --- `storage` schema owned by supabase_admin, and `postgres` cannot create in --- it. Ownership of the scaffold tables is handed to `postgres` afterwards so --- schema.sql (applied as `postgres`, matching how live is administered) can --- insert bucket rows and create the storage.objects policies. +-- The Docker wrapper discovers the bare image's storage-schema owner at +-- runtime and uses it only for this local scaffold. Hosted migrations never +-- assume a platform-reserved role. Ownership of the scaffold tables is handed +-- to `postgres` afterwards so schema.sql (applied as `postgres`, matching +-- how live is administered) can insert bucket rows and create policies. create schema if not exists storage; diff --git a/tests/hosted-migration-role-guard.test.ts b/tests/hosted-migration-role-guard.test.ts new file mode 100644 index 000000000..c28bc81bc --- /dev/null +++ b/tests/hosted-migration-role-guard.test.ts @@ -0,0 +1,86 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + IMMUTABLE_HISTORICAL_MIGRATION, + RESERVED_HOSTED_ROLE, + isGuardedMigrationRolePath, + validateMigrationRoleEntries, + validateRepository, +} from "../scripts/check-hosted-migration-role.mjs"; + +describe("hosted migration-role guard", () => { + it("accepts the current repository state", () => { + expect(validateRepository()).toEqual([]); + }); + + it("accepts the pinned immutable migration and rejects modifications or removal", () => { + const historicalContent = readFileSync(IMMUTABLE_HISTORICAL_MIGRATION); + + expect( + validateMigrationRoleEntries([{ path: IMMUTABLE_HISTORICAL_MIGRATION, content: historicalContent }]), + ).toEqual([]); + expect( + validateMigrationRoleEntries([ + { path: IMMUTABLE_HISTORICAL_MIGRATION, content: Buffer.concat([historicalContent, Buffer.from("\n")]) }, + ]), + ).toEqual([expect.stringContaining("immutable applied migration changed")]); + expect(validateMigrationRoleEntries([])).toEqual([ + expect.stringContaining("immutable applied migration is missing"), + ]); + }); + + it("rejects the reserved role token in active hosted content and file names", () => { + const contentFailures = validateMigrationRoleEntries( + [ + { + path: "supabase/migrations/20990101000000_bad_default_privileges.sql", + content: `alter default privileges for role ${RESERVED_HOSTED_ROLE.toUpperCase()} revoke all on tables from public;`, + }, + ], + { requireHistorical: false }, + ); + const pathFailures = validateMigrationRoleEntries( + [ + { + path: `supabase/migrations/20990101000000_${RESERVED_HOSTED_ROLE}_repair.sql`, + content: "select 1;", + }, + ], + { requireHistorical: false }, + ); + + expect(contentFailures).toEqual([expect.stringContaining("active content references")]); + expect(pathFailures).toEqual([expect.stringContaining("active file name references")]); + }); + + it("limits enforcement to hosted SQL/tooling and does not confuse environment-variable names with SQL roles", () => { + expect(isGuardedMigrationRolePath("scripts/generate-drift-manifest.ts")).toBe(true); + expect(isGuardedMigrationRolePath("docs/disaster-recovery-runbook.md")).toBe(true); + expect(isGuardedMigrationRolePath("docs/branch-review-ledger.md")).toBe(false); + + expect( + validateMigrationRoleEntries( + [ + { + path: "scripts/set-site-administrator.ts", + content: `process.env.ALLOW_${RESERVED_HOSTED_ROLE.toUpperCase()}_MUTATION`, + }, + { path: "docs/branch-review-ledger.md", content: `Historical reference: ${RESERVED_HOSTED_ROLE}` }, + ], + { requireHistorical: false }, + ), + ).toEqual([]); + }); + + it("discovers the bare-image storage owner instead of hard-coding a hosted role", () => { + const generator = readFileSync("scripts/generate-drift-manifest.ts", "utf8"); + const runbook = readFileSync("docs/disaster-recovery-runbook.md", "utf8"); + + expect(generator).toContain("pg_catalog.pg_get_userbyid(nspowner)"); + expect(generator).toContain("psql(storageSchemaOwner, scaffoldSql)"); + expect(runbook).toContain("storage_owner="); + expect(runbook).toContain('psql -U "${storage_owner}"'); + expect(generator).not.toContain(RESERVED_HOSTED_ROLE); + expect(runbook).not.toContain(RESERVED_HOSTED_ROLE); + }); +});