Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<!-- END:supabase-project-safety -->
Expand Down
11 changes: 9 additions & 2 deletions docs/disaster-recovery-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/process-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
135 changes: 135 additions & 0 deletions scripts/check-hosted-migration-role.mjs
Original file line number Diff line number Diff line change
@@ -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();
22 changes: 20 additions & 2 deletions scripts/generate-drift-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 5 additions & 5 deletions scripts/sql/drift-replay-scaffold.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading
Loading