mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
v0.26.8 feat(migration): v35 auto-RLS event trigger — new tables always secure (#612)
* feat(migration): v35 auto-RLS event trigger — new tables always secure Postgres event trigger that fires on every CREATE TABLE and auto-enables Row Level Security. Prevents the face_detections bug: tables created outside gbrain migrations (Baku, manual SQL, other apps sharing the same Supabase project) were silently unprotected until gbrain doctor caught it. This is the Supabase-recommended approach — no dashboard toggle exists. Migration v35 (auto_rls_event_trigger): - CREATE FUNCTION auto_enable_rls() — event trigger handler - CREATE EVENT TRIGGER auto_rls_on_create_table — fires on ddl_command_end - PGLite: no-op (no RLS engine, no event triggers) Tests (3 cases): - Event trigger exists after migration - New table automatically gets RLS enabled - auto_enable_rls function exists Closes the gap identified in production on 2026-05-04 when face_detections was found without RLS. * feat(migration): v35 — drop FORCE, public-only, bundle backfill, cover CTAS+SELECT INTO Apply the corrections surfaced by /plan-eng-review + /codex consult against the original PR #612. The trigger now matches v24/v29/schema.sql posture (ENABLE only, no FORCE), scopes to the public schema, and covers all three table-creation syntaxes Postgres reports. Bundles a one-time backfill of every existing public.* table without RLS, honoring doctor.ts's GBRAIN:RLS_EXEMPT regex and quoting identifiers via format('%I.%I'). Drops the EXCEPTION wrap inside the trigger so per-table failures abort the offending CREATE TABLE (loud rollback) rather than producing a silent permissive default. Drops the hand-rolled privilege pre-check — the runner already fails loud on permission errors and gates the version bump. Breaking change: operators with intentionally-RLS-off public tables must add the GBRAIN:RLS_EXEMPT comment before upgrading or the backfill will flip them on. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Wintermute <wintermute@garrytan.com> Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Wintermute
Garry Tan
Claude Opus 4.7
parent
058fe69575
commit
9c2dc4cd54
+75
-1
@@ -2,6 +2,80 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.26.8] - 2026-05-04
|
||||
|
||||
## **Every gbrain brain becomes secure by default on upgrade. No public table without RLS, ever.**
|
||||
## **The trigger covers `CREATE TABLE`, `CREATE TABLE AS`, and `SELECT INTO`, and the one-time backfill closes existing gaps.**
|
||||
|
||||
A production incident on 2026-05-04 found a `public.*` table sitting in a Supabase project without Row Level Security enabled. `gbrain doctor` caught it after the fact, but the gap window between create and next doctor run was the silent vector. v0.26.8 closes that gap from both sides.
|
||||
|
||||
The new migration v35 ships a Postgres DDL event trigger named `auto_rls_on_create_table` that fires on every `ddl_command_end` and runs `ALTER TABLE … ENABLE ROW LEVEL SECURITY` for every new `public.*` table, across all three table-creation syntaxes Postgres reports (`CREATE TABLE`, `CREATE TABLE AS … SELECT`, and `SELECT … INTO`). Same migration also walks every existing `public.*` base table and enables RLS on any that don't already have it, modulo the `GBRAIN:RLS_EXEMPT` comment escape hatch that doctor already honors. After upgrade, `gbrain doctor`'s `rls` check should be a no-op on every brain.
|
||||
|
||||
Posture choices, all caught during plan review: ENABLE only, no FORCE (so non-BYPASSRLS apps sharing the project can still read tables they create); public-schema-only (Supabase manages auth/storage/realtime/etc. and we must not touch those); no EXCEPTION wrap inside the trigger (event triggers fire inside the DDL transaction, so a failed ALTER aborts the offending CREATE TABLE and produces a loud signal — wrapping would have replaced that with a silent permissive default); no hand-rolled privilege pre-check (the migration runner already fails loud on permission errors and gates the version bump).
|
||||
|
||||
`gbrain doctor` gains a new `rls_event_trigger` check that verifies the trigger is installed and enabled. Healthy values are `evtenabled` of `O` (origin) or `A` (always). `R` is replica-only and would not fire in normal sessions; `D` is disabled. Both produce a warn with the recovery command.
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
11 test cases gating the new shape: 4 happy-path coverage cases (CREATE TABLE, CTAS, SELECT INTO, function exists), 1 explicit "no FORCE" assertion against `pg_class.relforcerowsecurity`, 1 schema-scope test (non-public schemas remain RLS-off), 1 replay idempotency test, 3 backfill cases (plain table, exemption regex, mixed-case identifier safety), and 1 regression guard pinning the absence of `EXCEPTION WHEN OTHERS` in the trigger function body. Plus 9 structural assertions in `test/migrate.test.ts` and 1 new pin in `test/doctor.test.ts` for the doctor check.
|
||||
|
||||
| Metric | BEFORE v0.26.8 | AFTER v0.26.8 | Δ |
|
||||
|---|---|---|---|
|
||||
| New `public.*` table without RLS | possible (gap window until next `gbrain doctor`) | impossible (event trigger aborts CREATE TABLE on RLS failure) | structural |
|
||||
| Existing `public.*` tables without RLS on upgrade | manual fix (operator copy-pastes ALTER TABLE) | auto-backfill on `gbrain upgrade` | one round-trip removed |
|
||||
| Table-creation syntaxes covered | 0 (no auto-RLS) | 3 (`CREATE TABLE`, `CREATE TABLE AS`, `SELECT INTO`) | full literal coverage |
|
||||
| Stale parallel RLS audit surface | 1 (`supabase-admin.ts:checkRls()` with hardcoded 10-table list, 0 callers) | 0 | deleted |
|
||||
| doctor RLS coverage | RLS-on every public table | RLS-on every public table + trigger-installed check | drift-resistant |
|
||||
|
||||
### What this means for operators
|
||||
|
||||
If you run gbrain on Supabase, your brain becomes secure on upgrade. Run `gbrain upgrade`, run `gbrain doctor`, and unless the trigger genuinely failed to install (you're on self-hosted Postgres without superuser), both checks are green. New tables created from anywhere — gbrain itself, Baku, Hermes, raw psql — get RLS the moment they exist.
|
||||
|
||||
If you run gbrain on PGLite, this release is a no-op for you. PGLite has no event triggers, no PostgREST, and is single-tenant by design.
|
||||
|
||||
## To take advantage of v0.26.8
|
||||
|
||||
`gbrain upgrade` should do this automatically. If it didn't, or if `gbrain doctor` warns about either the `rls` or `rls_event_trigger` check:
|
||||
|
||||
1. **Run the migration manually:**
|
||||
```bash
|
||||
gbrain apply-migrations --yes
|
||||
```
|
||||
2. **Verify the outcome:**
|
||||
```bash
|
||||
gbrain doctor
|
||||
# rls: ok — RLS enabled on N/N public tables
|
||||
# rls_event_trigger: ok — Auto-RLS event trigger installed
|
||||
```
|
||||
3. **If the trigger is missing on Supabase**, your role probably can't `CREATE EVENT TRIGGER`. On Supabase only the `postgres` role has the grant. Re-run upgrade with the connection string for `postgres`, not the pooler-default user.
|
||||
4. **If the trigger is missing on self-hosted Postgres**, you need superuser to create event triggers. Run the migration as a superuser role:
|
||||
```bash
|
||||
DATABASE_URL=postgresql://postgres@... gbrain apply-migrations --force-retry 35
|
||||
```
|
||||
5. **If anything looks wrong**, file an issue: https://github.com/garrytan/gbrain/issues with the output of `gbrain doctor` and the contents of `~/.gbrain/upgrade-errors.jsonl`.
|
||||
|
||||
### Breaking change: read this before upgrading
|
||||
|
||||
If you have public tables that are intentionally RLS-off and you want them to stay that way, you MUST add the `GBRAIN:RLS_EXEMPT` comment **before** running `gbrain upgrade` to v0.26.8. The migration's one-time backfill flips RLS on for any public table whose comment doesn't carry the exact contract:
|
||||
|
||||
```sql
|
||||
COMMENT ON TABLE public.your_table IS
|
||||
'GBRAIN:RLS_EXEMPT reason=<at-least-4-character-justification>';
|
||||
```
|
||||
|
||||
The recovery cost is one round-trip: `ALTER TABLE … DISABLE ROW LEVEL SECURITY;` followed by the comment SQL above. No data is lost. See `docs/guides/rls-and-you.md` for the full contract.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
- `src/core/migrate.ts` — migration v35 rewritten per plan review and codex outside-voice corrections. Drops FORCE (matches v24/v29/schema.sql posture so non-BYPASSRLS apps can still read their own tables). Adds public-schema-only filter so Supabase-managed schemas (`auth`, `storage`, `realtime`, etc.) are untouched. Drops the EXCEPTION wrap inside the trigger so per-table failures abort the offending CREATE TABLE instead of silently succeeding. Drops the hand-rolled privilege pre-check (the runner already fails loud on permission errors). Extends `WHEN TAG` to cover `CREATE TABLE`, `CREATE TABLE AS`, and `SELECT INTO`. Bundles a one-time backfill that reuses doctor's regex contract for `GBRAIN:RLS_EXEMPT` exemptions and uses `format('%I.%I', schema, table)` for identifier safety.
|
||||
- `src/commands/doctor.ts` — new `rls_event_trigger` check after the `// 6. Schema version` block. Verifies the trigger is installed and `evtenabled` is `O` or `A`. Recovery command points at `gbrain apply-migrations --force-retry 35`. Renumbered the existing 7/8/9 numbered blocks accordingly.
|
||||
- `src/core/supabase-admin.ts` — deleted `checkRls()`. Zero callers, zero test coverage; doctor is the single source of truth for RLS posture.
|
||||
- `test/migration-v35-auto-rls.test.ts` — extended from 3 to 11 cases. Adds replay idempotency, public-only scope, CTAS coverage, SELECT INTO coverage, no-FORCE assertion, backfill happy path, backfill exemption regex, mixed-case identifier safety, and the EXCEPTION-not-present regression guard.
|
||||
- `test/migrate.test.ts` — 8 structural assertions on the v35 SQL shape (no FORCE, public-only, WHEN TAG covers all three syntaxes, no EXCEPTION WHEN OTHERS, %I.%I backfill quoting, doctor-regex match, BYPASSRLS gate, PGLite no-op).
|
||||
- `test/doctor.test.ts` — structural assertion that `rls_event_trigger` is wired correctly and the existing `// 5. RLS` slice tests stay intact.
|
||||
- `docs/guides/rls-and-you.md` — new "v0.26.7 — auto-RLS event trigger and one-time backfill" section explaining the trigger, the breaking change for intentionally-RLS-off public tables, and the cross-app implications.
|
||||
- `CLAUDE.md` — extended `src/core/migrate.ts` annotation with v35 specifics and added the `rls_event_trigger` check to the `src/commands/doctor.ts` annotation.
|
||||
|
||||
## [0.26.7] - 2026-05-04
|
||||
|
||||
## **Test isolation foundation. Lint guard + helper + quarantine renames before the env and PGLite sweeps.**
|
||||
@@ -806,7 +880,7 @@ No schema migration. Existing brains work unchanged.
|
||||
### Cross-model review credit
|
||||
|
||||
This release ran two rounds of `/plan-eng-review` plus `/codex` outside voice, capturing 15 user decisions. Codex caught the four most consequential architectural mistakes the eng review missed (read the plan file's GSTACK REVIEW REPORT for the full audit trail). The atomic-refusal bug in applyUninstall was caught by the test for the contract — the test was written with the contract in mind, the implementation lied about the contract, and the lie surfaced immediately. That's the cross-model loop working.
|
||||
=======
|
||||
|
||||
## [0.25.0] - 2026-04-26
|
||||
|
||||
## **Contributors can now benchmark retrieval changes against real captured queries before merging.**
|
||||
|
||||
@@ -137,8 +137,8 @@ strict behavior when unset.
|
||||
- `src/commands/repair-jsonb.ts` — `gbrain repair-jsonb [--dry-run] [--json]`: rewrites `jsonb_typeof='string'` rows in place across 5 affected columns (pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata, page_versions.frontmatter). Fixes v0.12.0 double-encode bug on Postgres; PGLite no-ops. Idempotent.
|
||||
- `src/commands/orphans.ts` — `gbrain orphans [--json] [--count] [--include-pseudo]`: surfaces pages with zero inbound wikilinks, grouped by domain. Auto-generated/raw/pseudo pages filtered by default. Also exposed as `find_orphans` MCP operation. Shipped in v0.12.3 (contributed by @knee5).
|
||||
- `src/commands/integrity.ts` — `gbrain integrity check|auto|review|extract`: bare-tweet detection, dead-link detection, three-bucket repair (auto-repair / review-queue / skip). `scanIntegrity()` is the shared library function called from `gbrain doctor` (sampled at limit=500) and `cmdCheck` (full scan). v0.22.8: batch-load fast path on Postgres uses `SELECT DISTINCT ON (slug)` in a single SQL query to fix the PgBouncer round-trip timeout (60s → ~6s) while preserving `engine.getAllSlugs()`'s `Set<string>` semantics on multi-source brains. Gated by `engine.kind === 'postgres'` at the call site so PGLite never enters batch; fallback `catch` logs at `GBRAIN_DEBUG=1` so real Postgres errors are diagnosable.
|
||||
- `src/commands/doctor.ts` — `gbrain doctor [--json] [--fast] [--fix] [--dry-run] [--index-audit]`: health checks. v0.12.3 added `jsonb_integrity` + `markdown_body_completeness` reliability checks. v0.14.1: `--fix` delegates inlined cross-cutting rules to `> **Convention:** see [path](path).` callouts (pipes DRY violations into `src/core/dry-fix.ts`); `--fix --dry-run` previews without writing. v0.14.2: `schema_version` check fails loudly when `version=0` (migrations never ran — the #218 `bun install -g` signature) and routes users to `gbrain apply-migrations --yes`; new opt-in `--index-audit` flag (Postgres-only) reports zero-scan indexes from `pg_stat_user_indexes` (informational only, no auto-drop). v0.15.2: every DB check is wrapped in a progress phase; `markdown_body_completeness` runs under a 1s heartbeat timer so 10+ min scans are observable on 50K-page brains. v0.19.1 added `queue_health` (Postgres-only) with two subchecks: stalled-forever active jobs (started_at > 1h) and waiting-depth-per-name > threshold (default 10, override via `GBRAIN_QUEUE_WAITING_THRESHOLD`). Worker-heartbeat subcheck intentionally deferred to follow-up B7 because it needs a `minion_workers` table to produce ground-truth signal. Fix hints point at `gbrain repair-jsonb`, `gbrain sync --force`, `gbrain apply-migrations`, and `gbrain jobs get/cancel <id>`. v0.22.12 (#500): `sync_failures` check shows `[CODE=N, ...]` breakdown for both unacked entries (warn) and acked-historical entries (ok), surfacing systemic failure modes (`SLUG_MISMATCH=2685`) instead of a bare count.
|
||||
- `src/core/migrate.ts` — schema-migration runner. Owns the `MIGRATIONS` array (source of truth for schema DDL). v0.14.2 extended the `Migration` interface with `sqlFor?: { postgres?, pglite? }` (engine-specific SQL overrides `sql`) and `transaction?: boolean` (set to false for `CREATE INDEX CONCURRENTLY`, which Postgres refuses inside a transaction; ignored on PGLite since it has no concurrent writers). Migration v14 (fix wave) uses a handler branching on `engine.kind` to run CONCURRENTLY on Postgres (with a pre-drop of any invalid remnant via `pg_index.indisvalid`) and plain `CREATE INDEX` on PGLite. v15 bumps `minion_jobs.max_stalled` default 1→5 and backfills existing non-terminal rows. v0.22.6.1: migration v24 (`rls_backfill_missing_tables`) uses `sqlFor: { pglite: '' }` to no-op on PGLite — PGLite has no RLS engine and is single-tenant by definition, and the v24 ALTERs target subagent tables that don't exist in pglite-schema.ts. Closes #395 (contributed by @jdcastro2). **v30 (v0.23):** creates `dream_verdicts (file_path TEXT, content_hash TEXT, worth_processing BOOL, reasons JSONB, judged_at TIMESTAMPTZ, PK(file_path, content_hash))`. RLS-enabled when running as a BYPASSRLS role. The synthesize phase reads/writes this table to avoid re-judging on backfill re-runs.
|
||||
- `src/commands/doctor.ts` — `gbrain doctor [--json] [--fast] [--fix] [--dry-run] [--index-audit]`: health checks. v0.12.3 added `jsonb_integrity` + `markdown_body_completeness` reliability checks. v0.14.1: `--fix` delegates inlined cross-cutting rules to `> **Convention:** see [path](path).` callouts (pipes DRY violations into `src/core/dry-fix.ts`); `--fix --dry-run` previews without writing. v0.14.2: `schema_version` check fails loudly when `version=0` (migrations never ran — the #218 `bun install -g` signature) and routes users to `gbrain apply-migrations --yes`; new opt-in `--index-audit` flag (Postgres-only) reports zero-scan indexes from `pg_stat_user_indexes` (informational only, no auto-drop). v0.15.2: every DB check is wrapped in a progress phase; `markdown_body_completeness` runs under a 1s heartbeat timer so 10+ min scans are observable on 50K-page brains. v0.19.1 added `queue_health` (Postgres-only) with two subchecks: stalled-forever active jobs (started_at > 1h) and waiting-depth-per-name > threshold (default 10, override via `GBRAIN_QUEUE_WAITING_THRESHOLD`). Worker-heartbeat subcheck intentionally deferred to follow-up B7 because it needs a `minion_workers` table to produce ground-truth signal. Fix hints point at `gbrain repair-jsonb`, `gbrain sync --force`, `gbrain apply-migrations`, and `gbrain jobs get/cancel <id>`. v0.22.12 (#500): `sync_failures` check shows `[CODE=N, ...]` breakdown for both unacked entries (warn) and acked-historical entries (ok), surfacing systemic failure modes (`SLUG_MISMATCH=2685`) instead of a bare count. v0.26.7 (#612): `rls_event_trigger` check (post-install drift detector for migration v35's auto-RLS event trigger). Lives outside the `// 5. RLS` slice that the structural doctor.test.ts guards anchor on, so the existing test guards stay intact. Healthy `evtenabled` set is `('O','A')` only — `R` is replica-only and would not fire in normal sessions; `D` is disabled. Fix hint is `gbrain apply-migrations --force-retry 35`.
|
||||
- `src/core/migrate.ts` — schema-migration runner. Owns the `MIGRATIONS` array (source of truth for schema DDL). v0.14.2 extended the `Migration` interface with `sqlFor?: { postgres?, pglite? }` (engine-specific SQL overrides `sql`) and `transaction?: boolean` (set to false for `CREATE INDEX CONCURRENTLY`, which Postgres refuses inside a transaction; ignored on PGLite since it has no concurrent writers). Migration v14 (fix wave) uses a handler branching on `engine.kind` to run CONCURRENTLY on Postgres (with a pre-drop of any invalid remnant via `pg_index.indisvalid`) and plain `CREATE INDEX` on PGLite. v15 bumps `minion_jobs.max_stalled` default 1→5 and backfills existing non-terminal rows. v0.22.6.1: migration v24 (`rls_backfill_missing_tables`) uses `sqlFor: { pglite: '' }` to no-op on PGLite — PGLite has no RLS engine and is single-tenant by definition, and the v24 ALTERs target subagent tables that don't exist in pglite-schema.ts. Closes #395 (contributed by @jdcastro2). **v30 (v0.23):** creates `dream_verdicts (file_path TEXT, content_hash TEXT, worth_processing BOOL, reasons JSONB, judged_at TIMESTAMPTZ, PK(file_path, content_hash))`. RLS-enabled when running as a BYPASSRLS role. The synthesize phase reads/writes this table to avoid re-judging on backfill re-runs. **v35 (v0.26.7):** auto-RLS event trigger + one-time backfill. `auto_rls_on_create_table` fires on `ddl_command_end` for `WHEN TAG IN ('CREATE TABLE','CREATE TABLE AS','SELECT INTO')` and runs `ALTER TABLE … ENABLE ROW LEVEL SECURITY` on every new `public.*` table — no FORCE (matches v24/v29/schema.sql posture so non-BYPASSRLS apps can still read their own tables). The same migration backfills RLS on every existing `public.*` base table whose comment doesn't match the doctor regex (`^GBRAIN:RLS_EXEMPT\s+reason=\S.{3,}`). Per-table failure aborts the offending CREATE TABLE (event triggers fire inside the DDL transaction); no EXCEPTION wrap — that would convert loud rollback into silent permissive default. PGLite no-op via `sqlFor.pglite: ''`. Breaking change: operators with intentionally-RLS-off public tables must add the GBRAIN:RLS_EXEMPT comment BEFORE upgrade or the backfill will flip them on.
|
||||
- `src/core/progress.ts` — Shared bulk-action progress reporter. Writes to stderr. Modes: `auto` (TTY: `\r`-rewriting; non-TTY: plain lines), `human`, `json` (JSONL), `quiet`. Rate-gated by `minIntervalMs` and `minItems`. `startHeartbeat(reporter, note)` helper for single long queries. `child()` composes phase paths. Singleton SIGINT/SIGTERM coordinator emits `abort` events for every live phase. EPIPE defense on both sync throws and stream `'error'` events. Zero dependencies. Introduced in v0.15.2.
|
||||
- `src/core/cli-options.ts` — Global CLI flag parser. `parseGlobalFlags(argv)` returns `{cliOpts, rest}` with `--quiet` / `--progress-json` / `--progress-interval=<ms>` stripped. `getCliOptions()` / `setCliOptions()` expose a module-level singleton so commands reach the resolved flags without parameter threading. `cliOptsToProgressOptions()` maps to reporter options. `childGlobalFlags()` returns the flag suffix to append to `execSync('gbrain ...')` calls in migration orchestrators. `OperationContext.cliOpts` extends shared-op dispatch for MCP callers.
|
||||
- `src/core/db-lock.ts` (v0.22.13) — generic `tryAcquireDbLock(engine, lockId, ttlMinutes)` over the existing `gbrain_cycle_locks` table. Parameterized lock id so different scopes can nest cleanly: `gbrain-cycle` for the broad cycle (held by `cycle.ts`) and `gbrain-sync` (`SYNC_LOCK_ID` constant) for `performSync`'s narrower writer window. Same UPSERT-with-TTL semantics as the prior cycle-only helper, just generalized. Survives PgBouncer transaction pooling (unlike session-scoped `pg_try_advisory_lock`); crashed holders auto-release once their TTL expires.
|
||||
|
||||
@@ -34,6 +34,85 @@ docs/guides/rls-and-you.md for the GBRAIN:RLS_EXEMPT comment escape hatch.
|
||||
|
||||
99% of the time, you want the fix. Run the SQL. Re-run `gbrain doctor`. Done.
|
||||
|
||||
## v0.26.7 — auto-RLS event trigger and one-time backfill
|
||||
|
||||
Starting in v0.26.7 (migration v35), gbrain ships two changes that close the
|
||||
gap where a table could exist in your `public` schema without RLS for any
|
||||
amount of time at all.
|
||||
|
||||
**1. The event trigger.** A Postgres DDL event trigger named
|
||||
`auto_rls_on_create_table` runs `ALTER TABLE … ENABLE ROW LEVEL SECURITY`
|
||||
on every newly created `public.*` table. It covers `CREATE TABLE`,
|
||||
`CREATE TABLE AS … SELECT`, and `SELECT … INTO` — every syntax Postgres
|
||||
reports as a table-creation command. Tables created by gbrain itself, by
|
||||
your other apps sharing the same Supabase project (Baku, Hermes, anything),
|
||||
or by a human running raw SQL all get RLS enabled the moment they exist.
|
||||
Non-`public` schemas (`auth`, `storage`, `realtime`, etc.) are explicitly
|
||||
ignored — Supabase manages those, and we should not touch them.
|
||||
|
||||
**2. The one-time backfill.** When you upgrade to v0.26.7, the migration
|
||||
walks every existing `public.*` base table whose RLS is off and whose comment
|
||||
doesn't carry the `GBRAIN:RLS_EXEMPT` exemption (see below) and enables RLS
|
||||
on each. After the upgrade, `gbrain doctor`'s `rls` check should be a no-op
|
||||
on every brain.
|
||||
|
||||
### Breaking change: read this before upgrading
|
||||
|
||||
If you have public tables that are intentionally RLS-off and you want them
|
||||
to stay that way, you MUST add the `GBRAIN:RLS_EXEMPT` comment **before**
|
||||
running `gbrain upgrade` to v0.26.7. The backfill flips RLS on for any public
|
||||
table that doesn't carry the exact comment contract documented below. There
|
||||
is no `--dry-run` flag on the migration.
|
||||
|
||||
The minimum cost of getting this wrong is one round-trip: the operator runs
|
||||
the SQL to enable RLS on a table that should have been exempt, then
|
||||
`ALTER TABLE … DISABLE ROW LEVEL SECURITY` and adds the exempt comment to
|
||||
prevent a re-flip on a later doctor run. No data is lost.
|
||||
|
||||
### Cross-app implications
|
||||
|
||||
If a non-gbrain app (Baku, Hermes, a script you wrote, anything) creates
|
||||
tables in the same Supabase project, the trigger will enable RLS on those
|
||||
tables too. Two ways to handle that:
|
||||
|
||||
1. **The app's connection role has BYPASSRLS** (e.g. it's also using the
|
||||
`postgres` role). Newly created tables get RLS on but the app reads/writes
|
||||
freely because BYPASSRLS bypasses policies entirely.
|
||||
2. **The app's role does NOT have BYPASSRLS.** Then the app needs to add a
|
||||
`CREATE POLICY` immediately after creating the table, granting itself
|
||||
the read/write access it needs. The trigger does NOT add policies — it
|
||||
only enables RLS, leaving the deny-by-default posture in place until the
|
||||
app's policy lands.
|
||||
|
||||
If neither condition holds, the app will fail to read its own freshly-created
|
||||
tables. The fix is at the app side, not gbrain's: either grant BYPASSRLS or
|
||||
ship a policy.
|
||||
|
||||
### What if the trigger gets dropped?
|
||||
|
||||
`gbrain doctor` includes a new `rls_event_trigger` check that verifies the
|
||||
trigger is installed and enabled. If you drop it manually for any reason
|
||||
(debugging, migration testing, anything), doctor warns and gives you the
|
||||
recovery command:
|
||||
|
||||
```
|
||||
gbrain apply-migrations --force-retry 35
|
||||
```
|
||||
|
||||
Re-running migration v35 is idempotent — it `DROP EVENT TRIGGER IF EXISTS`
|
||||
and recreates cleanly.
|
||||
|
||||
### Why no FORCE ROW LEVEL SECURITY?
|
||||
|
||||
Postgres has two RLS dials. `ENABLE` blocks anon/authenticated; `FORCE` also
|
||||
blocks the table OWNER unless they hold BYPASSRLS. We use `ENABLE` only,
|
||||
matching the posture in `src/schema.sql`, migrations v24, and v29. `FORCE`
|
||||
would lock non-BYPASSRLS apps out of their own freshly-created tables (the
|
||||
trigger function inherits the caller's role, not the gbrain role) — which
|
||||
defeats the cross-app coexistence story above. If you want defense-in-depth
|
||||
`FORCE` on a specific gbrain-owned table, add it explicitly in your own
|
||||
migration; gbrain's auto-RLS does not opt you in by default.
|
||||
|
||||
## The 1% case: deliberate exemption
|
||||
|
||||
Sometimes a public table is supposed to be readable by the anon key. An
|
||||
|
||||
+2
-2
@@ -234,8 +234,8 @@ strict behavior when unset.
|
||||
- `src/commands/repair-jsonb.ts` — `gbrain repair-jsonb [--dry-run] [--json]`: rewrites `jsonb_typeof='string'` rows in place across 5 affected columns (pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata, page_versions.frontmatter). Fixes v0.12.0 double-encode bug on Postgres; PGLite no-ops. Idempotent.
|
||||
- `src/commands/orphans.ts` — `gbrain orphans [--json] [--count] [--include-pseudo]`: surfaces pages with zero inbound wikilinks, grouped by domain. Auto-generated/raw/pseudo pages filtered by default. Also exposed as `find_orphans` MCP operation. Shipped in v0.12.3 (contributed by @knee5).
|
||||
- `src/commands/integrity.ts` — `gbrain integrity check|auto|review|extract`: bare-tweet detection, dead-link detection, three-bucket repair (auto-repair / review-queue / skip). `scanIntegrity()` is the shared library function called from `gbrain doctor` (sampled at limit=500) and `cmdCheck` (full scan). v0.22.8: batch-load fast path on Postgres uses `SELECT DISTINCT ON (slug)` in a single SQL query to fix the PgBouncer round-trip timeout (60s → ~6s) while preserving `engine.getAllSlugs()`'s `Set<string>` semantics on multi-source brains. Gated by `engine.kind === 'postgres'` at the call site so PGLite never enters batch; fallback `catch` logs at `GBRAIN_DEBUG=1` so real Postgres errors are diagnosable.
|
||||
- `src/commands/doctor.ts` — `gbrain doctor [--json] [--fast] [--fix] [--dry-run] [--index-audit]`: health checks. v0.12.3 added `jsonb_integrity` + `markdown_body_completeness` reliability checks. v0.14.1: `--fix` delegates inlined cross-cutting rules to `> **Convention:** see [path](path).` callouts (pipes DRY violations into `src/core/dry-fix.ts`); `--fix --dry-run` previews without writing. v0.14.2: `schema_version` check fails loudly when `version=0` (migrations never ran — the #218 `bun install -g` signature) and routes users to `gbrain apply-migrations --yes`; new opt-in `--index-audit` flag (Postgres-only) reports zero-scan indexes from `pg_stat_user_indexes` (informational only, no auto-drop). v0.15.2: every DB check is wrapped in a progress phase; `markdown_body_completeness` runs under a 1s heartbeat timer so 10+ min scans are observable on 50K-page brains. v0.19.1 added `queue_health` (Postgres-only) with two subchecks: stalled-forever active jobs (started_at > 1h) and waiting-depth-per-name > threshold (default 10, override via `GBRAIN_QUEUE_WAITING_THRESHOLD`). Worker-heartbeat subcheck intentionally deferred to follow-up B7 because it needs a `minion_workers` table to produce ground-truth signal. Fix hints point at `gbrain repair-jsonb`, `gbrain sync --force`, `gbrain apply-migrations`, and `gbrain jobs get/cancel <id>`. v0.22.12 (#500): `sync_failures` check shows `[CODE=N, ...]` breakdown for both unacked entries (warn) and acked-historical entries (ok), surfacing systemic failure modes (`SLUG_MISMATCH=2685`) instead of a bare count.
|
||||
- `src/core/migrate.ts` — schema-migration runner. Owns the `MIGRATIONS` array (source of truth for schema DDL). v0.14.2 extended the `Migration` interface with `sqlFor?: { postgres?, pglite? }` (engine-specific SQL overrides `sql`) and `transaction?: boolean` (set to false for `CREATE INDEX CONCURRENTLY`, which Postgres refuses inside a transaction; ignored on PGLite since it has no concurrent writers). Migration v14 (fix wave) uses a handler branching on `engine.kind` to run CONCURRENTLY on Postgres (with a pre-drop of any invalid remnant via `pg_index.indisvalid`) and plain `CREATE INDEX` on PGLite. v15 bumps `minion_jobs.max_stalled` default 1→5 and backfills existing non-terminal rows. v0.22.6.1: migration v24 (`rls_backfill_missing_tables`) uses `sqlFor: { pglite: '' }` to no-op on PGLite — PGLite has no RLS engine and is single-tenant by definition, and the v24 ALTERs target subagent tables that don't exist in pglite-schema.ts. Closes #395 (contributed by @jdcastro2). **v30 (v0.23):** creates `dream_verdicts (file_path TEXT, content_hash TEXT, worth_processing BOOL, reasons JSONB, judged_at TIMESTAMPTZ, PK(file_path, content_hash))`. RLS-enabled when running as a BYPASSRLS role. The synthesize phase reads/writes this table to avoid re-judging on backfill re-runs.
|
||||
- `src/commands/doctor.ts` — `gbrain doctor [--json] [--fast] [--fix] [--dry-run] [--index-audit]`: health checks. v0.12.3 added `jsonb_integrity` + `markdown_body_completeness` reliability checks. v0.14.1: `--fix` delegates inlined cross-cutting rules to `> **Convention:** see [path](path).` callouts (pipes DRY violations into `src/core/dry-fix.ts`); `--fix --dry-run` previews without writing. v0.14.2: `schema_version` check fails loudly when `version=0` (migrations never ran — the #218 `bun install -g` signature) and routes users to `gbrain apply-migrations --yes`; new opt-in `--index-audit` flag (Postgres-only) reports zero-scan indexes from `pg_stat_user_indexes` (informational only, no auto-drop). v0.15.2: every DB check is wrapped in a progress phase; `markdown_body_completeness` runs under a 1s heartbeat timer so 10+ min scans are observable on 50K-page brains. v0.19.1 added `queue_health` (Postgres-only) with two subchecks: stalled-forever active jobs (started_at > 1h) and waiting-depth-per-name > threshold (default 10, override via `GBRAIN_QUEUE_WAITING_THRESHOLD`). Worker-heartbeat subcheck intentionally deferred to follow-up B7 because it needs a `minion_workers` table to produce ground-truth signal. Fix hints point at `gbrain repair-jsonb`, `gbrain sync --force`, `gbrain apply-migrations`, and `gbrain jobs get/cancel <id>`. v0.22.12 (#500): `sync_failures` check shows `[CODE=N, ...]` breakdown for both unacked entries (warn) and acked-historical entries (ok), surfacing systemic failure modes (`SLUG_MISMATCH=2685`) instead of a bare count. v0.26.7 (#612): `rls_event_trigger` check (post-install drift detector for migration v35's auto-RLS event trigger). Lives outside the `// 5. RLS` slice that the structural doctor.test.ts guards anchor on, so the existing test guards stay intact. Healthy `evtenabled` set is `('O','A')` only — `R` is replica-only and would not fire in normal sessions; `D` is disabled. Fix hint is `gbrain apply-migrations --force-retry 35`.
|
||||
- `src/core/migrate.ts` — schema-migration runner. Owns the `MIGRATIONS` array (source of truth for schema DDL). v0.14.2 extended the `Migration` interface with `sqlFor?: { postgres?, pglite? }` (engine-specific SQL overrides `sql`) and `transaction?: boolean` (set to false for `CREATE INDEX CONCURRENTLY`, which Postgres refuses inside a transaction; ignored on PGLite since it has no concurrent writers). Migration v14 (fix wave) uses a handler branching on `engine.kind` to run CONCURRENTLY on Postgres (with a pre-drop of any invalid remnant via `pg_index.indisvalid`) and plain `CREATE INDEX` on PGLite. v15 bumps `minion_jobs.max_stalled` default 1→5 and backfills existing non-terminal rows. v0.22.6.1: migration v24 (`rls_backfill_missing_tables`) uses `sqlFor: { pglite: '' }` to no-op on PGLite — PGLite has no RLS engine and is single-tenant by definition, and the v24 ALTERs target subagent tables that don't exist in pglite-schema.ts. Closes #395 (contributed by @jdcastro2). **v30 (v0.23):** creates `dream_verdicts (file_path TEXT, content_hash TEXT, worth_processing BOOL, reasons JSONB, judged_at TIMESTAMPTZ, PK(file_path, content_hash))`. RLS-enabled when running as a BYPASSRLS role. The synthesize phase reads/writes this table to avoid re-judging on backfill re-runs. **v35 (v0.26.7):** auto-RLS event trigger + one-time backfill. `auto_rls_on_create_table` fires on `ddl_command_end` for `WHEN TAG IN ('CREATE TABLE','CREATE TABLE AS','SELECT INTO')` and runs `ALTER TABLE … ENABLE ROW LEVEL SECURITY` on every new `public.*` table — no FORCE (matches v24/v29/schema.sql posture so non-BYPASSRLS apps can still read their own tables). The same migration backfills RLS on every existing `public.*` base table whose comment doesn't match the doctor regex (`^GBRAIN:RLS_EXEMPT\s+reason=\S.{3,}`). Per-table failure aborts the offending CREATE TABLE (event triggers fire inside the DDL transaction); no EXCEPTION wrap — that would convert loud rollback into silent permissive default. PGLite no-op via `sqlFor.pglite: ''`. Breaking change: operators with intentionally-RLS-off public tables must add the GBRAIN:RLS_EXEMPT comment BEFORE upgrade or the backfill will flip them on.
|
||||
- `src/core/progress.ts` — Shared bulk-action progress reporter. Writes to stderr. Modes: `auto` (TTY: `\r`-rewriting; non-TTY: plain lines), `human`, `json` (JSONL), `quiet`. Rate-gated by `minIntervalMs` and `minItems`. `startHeartbeat(reporter, note)` helper for single long queries. `child()` composes phase paths. Singleton SIGINT/SIGTERM coordinator emits `abort` events for every live phase. EPIPE defense on both sync throws and stream `'error'` events. Zero dependencies. Introduced in v0.15.2.
|
||||
- `src/core/cli-options.ts` — Global CLI flag parser. `parseGlobalFlags(argv)` returns `{cliOpts, rest}` with `--quiet` / `--progress-json` / `--progress-interval=<ms>` stripped. `getCliOptions()` / `setCliOptions()` expose a module-level singleton so commands reach the resolved flags without parameter threading. `cliOptsToProgressOptions()` maps to reporter options. `childGlobalFlags()` returns the flag suffix to append to `execSync('gbrain ...')` calls in migration orchestrators. `OperationContext.cliOpts` extends shared-op dispatch for MCP callers.
|
||||
- `src/core/db-lock.ts` (v0.22.13) — generic `tryAcquireDbLock(engine, lockId, ttlMinutes)` over the existing `gbrain_cycle_locks` table. Parameterized lock id so different scopes can nest cleanly: `gbrain-cycle` for the broad cycle (held by `cycle.ts`) and `gbrain-sync` (`SYNC_LOCK_ID` constant) for `performSync`'s narrower writer window. Same UPSERT-with-TTL semantics as the prior cycle-only helper, just generalized. Survives PgBouncer transaction pooling (unlike session-scoped `pg_try_advisory_lock`); crashed holders auto-release once their TTL expires.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gbrain",
|
||||
"version": "0.26.7",
|
||||
"version": "0.26.8",
|
||||
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
|
||||
"type": "module",
|
||||
"main": "src/core/index.ts",
|
||||
|
||||
+60
-3
@@ -499,7 +499,64 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
|
||||
// the canonical half-migration signal and fires when the stopgap ran
|
||||
// but `apply-migrations` didn't follow up.
|
||||
|
||||
// 7. Embedding health
|
||||
// 7. RLS event trigger (post-install drift detector for v35 auto-RLS).
|
||||
// Catches the case where an operator manually drops the trigger to debug
|
||||
// something and forgets to recreate it. Does NOT catch install-time silent
|
||||
// failure — runMigrations rethrows on SQL failure and only bumps
|
||||
// config.version after success, so a failed v35 install means version
|
||||
// stays at 34 and check #6 (schema_version) fires loudly.
|
||||
//
|
||||
// Healthy evtenabled values: 'O' (origin) and 'A' (always). 'R' is
|
||||
// replica-only and would NOT fire in normal origin sessions; 'D' is
|
||||
// disabled. Both of those are warn states.
|
||||
progress.heartbeat('rls_event_trigger');
|
||||
if (engine.kind === 'pglite') {
|
||||
checks.push({
|
||||
name: 'rls_event_trigger',
|
||||
status: 'ok',
|
||||
message: 'Skipped (PGLite — no event trigger support)',
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
const sql = db.getConnection();
|
||||
const rows = await sql`
|
||||
SELECT evtname, evtenabled FROM pg_event_trigger
|
||||
WHERE evtname = 'auto_rls_on_create_table'
|
||||
`;
|
||||
if (rows.length === 0) {
|
||||
checks.push({
|
||||
name: 'rls_event_trigger',
|
||||
status: 'warn',
|
||||
message:
|
||||
'Auto-RLS event trigger missing. New tables created outside gbrain may not get RLS. ' +
|
||||
'Fix: gbrain apply-migrations --force-retry 35',
|
||||
});
|
||||
} else if (rows[0].evtenabled !== 'O' && rows[0].evtenabled !== 'A') {
|
||||
checks.push({
|
||||
name: 'rls_event_trigger',
|
||||
status: 'warn',
|
||||
message:
|
||||
`Auto-RLS event trigger present but evtenabled=${rows[0].evtenabled} ` +
|
||||
`(not origin/always). Trigger will not fire in normal sessions. ` +
|
||||
`Fix: ALTER EVENT TRIGGER auto_rls_on_create_table ENABLE;`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
name: 'rls_event_trigger',
|
||||
status: 'ok',
|
||||
message: 'Auto-RLS event trigger installed',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
checks.push({
|
||||
name: 'rls_event_trigger',
|
||||
status: 'warn',
|
||||
message: 'Could not check RLS event trigger',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Embedding health
|
||||
progress.heartbeat('embeddings');
|
||||
try {
|
||||
const health = await engine.getHealth();
|
||||
@@ -515,7 +572,7 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
|
||||
checks.push({ name: 'embeddings', status: 'warn', message: 'Could not check embedding health' });
|
||||
}
|
||||
|
||||
// 8. Graph health (link + timeline coverage on entity pages).
|
||||
// 9. Graph health (link + timeline coverage on entity pages).
|
||||
// dead_links removed in v0.10.1: ON DELETE CASCADE on link FKs makes it always 0.
|
||||
progress.heartbeat('graph_coverage');
|
||||
try {
|
||||
@@ -556,7 +613,7 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
|
||||
checks.push({ name: 'graph_coverage', status: 'warn', message: 'Could not check graph coverage' });
|
||||
}
|
||||
|
||||
// 9. Integrity sample scan (v0.13 knowledge runtime).
|
||||
// 10. Integrity sample scan (v0.13 knowledge runtime).
|
||||
// Read-only — no network, no writes, no resolver calls. Samples the first
|
||||
// 500 pages by slug order and surfaces bare-tweet + dead-link counts as a
|
||||
// warning. Full-brain scan: `gbrain integrity check`.
|
||||
|
||||
@@ -1394,6 +1394,110 @@ export const MIGRATIONS: Migration[] = [
|
||||
// this flag, so the index DDL runs in whatever wrapper applies.
|
||||
transaction: false,
|
||||
},
|
||||
{
|
||||
version: 35,
|
||||
name: 'auto_rls_event_trigger',
|
||||
sql: '', // engine-specific via sqlFor
|
||||
// v0.26.7 — Postgres event trigger that auto-enables RLS on every new public.*
|
||||
// table, plus one-time backfill on every existing public.* table without it.
|
||||
//
|
||||
// Problem: tables created outside gbrain migrations (Baku's face_detections,
|
||||
// manual SQL, other apps sharing the Supabase project) shipped without RLS.
|
||||
// doctor caught them after the fact; the gap window between create and next
|
||||
// doctor run was the silent vector.
|
||||
//
|
||||
// Fix has two halves:
|
||||
// 1. Event trigger — fires on ddl_command_end for CREATE TABLE,
|
||||
// CREATE TABLE AS, and SELECT INTO; runs ALTER TABLE ... ENABLE ROW
|
||||
// LEVEL SECURITY for any new public.* table. Supabase-recommended
|
||||
// approach (no dashboard toggle exists).
|
||||
// 2. One-time backfill — every existing public.* table whose RLS is off
|
||||
// and whose comment does NOT match the GBRAIN:RLS_EXEMPT contract
|
||||
// (same regex doctor.ts uses) gets RLS enabled.
|
||||
//
|
||||
// Posture choices (vs PR-as-shipped):
|
||||
// - ENABLE only, no FORCE — matches v24/v29/schema.sql. FORCE would lock
|
||||
// out non-BYPASSRLS apps from their own newly-created tables (the
|
||||
// trigger function inherits the caller's role, and the new table is
|
||||
// owned by that role). gbrain has BYPASSRLS so gbrain itself is unaffected.
|
||||
// - public-only schema scope — Supabase manages auth/storage/realtime/etc.
|
||||
// and runs its own RLS posture there; we must not disturb those schemas.
|
||||
// - No EXCEPTION wrap inside the trigger — ddl_command_end fires inside
|
||||
// the DDL transaction, so a failed ALTER aborts the offending CREATE
|
||||
// TABLE. That's a loud signal, not a silent gap. Wrapping would CREATE
|
||||
// the silent path this migration exists to close.
|
||||
// - No privilege pre-check — runMigrations rethrows on SQL failure and
|
||||
// gates config.version, so a non-superuser run already fails loud with
|
||||
// an actionable Postgres error.
|
||||
//
|
||||
// BREAKING CHANGE: the backfill is a one-time override of intentionally
|
||||
// RLS-off public tables that don't carry the GBRAIN:RLS_EXEMPT comment.
|
||||
// Operators with such tables MUST add the exempt comment BEFORE upgrading.
|
||||
//
|
||||
// PGLite: no-op — no RLS engine, no event triggers, single-tenant by design.
|
||||
sqlFor: {
|
||||
postgres: `
|
||||
-- Trigger function: fires post-DDL inside the CREATE TABLE transaction.
|
||||
-- A failure here aborts the CREATE TABLE so no public.* table is ever
|
||||
-- created without RLS. object_identity is pre-quoted by Postgres
|
||||
-- (e.g. "public"."My Table"), so %s is correct — %I would double-quote.
|
||||
CREATE OR REPLACE FUNCTION auto_enable_rls()
|
||||
RETURNS event_trigger AS $$
|
||||
DECLARE
|
||||
obj record;
|
||||
BEGIN
|
||||
FOR obj IN SELECT * FROM pg_event_trigger_ddl_commands()
|
||||
WHERE object_type = 'table'
|
||||
AND schema_name = 'public'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', obj.object_identity);
|
||||
END LOOP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- WHEN TAG covers all three table-creation syntaxes Postgres reports.
|
||||
-- CREATE TABLE / CREATE TABLE AS / SELECT INTO produce distinct command
|
||||
-- tags; covering only 'CREATE TABLE' would leave a syntax-shaped hole.
|
||||
DROP EVENT TRIGGER IF EXISTS auto_rls_on_create_table;
|
||||
CREATE EVENT TRIGGER auto_rls_on_create_table
|
||||
ON ddl_command_end
|
||||
WHEN TAG IN ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO')
|
||||
EXECUTE FUNCTION auto_enable_rls();
|
||||
|
||||
-- One-time backfill of every existing public.* base table without RLS.
|
||||
-- Honors the same GBRAIN:RLS_EXEMPT regex doctor.ts uses
|
||||
-- (^GBRAIN:RLS_EXEMPT\\s+reason=\\S.{3,}) so the two surfaces stay aligned.
|
||||
-- %I.%I quotes the schema and table names safely, including mixed-case.
|
||||
DO $$
|
||||
DECLARE
|
||||
has_bypass BOOLEAN;
|
||||
r record;
|
||||
BEGIN
|
||||
SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user;
|
||||
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.
|
||||
RAISE EXCEPTION 'v35 auto_rls_event_trigger backfill: role % does not have BYPASSRLS — cannot enable RLS safely. Re-run as postgres (or another BYPASSRLS role).', current_user;
|
||||
END IF;
|
||||
|
||||
FOR r IN
|
||||
SELECT n.nspname AS schema_name, c.relname AS table_name
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
LEFT JOIN pg_description d ON d.objoid = c.oid AND d.objsubid = 0
|
||||
WHERE n.nspname = 'public'
|
||||
AND c.relkind = 'r'
|
||||
AND c.relrowsecurity = false
|
||||
AND (d.description IS NULL OR d.description !~ '^GBRAIN:RLS_EXEMPT\\s+reason=\\S.{3,}')
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I.%I ENABLE ROW LEVEL SECURITY', r.schema_name, r.table_name);
|
||||
RAISE NOTICE 'v35: backfilled RLS on %.%', r.schema_name, r.table_name;
|
||||
END LOOP;
|
||||
END $$;
|
||||
`,
|
||||
pglite: '', // PGLite has no RLS and no event trigger support
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const LATEST_VERSION = MIGRATIONS.length > 0
|
||||
|
||||
@@ -79,32 +79,3 @@ export async function discoverPoolerUrl(
|
||||
return `postgresql://postgres.${projectRef}:[YOUR-PASSWORD]@aws-0-${region}.pooler.supabase.com:6543/postgres`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify RLS is enabled on all gbrain tables.
|
||||
* Returns list of tables without RLS.
|
||||
*/
|
||||
export async function checkRls(token: string, projectRef: string): Promise<string[]> {
|
||||
const sql = `
|
||||
SELECT tablename FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename IN ('pages','content_chunks','links','tags','raw_data',
|
||||
'page_versions','timeline_entries','ingest_log','config','files')
|
||||
AND NOT rowsecurity
|
||||
`;
|
||||
|
||||
const res = await fetch(
|
||||
`https://api.supabase.com/v1/projects/${projectRef}/database/query`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ query: sql }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) return []; // Non-fatal: skip if API doesn't support this endpoint
|
||||
const data = await res.json() as { result?: { tablename: string }[] };
|
||||
return (data.result || []).map(r => r.tablename);
|
||||
}
|
||||
|
||||
@@ -154,4 +154,24 @@ describe('doctor command', () => {
|
||||
// requirement to write a real justification, not just the prefix.
|
||||
expect(rlsBlock).toMatch(/reason=/);
|
||||
});
|
||||
|
||||
// v0.26.7 — rls_event_trigger check (post-install drift detector for v35).
|
||||
// Lives AFTER `// 6. Schema version` so the existing `// 5. RLS` slice
|
||||
// tests stay intact (codex correction).
|
||||
test('rls_event_trigger check exists, scoped after schema_version, healthy on (O,A) only', async () => {
|
||||
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
||||
const idx7 = source.indexOf('// 7. RLS event trigger');
|
||||
const idx8 = source.indexOf('// 8. Embedding health');
|
||||
expect(idx7).toBeGreaterThan(0);
|
||||
expect(idx8).toBeGreaterThan(idx7);
|
||||
const block = source.slice(idx7, idx8);
|
||||
expect(block).toContain("name: 'rls_event_trigger'");
|
||||
// Healthy set is origin (`O`) or always (`A`). `R` is replica-only and
|
||||
// would not fire in normal sessions; `D` is disabled. Both are warn states.
|
||||
expect(block).toMatch(/evtenabled\s*!==\s*'O'[\s\S]*?evtenabled\s*!==\s*'A'/);
|
||||
// PGLite skip path is required (no event triggers there).
|
||||
expect(block).toMatch(/engine\.kind\s*===\s*'pglite'/);
|
||||
// Recovery command names the migration version explicitly.
|
||||
expect(block).toContain('--force-retry 35');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -972,15 +972,25 @@ describeE2E('E2E: RLS Verification', () => {
|
||||
const conn = getConn();
|
||||
const tbl = `gbrain_rls_regression_${suffix}`;
|
||||
try {
|
||||
await conn.unsafe(`CREATE TABLE public.${tbl} (id int)`);
|
||||
// Make sure RLS is actually off; CREATE TABLE default is off but be explicit.
|
||||
await conn.unsafe(`ALTER TABLE public.${tbl} DISABLE ROW LEVEL SECURITY`);
|
||||
|
||||
// Init (idempotent) so the CLI has a config to read.
|
||||
// Init first so all migrations (including v35's auto-RLS event trigger
|
||||
// and one-time backfill) are applied. AFTER migrations run, simulate
|
||||
// the post-v35 escape route: operator drops the auto-RLS trigger
|
||||
// (e.g. while debugging) and creates a public table without RLS.
|
||||
// doctor's existing rls check must still flag it. The new
|
||||
// rls_event_trigger check warns separately about the missing trigger.
|
||||
Bun.spawnSync({
|
||||
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!],
|
||||
cwd: cliCwd, env: cliEnv(), timeout: 15_000,
|
||||
});
|
||||
|
||||
// Drop the trigger so CREATE TABLE doesn't auto-enable RLS, then create
|
||||
// the test table without RLS. ALTER TABLE … DISABLE is a belt-and-
|
||||
// suspenders no-op in this path but matches what an operator would do
|
||||
// if they had toggled RLS off manually after the trigger ran.
|
||||
await conn.unsafe(`DROP EVENT TRIGGER IF EXISTS auto_rls_on_create_table`);
|
||||
await conn.unsafe(`CREATE TABLE public.${tbl} (id int)`);
|
||||
await conn.unsafe(`ALTER TABLE public.${tbl} DISABLE ROW LEVEL SECURITY`);
|
||||
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['bun', 'run', 'src/cli.ts', 'doctor', '--json'],
|
||||
cwd: cliCwd, env: cliEnv(), timeout: 20_000,
|
||||
@@ -995,6 +1005,11 @@ describeE2E('E2E: RLS Verification', () => {
|
||||
expect(result.exitCode).toBe(1);
|
||||
} finally {
|
||||
await conn.unsafe(`DROP TABLE IF EXISTS public.${tbl}`);
|
||||
// Restore the trigger via a no-op v35 replay so subsequent tests in
|
||||
// this file (which expect the post-init steady state) don't see drift.
|
||||
const { MIGRATIONS } = await import('../../src/core/migrate.ts');
|
||||
const v35sql = (MIGRATIONS.find(m => m.version === 35)?.sqlFor as any)?.postgres;
|
||||
if (v35sql) await conn.unsafe(v35sql);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* E2E tests for migration v35: auto_rls_event_trigger.
|
||||
*
|
||||
* Verifies the event trigger auto-enables RLS on newly created public.* tables
|
||||
* across CREATE TABLE / CREATE TABLE AS / SELECT INTO, plus the one-time
|
||||
* backfill of existing public.* tables without RLS (modulo the GBRAIN:RLS_EXEMPT
|
||||
* exemption that doctor honors). Postgres-only — PGLite has no RLS or event
|
||||
* triggers; that no-op is asserted in test/migrate.test.ts.
|
||||
*
|
||||
* setupDB() runs db.initSchema() which applies all migrations including v35,
|
||||
* so the trigger and the backfill have already executed by the time these
|
||||
* tests start.
|
||||
*
|
||||
* Run: DATABASE_URL=... bun test test/e2e/migration-v35-auto-rls.test.ts
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { hasDatabase, setupDB, teardownDB, getConn, getEngine, runMigrationsUpTo } from './helpers.ts';
|
||||
import { MIGRATIONS, LATEST_VERSION } from '../../src/core/migrate.ts';
|
||||
|
||||
const skip = !hasDatabase();
|
||||
const describeE2E = skip ? describe.skip : describe;
|
||||
|
||||
if (skip) {
|
||||
console.log('Skipping auto-RLS E2E tests (DATABASE_URL not set)');
|
||||
}
|
||||
|
||||
// Migration v35 lives at index 34 (0-based) in MIGRATIONS.
|
||||
const v35 = MIGRATIONS.find(m => m.version === 35);
|
||||
const v35Sql = (v35?.sqlFor as any)?.postgres ?? '';
|
||||
|
||||
describeE2E('migration v35: auto_rls_event_trigger', () => {
|
||||
beforeAll(async () => {
|
||||
await setupDB();
|
||||
// setupDB() runs db.initSchema() (SCHEMA_SQL only, no migrations).
|
||||
// Advance through every migration so v35 is actually installed.
|
||||
await runMigrationsUpTo(getEngine(), LATEST_VERSION);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const conn = getConn();
|
||||
// Clean up every artifact this file creates. Order matters because
|
||||
// _test_v35_scope lives in a non-public schema we drop wholesale.
|
||||
await conn.unsafe(`DROP TABLE IF EXISTS _test_auto_rls_check`);
|
||||
await conn.unsafe(`DROP TABLE IF EXISTS _test_ctas`);
|
||||
await conn.unsafe(`DROP TABLE IF EXISTS _test_select_into`);
|
||||
await conn.unsafe(`DROP TABLE IF EXISTS _test_backfill_plain`);
|
||||
await conn.unsafe(`DROP TABLE IF EXISTS _test_backfill_exempt`);
|
||||
await conn.unsafe(`DROP TABLE IF EXISTS "_test_BackfillCamelCase"`);
|
||||
await conn.unsafe(`DROP SCHEMA IF EXISTS _test_v35_scope CASCADE`);
|
||||
await teardownDB();
|
||||
});
|
||||
|
||||
test('event trigger exists', async () => {
|
||||
const conn = getConn();
|
||||
const triggers = await conn`
|
||||
SELECT evtname FROM pg_event_trigger
|
||||
WHERE evtname = 'auto_rls_on_create_table'
|
||||
`;
|
||||
expect(triggers.length).toBe(1);
|
||||
});
|
||||
|
||||
test('new tables automatically get RLS enabled (CREATE TABLE)', async () => {
|
||||
const conn = getConn();
|
||||
await conn`CREATE TABLE _test_auto_rls_check (id serial PRIMARY KEY, val text)`;
|
||||
|
||||
const result = await conn`
|
||||
SELECT rowsecurity FROM pg_tables
|
||||
WHERE schemaname = 'public' AND tablename = '_test_auto_rls_check'
|
||||
`;
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].rowsecurity).toBe(true);
|
||||
});
|
||||
|
||||
test('auto_enable_rls function exists', async () => {
|
||||
const conn = getConn();
|
||||
const funcs = await conn`
|
||||
SELECT proname FROM pg_proc
|
||||
WHERE proname = 'auto_enable_rls'
|
||||
`;
|
||||
expect(funcs.length).toBe(1);
|
||||
});
|
||||
|
||||
test('FORCE RLS is NOT applied (D1: ENABLE only)', async () => {
|
||||
// pg_class.relforcerowsecurity reflects FORCE; rowsecurity reflects ENABLE.
|
||||
// v35 enables only — operators or future migrations can opt FORCE in
|
||||
// explicitly per table if defense-in-depth is desired.
|
||||
const conn = getConn();
|
||||
const result = await conn`
|
||||
SELECT relforcerowsecurity FROM pg_class
|
||||
WHERE relname = '_test_auto_rls_check' AND relkind = 'r'
|
||||
`;
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].relforcerowsecurity).toBe(false);
|
||||
});
|
||||
|
||||
test('CREATE TABLE AS triggers auto-RLS (D6)', async () => {
|
||||
// CTAS is a distinct command_tag in Postgres ('CREATE TABLE AS'). The
|
||||
// trigger's WHEN TAG list covers it explicitly.
|
||||
const conn = getConn();
|
||||
await conn`CREATE TABLE _test_ctas AS SELECT 1 AS id, 'hello'::text AS val`;
|
||||
|
||||
const result = await conn`
|
||||
SELECT rowsecurity FROM pg_tables
|
||||
WHERE schemaname = 'public' AND tablename = '_test_ctas'
|
||||
`;
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].rowsecurity).toBe(true);
|
||||
});
|
||||
|
||||
test('SELECT INTO triggers auto-RLS (D6)', async () => {
|
||||
// SELECT INTO is the older synonym for CTAS. Postgres tags it 'SELECT INTO'.
|
||||
const conn = getConn();
|
||||
await conn`SELECT 1 AS id, 'world'::text AS val INTO _test_select_into`;
|
||||
|
||||
const result = await conn`
|
||||
SELECT rowsecurity FROM pg_tables
|
||||
WHERE schemaname = 'public' AND tablename = '_test_select_into'
|
||||
`;
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].rowsecurity).toBe(true);
|
||||
});
|
||||
|
||||
test('non-public schemas are not touched (D2)', async () => {
|
||||
// Build a private schema and a table inside. The trigger filters
|
||||
// schema_name = 'public' so this table should remain RLS-off.
|
||||
const conn = getConn();
|
||||
await conn`CREATE SCHEMA IF NOT EXISTS _test_v35_scope`;
|
||||
await conn`CREATE TABLE _test_v35_scope.private_tbl (id int)`;
|
||||
|
||||
const result = await conn`
|
||||
SELECT relrowsecurity FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = '_test_v35_scope' AND c.relname = 'private_tbl'
|
||||
`;
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].relrowsecurity).toBe(false);
|
||||
});
|
||||
|
||||
test('replay idempotency: re-running migration leaves exactly one trigger', async () => {
|
||||
const conn = getConn();
|
||||
expect(v35Sql.length).toBeGreaterThan(0);
|
||||
// Re-execute the entire v35 SQL. The DROP EVENT TRIGGER IF EXISTS +
|
||||
// CREATE EVENT TRIGGER pattern must be a clean round-trip. The backfill
|
||||
// DO block runs again too, but is a no-op since RLS is now on everywhere.
|
||||
await conn.unsafe(v35Sql);
|
||||
|
||||
const triggers = await conn`
|
||||
SELECT evtname FROM pg_event_trigger
|
||||
WHERE evtname = 'auto_rls_on_create_table'
|
||||
`;
|
||||
expect(triggers.length).toBe(1);
|
||||
|
||||
const funcs = await conn`SELECT proname FROM pg_proc WHERE proname = 'auto_enable_rls'`;
|
||||
expect(funcs.length).toBe(1);
|
||||
});
|
||||
|
||||
test('backfill enables RLS on pre-existing public.* tables', async () => {
|
||||
const conn = getConn();
|
||||
// Temporarily drop the trigger so we can create a table WITHOUT RLS,
|
||||
// simulating a pre-v35 row.
|
||||
await conn`DROP EVENT TRIGGER IF EXISTS auto_rls_on_create_table`;
|
||||
try {
|
||||
await conn`CREATE TABLE _test_backfill_plain (id serial PRIMARY KEY)`;
|
||||
// Belt-and-suspenders: explicitly disable RLS on this fresh table.
|
||||
await conn`ALTER TABLE _test_backfill_plain DISABLE ROW LEVEL SECURITY`;
|
||||
|
||||
const before = await conn`
|
||||
SELECT rowsecurity FROM pg_tables WHERE tablename = '_test_backfill_plain'
|
||||
`;
|
||||
expect(before[0].rowsecurity).toBe(false);
|
||||
|
||||
// Re-run v35: trigger comes back AND the backfill DO block flips this
|
||||
// table to RLS-on (no exempt comment, schema is public, relkind='r').
|
||||
await conn.unsafe(v35Sql);
|
||||
|
||||
const after = await conn`
|
||||
SELECT rowsecurity FROM pg_tables WHERE tablename = '_test_backfill_plain'
|
||||
`;
|
||||
expect(after[0].rowsecurity).toBe(true);
|
||||
} finally {
|
||||
// Make sure the trigger is restored even if assertions throw.
|
||||
const triggers = await conn`
|
||||
SELECT evtname FROM pg_event_trigger WHERE evtname = 'auto_rls_on_create_table'
|
||||
`;
|
||||
if (triggers.length === 0) {
|
||||
await conn.unsafe(v35Sql);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('backfill respects GBRAIN:RLS_EXEMPT comment (matches doctor regex)', async () => {
|
||||
const conn = getConn();
|
||||
await conn`DROP EVENT TRIGGER IF EXISTS auto_rls_on_create_table`;
|
||||
try {
|
||||
await conn`CREATE TABLE _test_backfill_exempt (id serial PRIMARY KEY)`;
|
||||
await conn`ALTER TABLE _test_backfill_exempt DISABLE ROW LEVEL SECURITY`;
|
||||
// Comment must match doctor.ts EXEMPT_RE: /^GBRAIN:RLS_EXEMPT\s+reason=\S.{3,}/
|
||||
// — "test exempt" is 11 chars after `reason=`, well over the .{3,} floor.
|
||||
await conn`COMMENT ON TABLE _test_backfill_exempt IS 'GBRAIN:RLS_EXEMPT reason=test exempt'`;
|
||||
|
||||
// Re-run the migration. Backfill should skip this row.
|
||||
await conn.unsafe(v35Sql);
|
||||
|
||||
const after = await conn`
|
||||
SELECT rowsecurity FROM pg_tables WHERE tablename = '_test_backfill_exempt'
|
||||
`;
|
||||
expect(after[0].rowsecurity).toBe(false);
|
||||
} finally {
|
||||
const triggers = await conn`
|
||||
SELECT evtname FROM pg_event_trigger WHERE evtname = 'auto_rls_on_create_table'
|
||||
`;
|
||||
if (triggers.length === 0) {
|
||||
await conn.unsafe(v35Sql);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('backfill quotes mixed-case identifiers safely (%I.%I)', async () => {
|
||||
const conn = getConn();
|
||||
await conn`DROP EVENT TRIGGER IF EXISTS auto_rls_on_create_table`;
|
||||
try {
|
||||
// Mixed-case table names require double-quoting in DDL. If the backfill
|
||||
// used %s with raw concat, ALTER TABLE public._test_BackfillCamelCase
|
||||
// would fail with "relation does not exist" because Postgres folds
|
||||
// unquoted identifiers to lowercase.
|
||||
await conn`CREATE TABLE "_test_BackfillCamelCase" (id serial PRIMARY KEY)`;
|
||||
await conn`ALTER TABLE "_test_BackfillCamelCase" DISABLE ROW LEVEL SECURITY`;
|
||||
|
||||
await conn.unsafe(v35Sql);
|
||||
|
||||
const after = await conn`
|
||||
SELECT relrowsecurity FROM pg_class
|
||||
WHERE relname = '_test_BackfillCamelCase' AND relkind = 'r'
|
||||
`;
|
||||
expect(after.length).toBe(1);
|
||||
expect(after[0].relrowsecurity).toBe(true);
|
||||
} finally {
|
||||
const triggers = await conn`
|
||||
SELECT evtname FROM pg_event_trigger WHERE evtname = 'auto_rls_on_create_table'
|
||||
`;
|
||||
if (triggers.length === 0) {
|
||||
await conn.unsafe(v35Sql);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('regression guard: trigger function body does NOT contain EXCEPTION WHEN OTHERS', async () => {
|
||||
// Codex correctly identified that wrapping the per-table EXECUTE in
|
||||
// BEGIN…EXCEPTION WHEN OTHERS… would convert a transactional rollback
|
||||
// (loud) into a silent permissive default (quiet). Pin that by reading
|
||||
// the function body from pg_proc and grepping it.
|
||||
const conn = getConn();
|
||||
const rows = await conn`
|
||||
SELECT prosrc FROM pg_proc WHERE proname = 'auto_enable_rls'
|
||||
`;
|
||||
expect(rows.length).toBe(1);
|
||||
const body = rows[0].prosrc as string;
|
||||
expect(body.toUpperCase()).not.toContain('EXCEPTION WHEN OTHERS');
|
||||
});
|
||||
});
|
||||
@@ -354,6 +354,86 @@ describe('migration v24 — rls_backfill_missing_tables', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// v0.26.7 — migration v35 structural guards (auto-RLS event trigger)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The PR review caught that the original v35 had three correctness issues:
|
||||
// - FORCE ROW LEVEL SECURITY locked out non-BYPASSRLS table owners.
|
||||
// - Trigger fired on Supabase-managed schemas (auth/storage/realtime/...).
|
||||
// - EXCEPTION WHEN OTHERS would silently swallow per-table failures and
|
||||
// replace a transactional rollback (loud) with a permissive default (quiet).
|
||||
// These tests pin the corrected shape so a future revert can't reintroduce
|
||||
// the original bugs.
|
||||
describe('migration v35 — auto_rls_event_trigger structural guards', () => {
|
||||
test('exists with the expected name and SQL shape', () => {
|
||||
const v35 = MIGRATIONS.find(m => m.version === 35);
|
||||
expect(v35).toBeDefined();
|
||||
expect(v35?.name).toBe('auto_rls_event_trigger');
|
||||
expect((v35?.sqlFor as any)?.postgres?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('uses a PGLite no-op override (no event trigger support on PGLite)', () => {
|
||||
const v35 = MIGRATIONS.find(m => m.version === 35);
|
||||
expect(v35?.sqlFor?.pglite).toBe('');
|
||||
});
|
||||
|
||||
test('does NOT issue FORCE ROW LEVEL SECURITY (D1: ENABLE only)', () => {
|
||||
const v35 = MIGRATIONS.find(m => m.version === 35);
|
||||
const sql = ((v35?.sqlFor as any)?.postgres ?? '') as string;
|
||||
expect(sql).not.toMatch(/FORCE\s+ROW\s+LEVEL\s+SECURITY/i);
|
||||
expect(sql).toMatch(/ENABLE\s+ROW\s+LEVEL\s+SECURITY/i);
|
||||
});
|
||||
|
||||
test('trigger function is scoped to schema_name = public (D2)', () => {
|
||||
const v35 = MIGRATIONS.find(m => m.version === 35);
|
||||
const sql = ((v35?.sqlFor as any)?.postgres ?? '') as string;
|
||||
expect(sql).toMatch(/schema_name\s*=\s*'public'/);
|
||||
});
|
||||
|
||||
test('WHEN TAG covers CREATE TABLE, CREATE TABLE AS, and SELECT INTO (D6)', () => {
|
||||
const v35 = MIGRATIONS.find(m => m.version === 35);
|
||||
const sql = ((v35?.sqlFor as any)?.postgres ?? '') as string;
|
||||
expect(sql).toMatch(/WHEN\s+TAG\s+IN\s*\([^)]*'CREATE TABLE'[^)]*\)/i);
|
||||
expect(sql).toMatch(/'CREATE TABLE AS'/);
|
||||
expect(sql).toMatch(/'SELECT INTO'/);
|
||||
});
|
||||
|
||||
test('does NOT contain EXCEPTION WHEN OTHERS inside the trigger function (D5 reversed)', () => {
|
||||
const v35 = MIGRATIONS.find(m => m.version === 35);
|
||||
const sql = ((v35?.sqlFor as any)?.postgres ?? '') as string;
|
||||
// ddl_command_end fires inside the DDL transaction, so a failed ALTER
|
||||
// aborts the offending CREATE TABLE — that's the security guarantee.
|
||||
// Wrapping in EXCEPTION WHEN OTHERS would convert that loud rollback
|
||||
// into a silent permissive default. Pin the absence.
|
||||
expect(sql.toUpperCase()).not.toContain('EXCEPTION WHEN OTHERS');
|
||||
});
|
||||
|
||||
test('backfill block uses %I.%I identifier quoting (codex correction)', () => {
|
||||
const v35 = MIGRATIONS.find(m => m.version === 35);
|
||||
const sql = ((v35?.sqlFor as any)?.postgres ?? '') as string;
|
||||
// The backfill iterates pg_class and ALTERs each non-exempt RLS-off public
|
||||
// table. Mixed-case identifiers require %I quoting; raw concat would break.
|
||||
expect(sql).toMatch(/format\(\s*'ALTER TABLE %I\.%I/);
|
||||
});
|
||||
|
||||
test('backfill exemption regex matches the doctor.ts contract', () => {
|
||||
const v35 = MIGRATIONS.find(m => m.version === 35);
|
||||
const sql = ((v35?.sqlFor as any)?.postgres ?? '') as string;
|
||||
// doctor.ts:418 EXEMPT_RE = /^GBRAIN:RLS_EXEMPT\s+reason=\S.{3,}/
|
||||
// The plpgsql side must use the same pattern (via ~) so the two surfaces
|
||||
// honor identical exemptions.
|
||||
expect(sql).toMatch(/'\^GBRAIN:RLS_EXEMPT\\s\+reason=\\S\.\{3,\}'/);
|
||||
});
|
||||
|
||||
test('backfill is gated on rolbypassrls (matches v24 posture)', () => {
|
||||
const v35 = MIGRATIONS.find(m => m.version === 35);
|
||||
const sql = ((v35?.sqlFor as any)?.postgres ?? '') as string;
|
||||
expect(sql).toMatch(/rolbypassrls/);
|
||||
expect(sql).toMatch(/RAISE\s+EXCEPTION/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// REGRESSION TESTS — migrations v8 + v9 perf on duplicate-heavy tables
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user