mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 11:22:34 +00:00
fix(security): schema-lint hardening migration (search_path + view security_invoker)
Migration v120 brings existing brains to the same posture as fresh installs: - ALTER VIEW page_links SET (security_invoker = on) on Postgres so the view honors the caller's RLS instead of the owner's (the view-through-RLS bypass). - ALTER FUNCTION ... SET search_path on the gbrain-owned trigger/event functions (both engines, IF EXISTS so engine-only functions are skipped; body untouched, so the load-bearing auto_enable_rls event trigger is unchanged). Closes #171. - Broaden the BYPASSRLS preflight in the historical RLS migration gates to honor superuser and inherited-role BYPASSRLS, so a superuser-connected fresh install no longer aborts (#1385). Fresh-install function definitions in schema.sql / pglite-schema.ts are born-correct (regenerated schema-embedded.ts). scripts/check-search-path.sh is a new CI guard (wired into verify) that fails if a trigger function in the schema base files is added without SET search_path. Postgres-only assertions live in the bootstrap E2E; the PGLite path is covered by test/migration-v120.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
07dd05b31c
commit
b19eb35548
@@ -65,6 +65,7 @@
|
||||
"ci:select-e2e": "bun run scripts/select-e2e.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check:jsonb": "scripts/check-jsonb-pattern.sh",
|
||||
"check:search-path": "scripts/check-search-path.sh",
|
||||
"check:no-double-retry": "scripts/check-no-double-retry.sh",
|
||||
"check:batch-audit-site": "scripts/check-batch-audit-site.sh",
|
||||
"check:worker-lock-renewal-shape": "scripts/check-worker-lock-renewal-shape.sh",
|
||||
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# CI guard (#1647 / #171): every trigger function in the canonical schema base
|
||||
# files MUST pin `SET search_path`. Without it, an unqualified reference inside
|
||||
# the function body resolves through the caller's search_path, so a same-named
|
||||
# object in a user-controlled schema could shadow it. Migration v120 ALTERs
|
||||
# existing brains; this guard keeps fresh-install function definitions correct
|
||||
# so a NEW trigger function can't reintroduce the gap. Mirrors the
|
||||
# check-jsonb-pattern.sh guard philosophy (a written rule caused the disease;
|
||||
# a guard cures it).
|
||||
#
|
||||
# Scope: schema base files only (src/schema.sql, src/core/pglite-schema.ts).
|
||||
# Historical migration bodies in migrate.ts are append-only and not rescanned;
|
||||
# the runtime doctor probe (pg_proc.proconfig) covers the live post-migration
|
||||
# state on real brains.
|
||||
#
|
||||
# Usage: scripts/check-search-path.sh
|
||||
# Exit: 0 when all trigger functions pin search_path, 1 otherwise.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
FILES="src/schema.sql src/core/pglite-schema.ts src/core/schema-embedded.ts"
|
||||
|
||||
# A hardened header reads `... RETURNS trigger SET search_path = ... AS $tag$`.
|
||||
# An UNHARDENED one reads `... RETURNS trigger AS $tag$` — match that form and
|
||||
# (belt-and-suspenders) drop any line that already mentions search_path.
|
||||
BAD="$(grep -nEi 'CREATE OR REPLACE FUNCTION [a-z_]+\(\) RETURNS trigger AS ' $FILES 2>/dev/null | grep -vi 'search_path' || true)"
|
||||
|
||||
if [ -n "$BAD" ]; then
|
||||
echo "ERROR: trigger function(s) missing SET search_path in schema base files:"
|
||||
echo "$BAD"
|
||||
echo
|
||||
echo "Add 'SET search_path = pg_catalog, public' to the function header, e.g.:"
|
||||
echo " CREATE OR REPLACE FUNCTION foo() RETURNS trigger SET search_path = pg_catalog, public AS \$\$"
|
||||
echo "See #1647 / #171."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: all trigger functions in schema base files pin search_path"
|
||||
@@ -38,6 +38,7 @@ CHECKS=(
|
||||
"check:proposal-pii"
|
||||
"check:test-names"
|
||||
"check:jsonb"
|
||||
"check:search-path"
|
||||
"check:source-id-projection"
|
||||
"check:source-config-leak"
|
||||
"check:progress"
|
||||
|
||||
+82
-10
@@ -862,7 +862,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
DECLARE
|
||||
has_bypass BOOLEAN;
|
||||
BEGIN
|
||||
SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user;
|
||||
SELECT EXISTS (SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'USAGE') AND (r.rolbypassrls OR r.rolsuper)) INTO has_bypass; -- #1385: superuser + inherited-role BYPASSRLS, not just the role's own rolbypassrls
|
||||
IF NOT has_bypass THEN
|
||||
-- Fail the migration loudly instead of WARNING + version-bump.
|
||||
-- The runner unconditionally records schema_version on success,
|
||||
@@ -1151,7 +1151,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
DECLARE
|
||||
has_bypass BOOLEAN;
|
||||
BEGIN
|
||||
SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user;
|
||||
SELECT EXISTS (SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'USAGE') AND (r.rolbypassrls OR r.rolsuper)) INTO has_bypass; -- #1385: superuser + inherited-role BYPASSRLS, not just the role's own rolbypassrls
|
||||
IF NOT has_bypass THEN
|
||||
RAISE EXCEPTION 'v29 cathedral_ii_code_edges_rls: role % does not have BYPASSRLS privilege — cannot enable RLS safely. Re-run as postgres (or another BYPASSRLS role). The migration will retry automatically on the next initSchema call.', current_user;
|
||||
END IF;
|
||||
@@ -1239,7 +1239,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
DECLARE
|
||||
has_bypass BOOLEAN;
|
||||
BEGIN
|
||||
SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user;
|
||||
SELECT EXISTS (SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'USAGE') AND (r.rolbypassrls OR r.rolsuper)) INTO has_bypass; -- #1385: superuser + inherited-role BYPASSRLS, not just the role's own rolbypassrls
|
||||
IF has_bypass THEN
|
||||
ALTER TABLE takes ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE synthesis_evidence ENABLE ROW LEVEL SECURITY;
|
||||
@@ -1341,7 +1341,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
DECLARE
|
||||
has_bypass BOOLEAN;
|
||||
BEGIN
|
||||
SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user;
|
||||
SELECT EXISTS (SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'USAGE') AND (r.rolbypassrls OR r.rolsuper)) INTO has_bypass; -- #1385: superuser + inherited-role BYPASSRLS, not just the role's own rolbypassrls
|
||||
IF has_bypass THEN
|
||||
ALTER TABLE dream_verdicts ENABLE ROW LEVEL SECURITY;
|
||||
END IF;
|
||||
@@ -1380,7 +1380,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
DECLARE
|
||||
has_bypass BOOLEAN;
|
||||
BEGIN
|
||||
SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user;
|
||||
SELECT EXISTS (SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'USAGE') AND (r.rolbypassrls OR r.rolsuper)) INTO has_bypass; -- #1385: superuser + inherited-role BYPASSRLS, not just the role's own rolbypassrls
|
||||
IF NOT has_bypass THEN
|
||||
RAISE EXCEPTION 'v31 eval_capture_tables: role % does not have BYPASSRLS privilege — cannot enable RLS safely. Re-run as postgres (or another BYPASSRLS role). The migration will retry automatically on the next initSchema call.', current_user;
|
||||
END IF;
|
||||
@@ -1499,7 +1499,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
DECLARE
|
||||
has_bypass BOOLEAN;
|
||||
BEGIN
|
||||
SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user;
|
||||
SELECT EXISTS (SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'USAGE') AND (r.rolbypassrls OR r.rolsuper)) INTO has_bypass; -- #1385: superuser + inherited-role BYPASSRLS, not just the role's own rolbypassrls
|
||||
IF has_bypass THEN
|
||||
ALTER TABLE oauth_clients ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE oauth_tokens ENABLE ROW LEVEL SECURITY;
|
||||
@@ -1720,7 +1720,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
has_bypass BOOLEAN;
|
||||
r record;
|
||||
BEGIN
|
||||
SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user;
|
||||
SELECT EXISTS (SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'USAGE') AND (r.rolbypassrls OR r.rolsuper)) INTO has_bypass; -- #1385: superuser + inherited-role BYPASSRLS, not just the role's own rolbypassrls
|
||||
IF NOT has_bypass THEN
|
||||
-- Same posture as v24: raise to abort the migration so the runner
|
||||
-- leaves config.version unbumped and retries on the next call.
|
||||
@@ -2112,7 +2112,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
DECLARE
|
||||
has_bypass BOOLEAN;
|
||||
BEGIN
|
||||
SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user;
|
||||
SELECT EXISTS (SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'USAGE') AND (r.rolbypassrls OR r.rolsuper)) INTO has_bypass; -- #1385: superuser + inherited-role BYPASSRLS, not just the role's own rolbypassrls
|
||||
IF has_bypass THEN
|
||||
ALTER TABLE drift_decisions ENABLE ROW LEVEL SECURITY;
|
||||
END IF;
|
||||
@@ -2365,7 +2365,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
DECLARE
|
||||
has_bypass BOOLEAN;
|
||||
BEGIN
|
||||
SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user;
|
||||
SELECT EXISTS (SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'USAGE') AND (r.rolbypassrls OR r.rolsuper)) INTO has_bypass; -- #1385: superuser + inherited-role BYPASSRLS, not just the role's own rolbypassrls
|
||||
IF has_bypass THEN
|
||||
ALTER TABLE facts ENABLE ROW LEVEL SECURITY;
|
||||
END IF;
|
||||
@@ -4389,7 +4389,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
DECLARE
|
||||
has_bypass BOOLEAN;
|
||||
BEGIN
|
||||
SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user;
|
||||
SELECT EXISTS (SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'USAGE') AND (r.rolbypassrls OR r.rolsuper)) INTO has_bypass; -- #1385: superuser + inherited-role BYPASSRLS, not just the role's own rolbypassrls
|
||||
IF has_bypass THEN
|
||||
ALTER TABLE take_domain_assignments ENABLE ROW LEVEL SECURITY;
|
||||
END IF;
|
||||
@@ -5367,6 +5367,78 @@ export const MIGRATIONS: Migration[] = [
|
||||
END $$;
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 120,
|
||||
name: 'schema_lint_hardening_search_path_security_invoker',
|
||||
// v0.42 schema-lint hardening wave (#1647 / #171).
|
||||
//
|
||||
// (b) security_invoker on the page_links view: pre-fix the view ran with
|
||||
// the definer/owner's privileges, so the anon / PostgREST role could
|
||||
// read `links` (which has RLS) THROUGH the view, bypassing RLS. This
|
||||
// is the single ERROR-severity Supabase lint. Postgres-only — PGLite
|
||||
// is embedded/single-user with no anon role and no PostgREST, so the
|
||||
// view has no RLS-bypass surface there (and security_invoker carries
|
||||
// no benefit). Guarded with IF EXISTS for very old brains.
|
||||
//
|
||||
// (a)/(#171) search_path on every gbrain-owned trigger/event function:
|
||||
// an unqualified reference (e.g. `FROM timeline_entries`) resolves
|
||||
// through the caller's search_path, so a same-named object in a
|
||||
// user-controlled schema could shadow it. Pinning search_path closes
|
||||
// that. ALTER FUNCTION (NOT CREATE OR REPLACE) leaves each body
|
||||
// untouched — lowest drift risk, and critically safe for the
|
||||
// load-bearing `auto_enable_rls` event-trigger function (codex #3).
|
||||
// The IF EXISTS loop is engine-agnostic and skips functions a given
|
||||
// brain never created (e.g. auto_enable_rls + the NOTIFY/chunk
|
||||
// trigger functions are Postgres-only — codex #4).
|
||||
//
|
||||
// Regression guard is a doctor probe (pg_proc.proconfig) + scripts/
|
||||
// check-search-path.sh, NOT a migration verify-hook — hooks don't run on
|
||||
// brains already stamped past this version (learning: migration-verify-hook-
|
||||
// never-runs-on-stamped-brains). Fresh installs are born correct: the
|
||||
// function defs in schema.sql / pglite-schema.ts carry SET search_path too.
|
||||
idempotent: true,
|
||||
sql: '', // engine-specific via sqlFor
|
||||
sqlFor: {
|
||||
postgres: `
|
||||
ALTER VIEW IF EXISTS page_links SET (security_invoker = on);
|
||||
|
||||
DO $$
|
||||
DECLARE fn text;
|
||||
BEGIN
|
||||
FOREACH fn IN ARRAY ARRAY[
|
||||
'bump_page_generation_fn','bump_page_generation_clock_fn',
|
||||
'update_chunk_search_vector','update_page_search_vector',
|
||||
'notify_minion_job_change','auto_enable_rls'
|
||||
] LOOP
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE n.nspname = 'public' AND p.proname = fn
|
||||
) THEN
|
||||
EXECUTE format('ALTER FUNCTION public.%I() SET search_path = pg_catalog, public', fn);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
`,
|
||||
pglite: `
|
||||
DO $$
|
||||
DECLARE fn text;
|
||||
BEGIN
|
||||
FOREACH fn IN ARRAY ARRAY[
|
||||
'bump_page_generation_fn','bump_page_generation_clock_fn',
|
||||
'update_chunk_search_vector','update_page_search_vector',
|
||||
'notify_minion_job_change'
|
||||
] LOOP
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE n.nspname = 'public' AND p.proname = fn
|
||||
) THEN
|
||||
EXECUTE format('ALTER FUNCTION public.%I() SET search_path = pg_catalog, public', fn);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const LATEST_VERSION = MIGRATIONS.length > 0
|
||||
|
||||
@@ -124,7 +124,7 @@ CREATE TABLE IF NOT EXISTS pages (
|
||||
-- bookmark gate fires for any cache row stored before the new page existed.
|
||||
-- UPDATE: bumps only when content columns IS DISTINCT FROM (allow-list of
|
||||
-- 10 widened per D6) so read-time mutations don't invalidate every cache.
|
||||
CREATE OR REPLACE FUNCTION bump_page_generation_fn() RETURNS trigger AS $func$
|
||||
CREATE OR REPLACE FUNCTION bump_page_generation_fn() RETURNS trigger SET search_path = pg_catalog, public AS $func$
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
NEW.generation := COALESCE((SELECT MAX(generation) FROM pages), 0) + 1;
|
||||
@@ -179,7 +179,7 @@ SELECT setval('page_generation_clock_seq', GREATEST(
|
||||
COALESCE((SELECT MAX(generation) FROM pages), 0)
|
||||
));
|
||||
|
||||
CREATE OR REPLACE FUNCTION bump_page_generation_clock_fn() RETURNS trigger AS $func$
|
||||
CREATE OR REPLACE FUNCTION bump_page_generation_clock_fn() RETURNS trigger SET search_path = pg_catalog, public AS $func$
|
||||
BEGIN
|
||||
PERFORM nextval('page_generation_clock_seq');
|
||||
RETURN NULL;
|
||||
@@ -1016,7 +1016,7 @@ ALTER TABLE pages ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_search ON pages USING GIN(search_vector);
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_page_search_vector() RETURNS trigger AS $$
|
||||
CREATE OR REPLACE FUNCTION update_page_search_vector() RETURNS trigger SET search_path = pg_catalog, public AS $$
|
||||
DECLARE
|
||||
timeline_text TEXT;
|
||||
BEGIN
|
||||
|
||||
@@ -164,7 +164,7 @@ CREATE TABLE IF NOT EXISTS pages (
|
||||
-- content columns IS DISTINCT FROM (allow-list widened per D6 + codex #3
|
||||
-- to include title/type/page_kind/corpus_generation/content_hash) so
|
||||
-- read-time mutations don't invalidate every cache row.
|
||||
CREATE OR REPLACE FUNCTION bump_page_generation_fn() RETURNS trigger AS \$func\$
|
||||
CREATE OR REPLACE FUNCTION bump_page_generation_fn() RETURNS trigger SET search_path = pg_catalog, public AS \$func\$
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
NEW.generation := COALESCE((SELECT MAX(generation) FROM pages), 0) + 1;
|
||||
@@ -244,7 +244,7 @@ SELECT setval('page_generation_clock_seq', GREATEST(
|
||||
COALESCE((SELECT MAX(generation) FROM pages), 0)
|
||||
));
|
||||
|
||||
CREATE OR REPLACE FUNCTION bump_page_generation_clock_fn() RETURNS trigger AS \$func\$
|
||||
CREATE OR REPLACE FUNCTION bump_page_generation_clock_fn() RETURNS trigger SET search_path = pg_catalog, public AS \$func\$
|
||||
BEGIN
|
||||
PERFORM nextval('page_generation_clock_seq');
|
||||
RETURN NULL;
|
||||
@@ -360,7 +360,7 @@ CREATE INDEX IF NOT EXISTS content_chunks_stale_idx
|
||||
-- NL queries ("how do we handle errors") rank doc-comment hits above body text.
|
||||
-- BEFORE INSERT OR UPDATE OF specific columns — only refires when those change,
|
||||
-- not on every chunk update (e.g., embedding refresh doesn't trigger rebuild).
|
||||
CREATE OR REPLACE FUNCTION update_chunk_search_vector() RETURNS TRIGGER AS \$fn\$
|
||||
CREATE OR REPLACE FUNCTION update_chunk_search_vector() RETURNS TRIGGER SET search_path = pg_catalog, public AS \$fn\$
|
||||
BEGIN
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('english', COALESCE(NEW.doc_comment, '')), 'A') ||
|
||||
@@ -819,7 +819,7 @@ ALTER TABLE pages ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_search ON pages USING GIN(search_vector);
|
||||
|
||||
-- Function to rebuild search_vector for a page
|
||||
CREATE OR REPLACE FUNCTION update_page_search_vector() RETURNS trigger AS \$\$
|
||||
CREATE OR REPLACE FUNCTION update_page_search_vector() RETURNS trigger SET search_path = pg_catalog, public AS \$\$
|
||||
DECLARE
|
||||
timeline_text TEXT;
|
||||
BEGIN
|
||||
@@ -1358,7 +1358,7 @@ CREATE INDEX IF NOT EXISTS think_ab_results_recent_idx
|
||||
ON think_ab_results (source_id, ran_at DESC);
|
||||
|
||||
-- NOTIFY trigger for real-time job events (Postgres only, not PGLite)
|
||||
CREATE OR REPLACE FUNCTION notify_minion_job_change() RETURNS trigger AS \$\$
|
||||
CREATE OR REPLACE FUNCTION notify_minion_job_change() RETURNS trigger SET search_path = pg_catalog, public AS \$\$
|
||||
BEGIN
|
||||
PERFORM pg_notify('minion_jobs', json_build_object(
|
||||
'id', NEW.id, 'status', NEW.status, 'name', NEW.name,
|
||||
|
||||
+5
-5
@@ -160,7 +160,7 @@ CREATE TABLE IF NOT EXISTS pages (
|
||||
-- content columns IS DISTINCT FROM (allow-list widened per D6 + codex #3
|
||||
-- to include title/type/page_kind/corpus_generation/content_hash) so
|
||||
-- read-time mutations don't invalidate every cache row.
|
||||
CREATE OR REPLACE FUNCTION bump_page_generation_fn() RETURNS trigger AS $func$
|
||||
CREATE OR REPLACE FUNCTION bump_page_generation_fn() RETURNS trigger SET search_path = pg_catalog, public AS $func$
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
NEW.generation := COALESCE((SELECT MAX(generation) FROM pages), 0) + 1;
|
||||
@@ -240,7 +240,7 @@ SELECT setval('page_generation_clock_seq', GREATEST(
|
||||
COALESCE((SELECT MAX(generation) FROM pages), 0)
|
||||
));
|
||||
|
||||
CREATE OR REPLACE FUNCTION bump_page_generation_clock_fn() RETURNS trigger AS $func$
|
||||
CREATE OR REPLACE FUNCTION bump_page_generation_clock_fn() RETURNS trigger SET search_path = pg_catalog, public AS $func$
|
||||
BEGIN
|
||||
PERFORM nextval('page_generation_clock_seq');
|
||||
RETURN NULL;
|
||||
@@ -356,7 +356,7 @@ CREATE INDEX IF NOT EXISTS content_chunks_stale_idx
|
||||
-- NL queries ("how do we handle errors") rank doc-comment hits above body text.
|
||||
-- BEFORE INSERT OR UPDATE OF specific columns — only refires when those change,
|
||||
-- not on every chunk update (e.g., embedding refresh doesn't trigger rebuild).
|
||||
CREATE OR REPLACE FUNCTION update_chunk_search_vector() RETURNS TRIGGER AS $fn$
|
||||
CREATE OR REPLACE FUNCTION update_chunk_search_vector() RETURNS TRIGGER SET search_path = pg_catalog, public AS $fn$
|
||||
BEGIN
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('english', COALESCE(NEW.doc_comment, '')), 'A') ||
|
||||
@@ -815,7 +815,7 @@ ALTER TABLE pages ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_search ON pages USING GIN(search_vector);
|
||||
|
||||
-- Function to rebuild search_vector for a page
|
||||
CREATE OR REPLACE FUNCTION update_page_search_vector() RETURNS trigger AS $$
|
||||
CREATE OR REPLACE FUNCTION update_page_search_vector() RETURNS trigger SET search_path = pg_catalog, public AS $$
|
||||
DECLARE
|
||||
timeline_text TEXT;
|
||||
BEGIN
|
||||
@@ -1354,7 +1354,7 @@ CREATE INDEX IF NOT EXISTS think_ab_results_recent_idx
|
||||
ON think_ab_results (source_id, ran_at DESC);
|
||||
|
||||
-- NOTIFY trigger for real-time job events (Postgres only, not PGLite)
|
||||
CREATE OR REPLACE FUNCTION notify_minion_job_change() RETURNS trigger AS $$
|
||||
CREATE OR REPLACE FUNCTION notify_minion_job_change() RETURNS trigger SET search_path = pg_catalog, public AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('minion_jobs', json_build_object(
|
||||
'id', NEW.id, 'status', NEW.status, 'name', NEW.name,
|
||||
|
||||
@@ -90,4 +90,31 @@ describe.skipIf(skip)('PostgresEngine forward-reference bootstrap (E2E)', () =>
|
||||
await engine.initSchema();
|
||||
expect(await engine.getConfig('version')).toBe(String(LATEST_VERSION));
|
||||
});
|
||||
|
||||
// Migration v120 — schema-lint hardening (#1647 / #171). Postgres-only
|
||||
// assertions (security_invoker has no surface on embedded PGLite).
|
||||
test('v120: page_links view runs with security_invoker=on (#1647b)', async () => {
|
||||
await engine.initSchema();
|
||||
const rows = await engine.executeRaw<{ reloptions: string[] | null }>(
|
||||
`SELECT c.reloptions FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public' AND c.relname = 'page_links' AND c.relkind = 'v'`,
|
||||
);
|
||||
expect(rows.length).toBe(1);
|
||||
expect(JSON.stringify(rows[0].reloptions ?? [])).toContain('security_invoker=on');
|
||||
});
|
||||
|
||||
test('v120: trigger + event-trigger functions pin search_path, incl auto_enable_rls (#1647a/#171)', async () => {
|
||||
await engine.initSchema();
|
||||
const rows = await engine.executeRaw<{ proname: string; proconfig: unknown }>(
|
||||
`SELECT p.proname, p.proconfig FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND p.proname IN ('bump_page_generation_fn','bump_page_generation_clock_fn',
|
||||
'update_chunk_search_vector','update_page_search_vector',
|
||||
'notify_minion_job_change','auto_enable_rls')`,
|
||||
);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(5);
|
||||
for (const r of rows) {
|
||||
expect(JSON.stringify(r.proconfig ?? [])).toContain('search_path=');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Migration v120 — schema-lint hardening (#1647 / #171).
|
||||
*
|
||||
* Validates the search_path pin lands on the PGLite trigger functions and that
|
||||
* the migration is idempotent. (PGLite is Postgres 17.5, so this also
|
||||
* empirically confirms `ALTER FUNCTION ... SET search_path` runs on PGLite —
|
||||
* the engine-asymmetry concern from the eng-review codex pass.) The
|
||||
* security_invoker + auto_enable_rls assertions are Postgres-only and live in
|
||||
* the Postgres bootstrap E2E.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { runMigrations } from '../src/core/migrate.ts';
|
||||
|
||||
describe('migration v120 — search_path hardening', () => {
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema(); // applies all migrations through LATEST_VERSION (incl. v120)
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
test('PGLite trigger functions carry SET search_path after migrations', async () => {
|
||||
const rows = await engine.executeRaw<{ proname: string; proconfig: unknown }>(
|
||||
`SELECT p.proname, p.proconfig
|
||||
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND p.proname IN ('bump_page_generation_fn','bump_page_generation_clock_fn','update_page_search_vector')`,
|
||||
);
|
||||
expect(rows.length).toBe(3);
|
||||
for (const r of rows) {
|
||||
// proconfig is a text[] like {search_path=pg_catalog, public}; coerce to a
|
||||
// string so the assertion is robust to driver array shape.
|
||||
expect(JSON.stringify(r.proconfig ?? [])).toContain('search_path=');
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test('re-running migrations after initSchema is idempotent (0 applied, no error)', async () => {
|
||||
const res = await runMigrations(engine);
|
||||
expect(res.applied).toBe(0);
|
||||
}, 30000);
|
||||
});
|
||||
Reference in New Issue
Block a user