* fix(doctor): check ALL public tables for RLS, not just gbrain's own The RLS check was hardcoded to only verify 10 gbrain-managed tables: pages, content_chunks, links, tags, raw_data, page_versions, timeline_entries, ingest_log, config, files. Any other table in the public schema (created by the application, extensions, or manually) was invisible to the check. This allowed 12 tables to exist without RLS for months — publicly readable by anyone with the Supabase anon key. Changes: - Query ALL tables in public schema, not a hardcoded list - Upgrade severity from 'warn' to 'fail' — missing RLS is a security issue, not a suggestion - Include table count in success message for visibility - Include remediation SQL in failure message Supabase exposes the public schema via PostgREST. Any table without RLS is readable/writable by the anon key by default. * fix(schema): enable RLS on 10 gbrain-managed public tables The base schema and prior migrations shipped 10 public tables without Row Level Security enabled: access_tokens, mcp_request_log, minion_inbox, minion_attachments, subagent_messages, subagent_tool_executions, subagent_rate_leases, gbrain_cycle_locks, budget_ledger, budget_reservations. Supabase exposes the public schema via PostgREST, so tables without RLS are readable and writable by anyone holding the anon key. access_tokens and the subagent conversation history tables carry the most sensitive data in the set. Fix: add the missing ENABLE RLS statements to src/schema.sql (inside the existing BYPASSRLS-gated DO block, so dev sessions without bypass don't get locked out). Add a new schema migration v17 rls_backfill_missing_tables that does the same on existing brains. budget_ledger and budget_reservations were previously migration-only (v12); promoted to the base schema so fresh installs pick up RLS from the standard gate. Regenerated src/core/schema-embedded.ts. * fix(doctor): widen RLS check to all public tables, add GBRAIN:RLS_EXEMPT escape hatch The RLS check was hardcoded to 10 gbrain-managed tables; any other table in the public schema (plugin-created, user-created, extension- created) was invisible to the check. Widen the scan to every pg_tables row in the public schema. Upgrade severity warn to fail. Missing RLS is a security issue, not a suggestion. gbrain doctor now exits 1 when any public table lacks RLS. Cron and CI wrappers that call gbrain doctor should be aware of the exit-code flip. Add an explicit escape hatch for tables that should stay readable by the anon key on purpose (analytics, public materialized views, plugin tables). The doctor reads pg_description for each non-RLS table and treats a comment matching GBRAIN:RLS_EXEMPT reason=<why> as an intentional exemption. Doctor enumerates exempt tables by name on every successful run so they never go invisible. There is no gbrain rls-exempt CLI subcommand by design. The escape hatch is deliberately painful: operators drop to psql and type the justification as raw SQL. Comment lives in pg_description, survives pg_dump, shows up in schema diffs, and appears in shell history. PGLite is now explicitly skipped with an ok status (embedded and single-user, no PostgREST exposure). Previously hit the db.getConnection() throw-catch path and surfaced a misleading warn. Remediation SQL now quotes identifiers (ALTER TABLE "public"."<name>" ...) so it works on tables with hyphens, reserved words, or mixed case. See docs/guides/rls-and-you.md for the full user-facing guide. * test: coverage for RLS hardening (doctor + migration + e2e) Four layers of guard for the v0.18 RLS changes: test/doctor.test.ts: source-grep structural regression guards on the doctor RLS block — absence of the old tablename IN filter, presence of status=fail on the gap branch, quoted-identifier remediation SQL, PGLite skip wrapper, GBRAIN:RLS_EXEMPT parsing with required reason=. Fast, no DB needed. Mirrors the statement_timeout regression pattern in test/postgres-engine.test.ts. test/migrate.test.ts: structural guard for migration v17. Asserts the migration exists with the expected name, all 10 ALTER TABLE statements are present, BYPASSRLS gating is in place, and LATEST_VERSION has caught up. test/e2e/mechanical.test.ts: rewrote the E2E RLS Verification block. The old hardcoded-allowlist query is replaced with an every-public-table-has-RLS assertion. Four new CLI-spawn cases verify real end-to-end behavior: (a) no-RLS public table makes gbrain doctor --json return status=fail with ALTER TABLE in the message and exit code 1, (b) a GBRAIN:RLS_EXEMPT comment with a valid reason makes doctor report the table as explicitly exempt and keep status=ok, (c) a GBRAIN:RLS_EXEMPT prefix without a reason= segment still fails doctor, (d) an unrelated comment on a no-RLS table still fails doctor. All helpers use try/finally with unique-per-run suffixes (gbrain_rls_..._<pid>_<timestamp>) so assertion failures don't pollute subsequent tests. * docs: one-page guide for RLS and GBRAIN:RLS_EXEMPT escape hatch Covers why RLS matters on Supabase (PostgREST exposes the public schema to the anon key), what to do when gbrain doctor fails, the exact SQL template for an intentional exemption, how to audit exemptions later, and how the check behaves on PGLite vs self-hosted Postgres. Emphasizes that the escape hatch is deliberately painful on purpose: there is no gbrain rls-exempt CLI subcommand and no config-file allowlist. The operator drops to psql and writes the justification in SQL, which makes the action visible in shell history, pg_dump, schema diffs, and doctor output on every run. Referenced from gbrain doctor's failure message when any public table lacks RLS. * chore: bump version and changelog (v0.18.0) Reconciles VERSION and package.json (were drifting: 0.17.0 vs 0.16.4). Runtime gbrain --version reads from package.json via src/version.ts, so prior ships were reporting 0.16.4. Both now land on 0.18.0. Minor bump (not patch) because gbrain doctor's exit code semantics change: missing RLS on a public table was warn+exit-0, is now fail+exit-1. Any external cron, CI, or skillpack-check wrapper around gbrain doctor needs to be aware. skillpack-check.ts itself is unaffected (uses --fast, skips DB checks). CHANGELOG entry follows the release-summary format from CLAUDE.md: headline, lead paragraph, numbers-that-matter table, what-this- means-for-your-workflow, To take advantage of v0.18.0 block with remediation SQL + exemption format, itemized changes. Also sweeps a stale @Wintermute reference in the 0.17.0 entry to "Garry's OpenClaw" per the CLAUDE.md privacy rule. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(v0.18.1): address codex review (orchestrator wiring + fail-closed + identifier escape) Four fixes from `/codex` review of the merged diff: 1. HIGH — wire migration v24 into the `gbrain apply-migrations` upgrade path. Without an orchestrator entry, `gbrain upgrade`'s post-upgrade step runs `apply-migrations --yes`, which walks the registry in `src/commands/migrations/index.ts`. The registry stopped at v0_18_0, so v24 never fired on upgrade (connectEngine and doctor do not call initSchema). New `v0_18_1.ts` orchestrator mirrors v0.18.0's Phase A: shells out to `gbrain init --migrate-only`, which triggers initSchema → runMigrations → v24 applies. Registered in the migrations array. 2. HIGH — fail loudly when v24 runs under a non-BYPASSRLS role instead of RAISE WARNING-then-silently-bumping-version. The runner at migrate.ts:773 unconditionally calls `setConfig('version', String(m.version))` when a migration completes without throwing, so a WARNING-and-continue path would permanently lock the backfill out: schema_version=24 on the next run means `m.version > current` is false and v24 is skipped forever, even after the role gets BYPASSRLS. Changed `RAISE WARNING` → `RAISE EXCEPTION` so the transaction aborts, schema_version stays at 23, and a subsequent initSchema retries cleanly after the role is fixed. Test asserts the SQL uses EXCEPTION and does not use WARNING. 3. MEDIUM — escape double-quote characters in the remediation SQL output. doctor.ts was building `ALTER TABLE "public"."${n}"` with `n` un-escaped, so a pathological table name containing a literal `"` would break out of the quoted identifier and produce invalid copy-paste SQL. Double the `"` before interpolating, matching Postgres quoted-identifier escaping rules. Extremely rare in practice, cheap to get right. 4. LOW — CHANGELOG cleanup: corrected the upgrade-behavior claim (v24 runs via `apply-migrations --yes` through the new orchestrator, not during `gbrain doctor`) and split the "tables with RLS" row into two metrics (21 base-schema tables + 2 migration-only budget_* tables = 23 managed total, all covered). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: add v0.18.1 to apply-migrations skippedFuture expectations CI-only failure: test/apply-migrations.test.ts hardcodes the orchestrator-migration version list in two `skippedFuture` expectations. The v0.18.1 orchestrator I added in the prior commit pushed the list to 8 entries. Both assertions now include 0.18.1 at the tail. Caught by the gbrain CI run on the merged branch — locally the rest of the unit suite (dream/orphans) is flaky due to unrelated PGLite parallelism, but `bun test test/apply-migrations.test.ts` now passes 18/18. CI should follow. * docs: scrub v0.18.1 CHANGELOG — remove specific-table attack surface Responsible-disclosure pass on the public-facing release notes. The prior CHANGELOG entry enumerated which gbrain-managed public tables had shipped without RLS and highlighted the most sensitive ones by name. That gives anyone reading the CHANGELOG a directed probe list for unpatched Supabase installs before operators have had a chance to run `gbrain upgrade`. Rewritten to describe the change at a functional level (what doctor does now, what the upgrade path does, what the escape hatch is) without naming the specific tables or quantifying the gap. The actual SQL remains in the binary — anyone reverse-engineering can find it there — but we shouldn't put it on the release page with a banner. User-facing content kept intact: the "To take advantage of" block, the upgrade commands, the exemption SQL template, the breaking exit-code note. * docs(CLAUDE.md): add responsible-disclosure rule for release notes Prior incident on this branch: the original v0.18.1 CHANGELOG entry enumerated the specific public tables that had shipped without RLS, quantified the exposure duration, and highlighted the most sensitive ones by name. Garry caught it. Scrubbed in ecd06a0. This directive codifies the rule so future sessions (or other agents working in this repo) don't repeat the mistake: - Describe security fixes functionally, not by attack surface. - Public artifacts (CHANGELOG, README, docs/, PR titles/bodies, commit messages, release pages) get the functional description. - Private artifacts (plan files under ~/.claude/plans/ or ~/.gstack/projects/) keep the detailed before/after tables. - Source code will disclose the specifics to reverse engineers anyway — that's intrinsic. The concern is the broadcast-channel asymmetry of a release page. Also added a corresponding feedback memory at ~/.claude/projects/.../feedback_responsible_disclosure.md so the rule carries across sessions and other projects, not just gbrain. Placed right after the existing privacy rule (scrub real names) since they share the same "public artifact hygiene" posture. * chore: regenerate llms.txt + llms-full.txt (CLAUDE.md drift) Adding the responsible-disclosure rule to CLAUDE.md in ffe340d diverged the committed llms-full.txt from the generator output. The build-llms drift-guard test caught it in CI. Regenerated. * fix(v24): guard budget_ledger + budget_reservations with IF EXISTS Garry flagged: migration v24 fires `ALTER TABLE budget_ledger ENABLE ROW LEVEL SECURITY` unconditionally. budget_ledger and budget_reservations are migration-only (v12) — not in schema.sql, not re-created on every initSchema. In the normal flow v12 runs before v24 so they exist, but two edge cases break that assumption: 1. An operator manually dropped them (budget data is regenerable from resolver call logs, so `DROP TABLE` is a reasonable cleanup move). 2. A brain was somehow running an old gbrain that lacked v12, and is only catching up now. Bare ALTER hits 42P01 (relation does not exist), aborts the transaction, and leaves schema_version at 23. On next initSchema, v24 retries and hits the same error — stuck in a loop. Fix: wrap each of the two budget ALTERs in IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '<tbl>') THEN ... END IF; The other 8 tables are not guarded. schema.sql creates them idempotently on every initSchema run before migrations fire, so they are guaranteed to exist by the time v24 runs. Adding guards there would be unnecessary and make the SQL noisier. Also simplified the DECLARE/BEGIN structure: moved the non-BYPASSRLS early-exit to the top so the happy path reads cleanly without the outer IF. Tests: - test/migrate.test.ts: new assertion that both budget_* ALTERs are wrapped in information_schema.tables IF EXISTS blocks; BYPASSRLS gate assertion relaxed to match either phrasing. - Manual e2e: fresh Postgres init (v0→v24), then DROP TABLE budget_ledger + budget_reservations, reset version=23, re-run init. v24 applied cleanly, version advanced to 24, budget_* stayed dropped. Without the guard this would have errored out. * test(e2e): v24 self-heals when budget_* tables are missing Behavioral e2e proof for the IF EXISTS guard added in 2fc7780. Scenario: 1. Fresh Postgres init to v24 (setupDB in beforeAll). 2. DROP TABLE budget_ledger + budget_reservations. 3. Roll config.version back to '23'. 4. CLI-spawn `gbrain init --non-interactive` to re-trigger initSchema. 5. Assert: exit 0, no 42P01 in stderr, version advances to 24, budget_* stay dropped (since v12 doesn't re-run at current=23 > v12=12). Without the guard, step 4 hits 42P01 (relation does not exist), aborts the transaction, leaves version at 23, and the next initSchema re-runs v24 forever — an infinite retry loop. This test catches any future regression that strips the guard. Cleanup (finally block) restores budget_* with the exact migration v12 schema so downstream tests that reference these tables see the original shape. Version is restored from the pre-test snapshot. Runs with the rest of the E2E: RLS Verification block. 78/78 in test/e2e/mechanical.test.ts with the addition. --------- Co-authored-by: Wintermute <wintermute@garrytan.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
82
CHANGELOG.md
82
CHANGELOG.md
@@ -2,6 +2,86 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.18.1] - 2026-04-22
|
||||
|
||||
## **Row Level Security hardening pass.**
|
||||
## **Fresh installs secure by default. Existing brains are brought up to the same bar automatically on upgrade.**
|
||||
|
||||
A security-posture tightening release. `gbrain doctor` now enforces RLS across the entire `public` schema (not a hardcoded allowlist), the base schema ships every gbrain-managed table with RLS enabled, and an automatic migration runs on `gbrain upgrade` to bring older installs to the same state. After `gbrain upgrade` (or `gbrain apply-migrations --yes`), `gbrain doctor` should report clean on healthy brains.
|
||||
|
||||
The doctor check severity upgrades from `warn` to `fail`. Missing RLS is a security issue, not a suggestion. `gbrain doctor` exits 1 when any public table is missing RLS. If you wrap `gbrain doctor` in a cron or CI health check, expect it to flip red on setups that haven't upgraded.
|
||||
|
||||
There is an escape hatch for tables you deliberately want readable by the anon key (analytics views, public materialized views, plugin tables that use anon reads on purpose). It is a Postgres `COMMENT ON TABLE` with a `GBRAIN:RLS_EXEMPT reason=<why>` prefix. No CLI subcommand. You drop to psql and type the reason. Full details in [docs/guides/rls-and-you.md](docs/guides/rls-and-you.md). The escape hatch is deliberately painful because the default should be closed.
|
||||
|
||||
### What changes
|
||||
|
||||
| Area | BEFORE v0.18.1 | AFTER v0.18.1 |
|
||||
|------|----------------|---------------|
|
||||
| Scope of doctor RLS check | hardcoded allowlist | every `pg_tables` row in `public` |
|
||||
| Severity when RLS missing | warn (exit 0) | fail (exit 1) |
|
||||
| Escape hatch for intentional anon-readable tables | none | `GBRAIN:RLS_EXEMPT reason=...` pg comment |
|
||||
| Identifier-safe remediation SQL | no | yes (`ALTER TABLE "public"."<name>"`) |
|
||||
| PGLite doctor output for RLS | misleading warn | clean `ok` with skip reason |
|
||||
| Exemption list surfaced on every doctor run | n/a | enumerated by name |
|
||||
|
||||
### What this means for your workflow
|
||||
|
||||
Existing Supabase brains: run `gbrain upgrade`, then `gbrain doctor`. Everything managed by gbrain should report clean. If doctor flags something, it's a plugin, user-created, or extension table — the message names each one and gives you the exact `ALTER TABLE` line.
|
||||
|
||||
PGLite brains (the `gbrain init` default): nothing to do. RLS is irrelevant on embedded Postgres. Doctor skips the check with an explicit message.
|
||||
|
||||
Cron and CI wrappers: audit them. The exit-code flip is the one breaking change in this release. If a table is anon-readable on purpose, use the `GBRAIN:RLS_EXEMPT` comment escape hatch rather than silencing the whole check.
|
||||
|
||||
Credit: Garry's OpenClaw for the original check-widening PR (#336). Codex found additional gaps during plan review.
|
||||
|
||||
## To take advantage of v0.18.1
|
||||
|
||||
`gbrain upgrade` should do this automatically. It runs `gbrain post-upgrade`,
|
||||
which calls `gbrain apply-migrations --yes`, which runs the v0.18.1 orchestrator.
|
||||
If `gbrain doctor` still reports missing RLS after upgrade:
|
||||
|
||||
1. **Apply migrations manually:**
|
||||
```bash
|
||||
gbrain apply-migrations --yes
|
||||
```
|
||||
2. **Re-run the health check:**
|
||||
```bash
|
||||
gbrain doctor
|
||||
```
|
||||
3. **If specific tables still fail**, the doctor message names each one and gives you the fix. Example:
|
||||
```
|
||||
1 table(s) WITHOUT Row Level Security: my_plugin_state. Fix: ALTER TABLE "public"."my_plugin_state" ENABLE ROW LEVEL SECURITY;
|
||||
```
|
||||
4. **If a table should stay readable by the anon key on purpose**, use the escape hatch (see `docs/guides/rls-and-you.md`):
|
||||
```sql
|
||||
COMMENT ON TABLE public.my_analytics_view IS
|
||||
'GBRAIN:RLS_EXEMPT reason=analytics-only, anon-readable ok, owner=you, date=2026-04-22';
|
||||
```
|
||||
5. **If any step fails or the numbers look wrong**, please file an issue:
|
||||
https://github.com/garrytan/gbrain/issues with:
|
||||
- output of `gbrain doctor --json`
|
||||
- contents of `~/.gbrain/upgrade-errors.jsonl` if it exists
|
||||
- which step broke
|
||||
|
||||
This feedback loop is how the gbrain maintainers find fragile upgrade paths. Thank you.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
- **Schema + migration:** `src/schema.sql` and `src/core/schema-embedded.ts` ensure every gbrain-managed public table ships with RLS enabled for fresh installs. A new schema migration in `src/core/migrate.ts` backfills existing brains to the same state. The migration is gated on `rolbypassrls` and fails loudly if the current role lacks BYPASSRLS (so `schema_version` stays at the prior value and retries cleanly after role assignment).
|
||||
- **Upgrade orchestrator:** New `src/commands/migrations/v0_18_1.ts` wires the schema migration into the `gbrain apply-migrations --yes` path (mirrors v0.18.0's Phase A pattern).
|
||||
- **Doctor check widened:** `src/commands/doctor.ts` RLS check now scans every public table from `pg_tables` rather than a hardcoded allowlist. Severity upgraded `warn → fail`. Success message shows table count. Failure message includes per-table quoted `ALTER TABLE "public"."<name>" ENABLE ROW LEVEL SECURITY;` remediation SQL.
|
||||
- **Escape hatch — "write it in blood":** Doctor reads `obj_description` for each non-RLS public table. Tables whose comment matches `^GBRAIN:RLS_EXEMPT\s+reason=\S.{3,}` count as explicitly exempt. Exempt tables are enumerated by name on every successful doctor run so the exemption list never goes invisible. No CLI subcommand — deliberate friction; operators must set the comment in psql.
|
||||
- **PGLite skip:** PGLite is embedded and single-user with no PostgREST; the RLS check now skips on PGLite with an explicit `ok` message ("Skipped — no PostgREST exposure, RLS not applicable") instead of the misleading `warn` it emitted before. Partial polish: pgvector, jsonb_integrity, and markdown_body_completeness checks still hit the same `getConnection()` throw → warn pattern on PGLite. Separate follow-up.
|
||||
- **Tests:**
|
||||
- `test/doctor.test.ts` gains source-grep structural regression guards covering scan scope, fail severity + quoted-identifier remediation, PGLite skip wrapper, and `GBRAIN:RLS_EXEMPT` parsing.
|
||||
- `test/e2e/mechanical.test.ts` `E2E: RLS Verification` block rewritten. The old allowlist-query test is replaced with an every-public-table-has-RLS assertion; new CLI-spawn tests verify fail-on-no-RLS (with exit code + ALTER TABLE in JSON message), exempt-with-valid-reason passes, empty-reason exemption fails, and unrelated comment still fails. All helpers use `try/finally` with unique suffix-per-run table names.
|
||||
- `test/migrate.test.ts` gains a structural guard for the new migration: exists, name matches, BYPASSRLS gating present, LATEST_VERSION has advanced.
|
||||
- **Docs:** new `docs/guides/rls-and-you.md` — one-page explainer covering why RLS matters, what to do when doctor fails, the escape hatch format + rules, auditing exemptions, PGLite behavior, self-hosted Postgres framing.
|
||||
- **Version reconciliation:** `VERSION` and `package.json` land on `0.18.1`.
|
||||
- **CHANGELOG privacy sweep:** replaced a stale `@Wintermute` credit in the 0.17.0 entry with "Garry's OpenClaw" per the [CLAUDE.md privacy rule](CLAUDE.md).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
|
||||
## [0.18.0] - 2026-04-22
|
||||
|
||||
## **Multi-source brains. One database, many repos. Federated or isolated, you choose.**
|
||||
@@ -137,7 +217,7 @@ Autopilot users: nothing to do. Your daemon picks up the new phases on next cycl
|
||||
|
||||
Reviewers/codex caught three plan-breakers during multi-round review that would have shipped silent DB writes on dry-run: (1) `performSync`'s full-sync path was ignoring `opts.dryRun`, (2) `runEmbedCore` had no dry-run mode and returned void, (3) `findOrphans` used `db.getConnection()` global and didn't compose with a passed engine. All three are fixed as preconditions (commits 1-3 of the 6-commit bisectable series).
|
||||
|
||||
Credit: @Wintermute for the original `gbrain dream` thesis (PR #309). The brand-promise framing survived; the implementation got redesigned from scratch around the runCycle primitive after CEO + Eng + Codex + DX review found structural issues.
|
||||
Credit: Garry's OpenClaw for the original `gbrain dream` thesis (PR #309). The brand-promise framing survived; the implementation got redesigned from scratch around the runCycle primitive after CEO + Eng + Codex + DX review found structural issues.
|
||||
|
||||
## To take advantage of v0.17.0
|
||||
|
||||
|
||||
53
CLAUDE.md
53
CLAUDE.md
@@ -596,6 +596,59 @@ GitHub, etc.) are fine — they're public entities, not contacts in anyone's bra
|
||||
Do not confuse illustrative API examples with queries that reveal real
|
||||
relationships.
|
||||
|
||||
## Responsible-disclosure rule: don't broadcast attack surface in release notes
|
||||
|
||||
**When a release fixes a security gap or a user-impacting bug, describe the fix
|
||||
functionally. Do not enumerate the attack surface, quantify the exposure window,
|
||||
or highlight the most sensitive records by name in public-facing artifacts.**
|
||||
|
||||
Public-facing artifacts include: `CHANGELOG.md`, `README.md`, `docs/`, PR titles
|
||||
and bodies, commit messages, GitHub issue titles and comments, release pages,
|
||||
tweets, blog posts.
|
||||
|
||||
**Don't write:**
|
||||
- "10 tables were publicly readable by the anon key for months, including X, Y, Z"
|
||||
- "X and Y are the most sensitive ones"
|
||||
- "N tables exposed. Fix: enable RLS on these specific tables: ..."
|
||||
|
||||
**Do write:**
|
||||
- "Security hardening pass. Fresh installs secure by default. Existing brains
|
||||
brought to the same bar automatically on upgrade."
|
||||
- "If `gbrain doctor` still flags anything after upgrade, the message names each
|
||||
table and gives the exact fix."
|
||||
|
||||
Why: anyone reading the release page before they've upgraded now has a directed
|
||||
probe list for unpatched installs. The source code ships the specifics anyway
|
||||
(`src/schema.sql`, `src/core/migrate.ts`, test fixtures) — reverse engineers can
|
||||
get them. But the release page is a broadcast channel. Don't hand attackers a
|
||||
curated list with a banner.
|
||||
|
||||
**The test:** if a reader with no prior context could read the release note and
|
||||
walk away knowing "gbrain at version X has table Y readable by anon key until
|
||||
they patch," the note is too specific. Rewrite until that's no longer possible.
|
||||
|
||||
**What IS fine in public artifacts:**
|
||||
- The mechanism of the fix ("the check now scans every public table instead of
|
||||
a hardcoded allowlist").
|
||||
- User-facing operator ergonomics (the escape-hatch SQL template, the upgrade
|
||||
commands, the breaking-change flag).
|
||||
- Credit to contributors.
|
||||
- Generic framing of severity ("security posture tightening pass") without
|
||||
quantification.
|
||||
|
||||
**What stays in private artifacts (plan files, private memories, internal docs):**
|
||||
- Specific table names, record counts, exposure duration.
|
||||
- Which records stand out as highest-risk.
|
||||
- Detailed before/after tables in the "numbers that matter" format.
|
||||
|
||||
If the CEO/Eng review of a plan produces a detailed exposure table, keep it in
|
||||
the plan file under `~/.claude/plans/` or `~/.gstack/projects/`. Don't copy it
|
||||
into the CHANGELOG or PR body.
|
||||
|
||||
Applies retroactively: if you see a prior CHANGELOG entry naming attack-surface
|
||||
specifics, scrub it as a small cleanup commit, the same way a stale Wintermute
|
||||
reference gets swept.
|
||||
|
||||
## Schema state tracking
|
||||
|
||||
`~/.gbrain/update-state.json` tracks which recommended schema directories the user
|
||||
|
||||
154
docs/guides/rls-and-you.md
Normal file
154
docs/guides/rls-and-you.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# RLS and you
|
||||
|
||||
Short version: every table in your gbrain's `public` schema needs Row Level
|
||||
Security enabled. If one doesn't, `gbrain doctor` now fails, not warns, and the
|
||||
process exits 1.
|
||||
|
||||
This guide explains why, what to do when you hit the check, and the escape hatch
|
||||
for the cases where you really do want a table to stay readable by the anon key.
|
||||
|
||||
## Why RLS matters
|
||||
|
||||
Supabase exposes everything in the `public` schema via PostgREST. Whatever's
|
||||
there is reachable by the anon key, which is a client-side secret by design.
|
||||
If RLS is off on a public table, the anon key can read it. On anything sensitive
|
||||
(auth tokens, chat history, financial data) that's an exfiltration vector, not
|
||||
a footgun.
|
||||
|
||||
gbrain's service-role connection holds `BYPASSRLS`, so enabling RLS without
|
||||
policies does NOT break gbrain itself. It just blocks the anon key's default
|
||||
read. That's the security posture: deny-by-default to anon, full access for
|
||||
the service role.
|
||||
|
||||
## What to do when doctor fails
|
||||
|
||||
Doctor's message names every table missing RLS and gives you a `ALTER TABLE`
|
||||
line per table:
|
||||
|
||||
```
|
||||
1 table(s) WITHOUT Row Level Security: expenses_ramp.
|
||||
Fix: ALTER TABLE "public"."expenses_ramp" ENABLE ROW LEVEL SECURITY;
|
||||
If a table should stay readable by the anon key on purpose, see
|
||||
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.
|
||||
|
||||
## The 1% case: deliberate exemption
|
||||
|
||||
Sometimes a public table is supposed to be readable by the anon key. An
|
||||
analytics view backing a public dashboard. A read-only reference table. A
|
||||
plugin that ships its own frontend and intentionally uses the anon key for
|
||||
reads.
|
||||
|
||||
gbrain has an escape hatch for these. It is deliberately painful to set up.
|
||||
That is the feature.
|
||||
|
||||
### The format
|
||||
|
||||
```sql
|
||||
-- In psql, connected as a BYPASSRLS role (e.g. postgres):
|
||||
COMMENT ON TABLE public.your_table IS
|
||||
'GBRAIN:RLS_EXEMPT reason=<why this is anon-readable on purpose>';
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- The comment value MUST start with `GBRAIN:RLS_EXEMPT` (case-sensitive).
|
||||
- It MUST include `reason=` followed by at least 4 characters of justification.
|
||||
- No other prefix, no checkbox in a config file, no environment variable. Only
|
||||
a Postgres table comment counts.
|
||||
- If RLS is also off on the table (which it must be for the anon key to
|
||||
actually read), you also need `ALTER TABLE ... DISABLE ROW LEVEL SECURITY;`
|
||||
explicitly. Disabling alone is not enough; the comment is what tells doctor
|
||||
this is intentional.
|
||||
|
||||
### Example
|
||||
|
||||
```sql
|
||||
ALTER TABLE public.expenses_ramp DISABLE ROW LEVEL SECURITY;
|
||||
COMMENT ON TABLE public.expenses_ramp IS
|
||||
'GBRAIN:RLS_EXEMPT reason=analytics-only, anon-readable ok, owner=garry, 2026-04-22';
|
||||
```
|
||||
|
||||
After that, `gbrain doctor` reports:
|
||||
|
||||
```
|
||||
rls: ok — RLS enabled on 20/21 public tables (1 explicitly exempt: expenses_ramp)
|
||||
```
|
||||
|
||||
Note that every subsequent run re-enumerates your exemptions by name. That's
|
||||
intentional. The escape hatch is not a one-time sign-off, it's a recurring
|
||||
reminder. If you ever want to know which tables are open, run `gbrain doctor`.
|
||||
|
||||
## Why SQL and not a CLI subcommand
|
||||
|
||||
gbrain does NOT ship a `gbrain rls-exempt add <table>` command. A CLI command
|
||||
would make it easy for an agent to silently open a table to anon reads. The
|
||||
comment-in-psql requirement forces the operator to type the justification
|
||||
in SQL, which is:
|
||||
|
||||
- Visible in shell history.
|
||||
- Visible in a git-tracked schema dump.
|
||||
- Visible in `pg_dump` output the next time you restore.
|
||||
- Visible in `gbrain doctor` output on every run.
|
||||
|
||||
An agent CAN still run the SQL, but it can't do it without the user seeing the
|
||||
action. That's the "write it in blood" design.
|
||||
|
||||
## Auditing exemptions later
|
||||
|
||||
To see every exemption in the current DB:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
c.relname AS table_name,
|
||||
obj_description(c.oid, 'pg_class') AS comment
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND c.relkind = 'r'
|
||||
AND obj_description(c.oid, 'pg_class') LIKE 'GBRAIN:RLS_EXEMPT%';
|
||||
```
|
||||
|
||||
If that list is longer than you remember signing off on, that's the signal.
|
||||
|
||||
## Removing an exemption
|
||||
|
||||
Just drop the comment and re-enable RLS:
|
||||
|
||||
```sql
|
||||
ALTER TABLE public.expenses_ramp ENABLE ROW LEVEL SECURITY;
|
||||
COMMENT ON TABLE public.expenses_ramp IS NULL;
|
||||
```
|
||||
|
||||
`gbrain doctor` stops listing the table as exempt and goes back to checking
|
||||
it like any other.
|
||||
|
||||
## PGLite
|
||||
|
||||
If you're on PGLite (the zero-config default), doctor skips this check
|
||||
entirely: PGLite is embedded, single-user, and has no PostgREST in front of
|
||||
it. The public-schema-exposure risk doesn't exist. You'll see:
|
||||
|
||||
```
|
||||
rls: ok — Skipped (PGLite — no PostgREST exposure, RLS not applicable)
|
||||
```
|
||||
|
||||
If you migrate to Supabase or self-hosted Postgres later, the check starts
|
||||
running and will flag any table that came over without RLS.
|
||||
|
||||
## Self-hosted Postgres
|
||||
|
||||
If you're running Postgres without PostgREST in front, the anon-key exposure
|
||||
doesn't apply. But gbrain still fails the check on missing RLS, because:
|
||||
|
||||
- The framing is "RLS on all public tables" is a gbrain security invariant,
|
||||
not a Supabase-specific workaround.
|
||||
- The `ALTER TABLE ... ENABLE RLS` fix is harmless on any Postgres: it only
|
||||
constrains non-bypass roles, which gbrain doesn't use.
|
||||
- If you ever put PostgREST or a similar tool in front later, the guard is
|
||||
already in place.
|
||||
|
||||
If this framing doesn't fit your deployment, file an issue with the specifics
|
||||
so we can decide whether a self-hosted-exempt mode is justified.
|
||||
@@ -675,6 +675,59 @@ GitHub, etc.) are fine — they're public entities, not contacts in anyone's bra
|
||||
Do not confuse illustrative API examples with queries that reveal real
|
||||
relationships.
|
||||
|
||||
## Responsible-disclosure rule: don't broadcast attack surface in release notes
|
||||
|
||||
**When a release fixes a security gap or a user-impacting bug, describe the fix
|
||||
functionally. Do not enumerate the attack surface, quantify the exposure window,
|
||||
or highlight the most sensitive records by name in public-facing artifacts.**
|
||||
|
||||
Public-facing artifacts include: `CHANGELOG.md`, `README.md`, `docs/`, PR titles
|
||||
and bodies, commit messages, GitHub issue titles and comments, release pages,
|
||||
tweets, blog posts.
|
||||
|
||||
**Don't write:**
|
||||
- "10 tables were publicly readable by the anon key for months, including X, Y, Z"
|
||||
- "X and Y are the most sensitive ones"
|
||||
- "N tables exposed. Fix: enable RLS on these specific tables: ..."
|
||||
|
||||
**Do write:**
|
||||
- "Security hardening pass. Fresh installs secure by default. Existing brains
|
||||
brought to the same bar automatically on upgrade."
|
||||
- "If `gbrain doctor` still flags anything after upgrade, the message names each
|
||||
table and gives the exact fix."
|
||||
|
||||
Why: anyone reading the release page before they've upgraded now has a directed
|
||||
probe list for unpatched installs. The source code ships the specifics anyway
|
||||
(`src/schema.sql`, `src/core/migrate.ts`, test fixtures) — reverse engineers can
|
||||
get them. But the release page is a broadcast channel. Don't hand attackers a
|
||||
curated list with a banner.
|
||||
|
||||
**The test:** if a reader with no prior context could read the release note and
|
||||
walk away knowing "gbrain at version X has table Y readable by anon key until
|
||||
they patch," the note is too specific. Rewrite until that's no longer possible.
|
||||
|
||||
**What IS fine in public artifacts:**
|
||||
- The mechanism of the fix ("the check now scans every public table instead of
|
||||
a hardcoded allowlist").
|
||||
- User-facing operator ergonomics (the escape-hatch SQL template, the upgrade
|
||||
commands, the breaking-change flag).
|
||||
- Credit to contributors.
|
||||
- Generic framing of severity ("security posture tightening pass") without
|
||||
quantification.
|
||||
|
||||
**What stays in private artifacts (plan files, private memories, internal docs):**
|
||||
- Specific table names, record counts, exposure duration.
|
||||
- Which records stand out as highest-risk.
|
||||
- Detailed before/after tables in the "numbers that matter" format.
|
||||
|
||||
If the CEO/Eng review of a plan produces a detailed exposure table, keep it in
|
||||
the plan file under `~/.claude/plans/` or `~/.gstack/projects/`. Don't copy it
|
||||
into the CHANGELOG or PR body.
|
||||
|
||||
Applies retroactively: if you see a prior CHANGELOG entry naming attack-surface
|
||||
specifics, scrub it as a small cleanup commit, the same way a stale Wintermute
|
||||
reference gets swept.
|
||||
|
||||
## Schema state tracking
|
||||
|
||||
`~/.gbrain/update-state.json` tracks which recommended schema directories the user
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gbrain",
|
||||
"version": "0.16.4",
|
||||
"version": "0.18.1",
|
||||
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
|
||||
"type": "module",
|
||||
"main": "src/core/index.ts",
|
||||
|
||||
@@ -276,25 +276,94 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
|
||||
// best-effort; never fail doctor on this check
|
||||
}
|
||||
|
||||
// 5. RLS
|
||||
// 5. RLS — check ALL public tables, not just gbrain's own.
|
||||
// Any table without RLS in the public schema is a security risk:
|
||||
// Supabase exposes the public schema via PostgREST, so tables without
|
||||
// RLS are readable/writable by anyone with the anon key.
|
||||
//
|
||||
// Escape hatch ("write it in blood"): if a user or plugin deliberately
|
||||
// wants a public-schema table readable by the anon key (analytics,
|
||||
// materialized views the anon key needs), they can exempt it with a
|
||||
// Postgres COMMENT whose value starts with:
|
||||
//
|
||||
// GBRAIN:RLS_EXEMPT reason=<non-empty reason>
|
||||
//
|
||||
// The comment lives in pg_description, survives pg_dump, is visible in
|
||||
// schema diffs, and requires raw SQL in psql to set — there is no
|
||||
// `gbrain rls-exempt add` CLI on purpose. Doctor re-enumerates the
|
||||
// exemption list on every successful run so exempt tables never go
|
||||
// invisible. See docs/guides/rls-and-you.md.
|
||||
progress.heartbeat('rls');
|
||||
try {
|
||||
const sql = db.getConnection();
|
||||
const tables = await sql`
|
||||
SELECT tablename, rowsecurity FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename IN ('pages','content_chunks','links','tags','raw_data',
|
||||
'page_versions','timeline_entries','ingest_log','config','files')
|
||||
`;
|
||||
const noRls = tables.filter((t: any) => !t.rowsecurity);
|
||||
if (noRls.length === 0) {
|
||||
checks.push({ name: 'rls', status: 'ok', message: 'RLS enabled on all tables' });
|
||||
} else {
|
||||
const names = noRls.map((t: any) => t.tablename).join(', ');
|
||||
checks.push({ name: 'rls', status: 'warn', message: `RLS not enabled on: ${names}` });
|
||||
if (engine.kind === 'pglite') {
|
||||
// PGLite is embedded and single-user — no PostgREST exposure,
|
||||
// RLS is not a meaningful security boundary here.
|
||||
checks.push({
|
||||
name: 'rls',
|
||||
status: 'ok',
|
||||
message: 'Skipped (PGLite — no PostgREST exposure, RLS not applicable)',
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
const sql = db.getConnection();
|
||||
// Left-join pg_description so we get the (optional) COMMENT ON TABLE
|
||||
// value alongside rowsecurity in a single round-trip. Filter to
|
||||
// base tables in the public schema.
|
||||
const tables = await sql`
|
||||
SELECT
|
||||
t.tablename,
|
||||
t.rowsecurity,
|
||||
COALESCE(
|
||||
obj_description(format('public.%I', t.tablename)::regclass, 'pg_class'),
|
||||
''
|
||||
) AS comment
|
||||
FROM pg_tables t
|
||||
WHERE t.schemaname = 'public'
|
||||
`;
|
||||
const EXEMPT_RE = /^GBRAIN:RLS_EXEMPT\s+reason=\S.{3,}/;
|
||||
const exempt: string[] = [];
|
||||
const gaps: string[] = [];
|
||||
for (const t of tables as Array<any>) {
|
||||
if (t.rowsecurity) continue;
|
||||
if (EXEMPT_RE.test(t.comment || '')) {
|
||||
exempt.push(t.tablename);
|
||||
} else {
|
||||
gaps.push(t.tablename);
|
||||
}
|
||||
}
|
||||
if (gaps.length === 0) {
|
||||
const suffix = exempt.length > 0
|
||||
? ` (${exempt.length} explicitly exempt: ${exempt.join(', ')})`
|
||||
: '';
|
||||
checks.push({
|
||||
name: 'rls',
|
||||
status: 'ok',
|
||||
message: `RLS enabled on ${tables.length - exempt.length}/${tables.length} public tables${suffix}`,
|
||||
});
|
||||
} else {
|
||||
const names = gaps.join(', ');
|
||||
// Double-escape " inside identifiers so a pathological table name
|
||||
// like `weird"table` renders as `"weird""table"` in the remediation
|
||||
// SQL (matches how Postgres parses quoted identifiers). Doubling
|
||||
// any existing " is the minimum needed to keep the output valid
|
||||
// copy-paste SQL. Extremely rare in practice but cheap to get right.
|
||||
const fixes = gaps
|
||||
.map(n => `ALTER TABLE "public"."${n.replace(/"/g, '""')}" ENABLE ROW LEVEL SECURITY;`)
|
||||
.join(' ');
|
||||
const exemptInfo = exempt.length > 0
|
||||
? ` (${exempt.length} other table(s) explicitly exempt.)`
|
||||
: '';
|
||||
checks.push({
|
||||
name: 'rls',
|
||||
status: 'fail',
|
||||
message:
|
||||
`${gaps.length} table(s) WITHOUT Row Level Security: ${names}.${exemptInfo} ` +
|
||||
`Fix: ${fixes} ` +
|
||||
`If a table should stay readable by the anon key on purpose, see docs/guides/rls-and-you.md for the GBRAIN:RLS_EXEMPT comment escape hatch.`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
checks.push({ name: 'rls', status: 'warn', message: 'Could not check RLS status' });
|
||||
}
|
||||
} catch {
|
||||
checks.push({ name: 'rls', status: 'warn', message: 'Could not check RLS status' });
|
||||
}
|
||||
|
||||
// 6. Schema version — also surfaces the #218 "postinstall silently failed"
|
||||
|
||||
@@ -19,6 +19,7 @@ import { v0_13_1 } from './v0_13_1.ts';
|
||||
import { v0_14_0 } from './v0_14_0.ts';
|
||||
import { v0_16_0 } from './v0_16_0.ts';
|
||||
import { v0_18_0 } from './v0_18_0.ts';
|
||||
import { v0_18_1 } from './v0_18_1.ts';
|
||||
|
||||
export const migrations: Migration[] = [
|
||||
v0_11_0,
|
||||
@@ -29,6 +30,7 @@ export const migrations: Migration[] = [
|
||||
v0_14_0,
|
||||
v0_16_0,
|
||||
v0_18_0,
|
||||
v0_18_1,
|
||||
];
|
||||
|
||||
/** Look up a migration by exact version string. */
|
||||
|
||||
69
src/commands/migrations/v0_18_1.ts
Normal file
69
src/commands/migrations/v0_18_1.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* v0.18.1 migration orchestrator — RLS hardening.
|
||||
*
|
||||
* v0.18.1 ships one new schema migration: v24 `rls_backfill_missing_tables`.
|
||||
* It enables Row Level Security on 10 gbrain-managed public tables that
|
||||
* shipped without it: access_tokens, mcp_request_log, minion_inbox,
|
||||
* minion_attachments, subagent_messages, subagent_tool_executions,
|
||||
* subagent_rate_leases, gbrain_cycle_locks, budget_ledger, budget_reservations.
|
||||
*
|
||||
* Phase structure mirrors v0.18.0:
|
||||
* A. Schema — `gbrain init --migrate-only` runs the migration chain,
|
||||
* picking up v24 on brains currently at v23 (post-v0.18.0) or earlier.
|
||||
*
|
||||
* Without this orchestrator, the `apply-migrations` registry stops at
|
||||
* v0.18.0 and the low-level schema migration in src/core/migrate.ts never
|
||||
* fires on upgrade, because doctor + connectEngine never call initSchema().
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
|
||||
|
||||
// ── Phase A — Schema ────────────────────────────────────────
|
||||
|
||||
function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
|
||||
if (opts.dryRun) return { name: 'schema', status: 'skipped', detail: 'dry-run' };
|
||||
try {
|
||||
execSync('gbrain init --migrate-only', { stdio: 'inherit', timeout: 600_000, env: process.env });
|
||||
return { name: 'schema', status: 'complete' };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { name: 'schema', status: 'failed', detail: msg };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Orchestrator ────────────────────────────────────────────
|
||||
|
||||
async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult> {
|
||||
const phases: OrchestratorPhaseResult[] = [];
|
||||
phases.push(phaseASchema(opts));
|
||||
|
||||
const anyFailed = phases.some(p => p.status === 'failed');
|
||||
const status: OrchestratorResult['status'] = anyFailed ? 'partial' : 'complete';
|
||||
|
||||
return {
|
||||
version: '0.18.1',
|
||||
status,
|
||||
phases,
|
||||
pending_host_work: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Export ──────────────────────────────────────────────────
|
||||
|
||||
export const v0_18_1: Migration = {
|
||||
version: '0.18.1',
|
||||
featurePitch: {
|
||||
headline: 'Row Level Security hardened on all public tables + escape hatch.',
|
||||
description:
|
||||
'v0.18.1 fixes a latent security gap: 10 gbrain-managed public tables ' +
|
||||
'shipped without RLS. On Supabase, they were reachable by the anon key. ' +
|
||||
'Migration v24 backfills RLS on existing brains automatically when ' +
|
||||
'`gbrain apply-migrations` runs. `gbrain doctor` now scans every ' +
|
||||
'public table (no hardcoded allowlist) and exits 1 on gaps. For tables ' +
|
||||
'that should stay anon-readable on purpose, operators set a ' +
|
||||
'`GBRAIN:RLS_EXEMPT reason=<why>` comment via psql. See ' +
|
||||
'docs/guides/rls-and-you.md.',
|
||||
},
|
||||
orchestrator,
|
||||
};
|
||||
@@ -687,6 +687,74 @@ export const MIGRATIONS: Migration[] = [
|
||||
CREATE INDEX IF NOT EXISTS idx_cycle_locks_ttl ON gbrain_cycle_locks(ttl_expires_at);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 24,
|
||||
name: 'rls_backfill_missing_tables',
|
||||
// v0.18.1 RLS hardening: 10 gbrain-managed public tables shipped
|
||||
// without RLS enabled (access_tokens, mcp_request_log, minion_inbox,
|
||||
// minion_attachments, subagent_messages, subagent_tool_executions,
|
||||
// subagent_rate_leases, gbrain_cycle_locks, budget_ledger,
|
||||
// budget_reservations). Supabase exposes the public schema via
|
||||
// PostgREST, so tables without RLS are readable by anyone with the
|
||||
// anon key.
|
||||
//
|
||||
// Numbered v24 to slot after v0.18.0's v20-v23 sources-migration
|
||||
// wave. The 'sources' and 'file_migration_ledger' tables added in
|
||||
// v0.18.0 already get RLS from schema.sql's base DO block; v24
|
||||
// backfills the 10 older tables that never had it.
|
||||
//
|
||||
// Gated on BYPASSRLS matching the pattern in schema.sql: enabling RLS
|
||||
// on a table in a session that does NOT hold BYPASSRLS would lock
|
||||
// the session out of its own data. RAISE WARNING is visible to the
|
||||
// migration runner's log stream.
|
||||
sql: `
|
||||
DO $$
|
||||
DECLARE
|
||||
has_bypass BOOLEAN;
|
||||
BEGIN
|
||||
SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user;
|
||||
IF NOT has_bypass THEN
|
||||
-- Fail the migration loudly instead of WARNING + version-bump.
|
||||
-- The runner unconditionally records schema_version on success,
|
||||
-- so a silent WARNING here would permanently lock the backfill out
|
||||
-- on future runs even after switching to a bypass role. Raising
|
||||
-- aborts the transaction, leaves schema_version at the prior value,
|
||||
-- and lets the next invocation retry after the role is fixed.
|
||||
RAISE EXCEPTION 'v24 rls_backfill_missing_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;
|
||||
|
||||
-- These 8 are guaranteed to exist: schema.sql creates them (idempotent
|
||||
-- via IF NOT EXISTS) on every initSchema call, and initSchema runs
|
||||
-- before this migration. Bare ALTER TABLE is safe.
|
||||
ALTER TABLE access_tokens ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE mcp_request_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE minion_inbox ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE minion_attachments ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE subagent_messages ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE subagent_tool_executions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE subagent_rate_leases ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE gbrain_cycle_locks ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- budget_ledger + budget_reservations are migration-only (v12). Not
|
||||
-- in schema.sql, not re-created on every initSchema. In normal flow
|
||||
-- v12 runs before v24 so they exist, but if an operator manually
|
||||
-- dropped them (unusual — budget data is regenerable from resolver
|
||||
-- logs) or was pinned to a pre-v12 gbrain version when the table
|
||||
-- went away, the bare ALTER would fail with 42P01 and abort v24.
|
||||
-- information_schema.tables lookup makes the statement self-healing.
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'budget_ledger') THEN
|
||||
ALTER TABLE budget_ledger ENABLE ROW LEVEL SECURITY;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'budget_reservations') THEN
|
||||
ALTER TABLE budget_reservations ENABLE ROW LEVEL SECURITY;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'v24: RLS backfill complete (role % has BYPASSRLS)', current_user;
|
||||
END $$;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export const LATEST_VERSION = MIGRATIONS.length > 0
|
||||
|
||||
@@ -556,6 +556,14 @@ BEGIN
|
||||
ALTER TABLE minion_jobs ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE sources ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE file_migration_ledger ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE access_tokens ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE mcp_request_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE minion_inbox ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE minion_attachments ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE subagent_messages ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE subagent_tool_executions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE subagent_rate_leases ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE gbrain_cycle_locks ENABLE ROW LEVEL SECURITY;
|
||||
RAISE NOTICE 'RLS enabled on all tables (role % has BYPASSRLS)', current_user;
|
||||
ELSE
|
||||
RAISE WARNING 'Skipping RLS: role % does not have BYPASSRLS privilege. Run as postgres role to enable.', current_user;
|
||||
|
||||
@@ -552,6 +552,14 @@ BEGIN
|
||||
ALTER TABLE minion_jobs ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE sources ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE file_migration_ledger ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE access_tokens ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE mcp_request_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE minion_inbox ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE minion_attachments ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE subagent_messages ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE subagent_tool_executions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE subagent_rate_leases ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE gbrain_cycle_locks ENABLE ROW LEVEL SECURITY;
|
||||
RAISE NOTICE 'RLS enabled on all tables (role % has BYPASSRLS)', current_user;
|
||||
ELSE
|
||||
RAISE WARNING 'Skipping RLS: role % does not have BYPASSRLS privilege. Run as postgres role to enable.', current_user;
|
||||
|
||||
@@ -106,8 +106,8 @@ describe('buildPlan — diff against completed + installed VERSION', () => {
|
||||
// skippedFuture until the binary catches up. v0.13.0 = frontmatter graph,
|
||||
// v0.13.1 = Knowledge Runtime grandfather, v0.14.0 = shell jobs +
|
||||
// autopilot cooperative, v0.16.0 = subagent runtime, v0.18.0 = multi-
|
||||
// source brains (this branch).
|
||||
expect(plan.skippedFuture.map(m => m.version)).toEqual(['0.12.0', '0.12.2', '0.13.0', '0.13.1', '0.14.0', '0.16.0', '0.18.0']);
|
||||
// source brains, v0.18.1 = RLS hardening (this branch).
|
||||
expect(plan.skippedFuture.map(m => m.version)).toEqual(['0.12.0', '0.12.2', '0.13.0', '0.13.1', '0.14.0', '0.16.0', '0.18.0', '0.18.1']);
|
||||
});
|
||||
|
||||
test('already applied → v0.11.0 lands in `applied` bucket, not pending', () => {
|
||||
@@ -143,11 +143,11 @@ describe('buildPlan — diff against completed + installed VERSION', () => {
|
||||
const idx = indexCompleted([]);
|
||||
const plan = buildPlan(idx, '0.12.0');
|
||||
expect(plan.pending.map(m => m.version)).toContain('0.11.0');
|
||||
// v0.12.2, v0.13.0, v0.13.1, v0.14.0, v0.16.0, v0.18.0 were added later;
|
||||
// installed=0.12.0 means they belong in skippedFuture, not pending. v0.11.0
|
||||
// and v0.12.0 stay pending despite being ≤ installed — that is the H9
|
||||
// invariant.
|
||||
expect(plan.skippedFuture.map(m => m.version)).toEqual(['0.12.2', '0.13.0', '0.13.1', '0.14.0', '0.16.0', '0.18.0']);
|
||||
// v0.12.2, v0.13.0, v0.13.1, v0.14.0, v0.16.0, v0.18.0, v0.18.1 were
|
||||
// added later; installed=0.12.0 means they belong in skippedFuture, not
|
||||
// pending. v0.11.0 and v0.12.0 stay pending despite being ≤ installed —
|
||||
// that is the H9 invariant.
|
||||
expect(plan.skippedFuture.map(m => m.version)).toEqual(['0.12.2', '0.13.0', '0.13.1', '0.14.0', '0.16.0', '0.18.0', '0.18.1']);
|
||||
});
|
||||
|
||||
test('--migration filter narrows to one version', () => {
|
||||
|
||||
@@ -90,4 +90,58 @@ describe('doctor command', () => {
|
||||
expect(source).toMatch(/table:\s*'ingest_log'.*col:\s*'pages_updated'/);
|
||||
expect(source).toMatch(/table:\s*'files'.*col:\s*'metadata'/);
|
||||
});
|
||||
|
||||
// v0.18 RLS hardening — regression guards for PR #336 + schema backfill.
|
||||
// These are structural assertions on the source string so a silent revert
|
||||
// of the severity or the IN-filter removal fails loudly without a live DB.
|
||||
test('RLS check scans ALL public tables (no hardcoded tablename IN list near the RLS block)', async () => {
|
||||
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
||||
const rlsBlock = source.slice(
|
||||
source.indexOf('// 5. RLS'),
|
||||
source.indexOf('// 6. Schema version'),
|
||||
);
|
||||
expect(rlsBlock.length).toBeGreaterThan(0);
|
||||
// Old pattern — must not come back. If it does, we're filtering the scan
|
||||
// to a hardcoded set and every plugin/user table is invisible again.
|
||||
expect(rlsBlock).not.toMatch(/tablename\s+IN\s*\(/);
|
||||
// New semantics: the scan query has no WHERE-IN filter, just schemaname='public'.
|
||||
expect(rlsBlock).toMatch(/FROM\s+pg_tables\b[\s\S]{0,200}schemaname\s*=\s*'public'/);
|
||||
});
|
||||
|
||||
test('RLS check raises status=fail with quoted-identifier remediation SQL', async () => {
|
||||
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
||||
const rlsBlock = source.slice(
|
||||
source.indexOf('// 5. RLS'),
|
||||
source.indexOf('// 6. Schema version'),
|
||||
);
|
||||
// Severity upgraded from 'warn' to 'fail' so `gbrain doctor` exits 1 on gaps.
|
||||
expect(rlsBlock).toMatch(/status:\s*'fail'/);
|
||||
// Remediation SQL uses quoted identifiers — safe for names with hyphens,
|
||||
// reserved words, mixed case.
|
||||
expect(rlsBlock).toContain('ALTER TABLE "public"."');
|
||||
expect(rlsBlock).toContain('ENABLE ROW LEVEL SECURITY');
|
||||
});
|
||||
|
||||
test('RLS check skips on PGLite (no PostgREST, not applicable)', async () => {
|
||||
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
||||
const rlsBlock = source.slice(
|
||||
source.indexOf('// 5. RLS'),
|
||||
source.indexOf('// 6. Schema version'),
|
||||
);
|
||||
expect(rlsBlock).toMatch(/engine\.kind\s*===\s*'pglite'/);
|
||||
expect(rlsBlock).toContain('PGLite');
|
||||
});
|
||||
|
||||
test('RLS check reads pg_description and recognizes the GBRAIN:RLS_EXEMPT escape hatch', async () => {
|
||||
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
||||
const rlsBlock = source.slice(
|
||||
source.indexOf('// 5. RLS'),
|
||||
source.indexOf('// 6. Schema version'),
|
||||
);
|
||||
expect(rlsBlock).toContain('obj_description');
|
||||
expect(rlsBlock).toContain('GBRAIN:RLS_EXEMPT');
|
||||
// The regex must require a non-empty reason= segment. "Blood" is in the
|
||||
// requirement to write a real justification, not just the prefix.
|
||||
expect(rlsBlock).toMatch(/reason=/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -938,30 +938,249 @@ describeE2E('E2E: RLS Verification', () => {
|
||||
});
|
||||
afterAll(teardownDB);
|
||||
|
||||
test('RLS is enabled on all gbrain tables', async () => {
|
||||
const cliCwd = join(import.meta.dir, '../..');
|
||||
const cliEnv = () => ({ ...process.env, DATABASE_URL: process.env.DATABASE_URL!, GBRAIN_DATABASE_URL: process.env.DATABASE_URL! });
|
||||
|
||||
// Seed a unique suffix per run so concurrent test DBs / crashed prior
|
||||
// runs don't collide. All helper tables follow `gbrain_rls_regression_<suffix>`.
|
||||
const suffix = `${process.pid}_${Date.now()}`;
|
||||
|
||||
test('RLS is enabled on every public table (no hardcoded allowlist)', async () => {
|
||||
const conn = getConn();
|
||||
const tables = await conn.unsafe(`
|
||||
SELECT tablename, rowsecurity FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename IN ('pages','content_chunks','links','tags','raw_data',
|
||||
'page_versions','timeline_entries','ingest_log','config','files')
|
||||
`);
|
||||
const noRls = tables.filter((t: any) => !t.rowsecurity);
|
||||
// Some test DBs may not have BYPASSRLS privilege, so RLS might be skipped.
|
||||
// If RLS was enabled, all tables should have it.
|
||||
// If RLS was enabled at all (the common case against Docker postgres), EVERY
|
||||
// public table must have it — no hardcoded IN-list exceptions.
|
||||
if (tables.some((t: any) => t.rowsecurity)) {
|
||||
expect(noRls.length).toBe(0);
|
||||
expect(noRls.map((t: any) => t.tablename)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('current user role has BYPASSRLS', async () => {
|
||||
const conn = getConn();
|
||||
const rows = await conn.unsafe(`SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user`);
|
||||
// Docker test DB uses postgres role which has BYPASSRLS
|
||||
if (rows.length > 0) {
|
||||
expect(rows[0].rolbypassrls).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('gbrain doctor fails with exit 1 when a public table is missing RLS', async () => {
|
||||
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.
|
||||
Bun.spawnSync({
|
||||
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!],
|
||||
cwd: cliCwd, env: cliEnv(), timeout: 15_000,
|
||||
});
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['bun', 'run', 'src/cli.ts', 'doctor', '--json'],
|
||||
cwd: cliCwd, env: cliEnv(), timeout: 20_000,
|
||||
});
|
||||
const stdout = new TextDecoder().decode(result.stdout);
|
||||
const parsed = JSON.parse(stdout);
|
||||
const rls = parsed.checks.find((c: any) => c.name === 'rls');
|
||||
expect(rls).toBeDefined();
|
||||
expect(rls.status).toBe('fail');
|
||||
expect(rls.message).toContain(tbl);
|
||||
expect(rls.message).toContain('ALTER TABLE');
|
||||
expect(result.exitCode).toBe(1);
|
||||
} finally {
|
||||
await conn.unsafe(`DROP TABLE IF EXISTS public.${tbl}`);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test('GBRAIN:RLS_EXEMPT comment with valid reason exempts a non-RLS public table', async () => {
|
||||
const conn = getConn();
|
||||
const tbl = `gbrain_rls_exempt_ok_${suffix}`;
|
||||
try {
|
||||
await conn.unsafe(`CREATE TABLE public.${tbl} (id int)`);
|
||||
await conn.unsafe(`ALTER TABLE public.${tbl} DISABLE ROW LEVEL SECURITY`);
|
||||
await conn.unsafe(`COMMENT ON TABLE public.${tbl} IS 'GBRAIN:RLS_EXEMPT reason=e2e test fixture, anon-readable ok'`);
|
||||
|
||||
Bun.spawnSync({
|
||||
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!],
|
||||
cwd: cliCwd, env: cliEnv(), timeout: 15_000,
|
||||
});
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['bun', 'run', 'src/cli.ts', 'doctor', '--json'],
|
||||
cwd: cliCwd, env: cliEnv(), timeout: 20_000,
|
||||
});
|
||||
const stdout = new TextDecoder().decode(result.stdout);
|
||||
const parsed = JSON.parse(stdout);
|
||||
const rls = parsed.checks.find((c: any) => c.name === 'rls');
|
||||
expect(rls.status).toBe('ok');
|
||||
expect(rls.message).toContain('explicitly exempt');
|
||||
expect(rls.message).toContain(tbl);
|
||||
} finally {
|
||||
await conn.unsafe(`DROP TABLE IF EXISTS public.${tbl}`);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test('GBRAIN:RLS_EXEMPT comment WITHOUT reason= still fails doctor', async () => {
|
||||
const conn = getConn();
|
||||
const tbl = `gbrain_rls_exempt_bad_${suffix}`;
|
||||
try {
|
||||
await conn.unsafe(`CREATE TABLE public.${tbl} (id int)`);
|
||||
await conn.unsafe(`ALTER TABLE public.${tbl} DISABLE ROW LEVEL SECURITY`);
|
||||
// Missing the `reason=<...>` segment — prefix alone is not enough.
|
||||
await conn.unsafe(`COMMENT ON TABLE public.${tbl} IS 'GBRAIN:RLS_EXEMPT'`);
|
||||
|
||||
Bun.spawnSync({
|
||||
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!],
|
||||
cwd: cliCwd, env: cliEnv(), timeout: 15_000,
|
||||
});
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['bun', 'run', 'src/cli.ts', 'doctor', '--json'],
|
||||
cwd: cliCwd, env: cliEnv(), timeout: 20_000,
|
||||
});
|
||||
const stdout = new TextDecoder().decode(result.stdout);
|
||||
const parsed = JSON.parse(stdout);
|
||||
const rls = parsed.checks.find((c: any) => c.name === 'rls');
|
||||
expect(rls.status).toBe('fail');
|
||||
expect(rls.message).toContain(tbl);
|
||||
expect(result.exitCode).toBe(1);
|
||||
} finally {
|
||||
await conn.unsafe(`DROP TABLE IF EXISTS public.${tbl}`);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test('Non-exempt unrelated COMMENT on a no-RLS table still fails doctor', async () => {
|
||||
const conn = getConn();
|
||||
const tbl = `gbrain_rls_comment_${suffix}`;
|
||||
try {
|
||||
await conn.unsafe(`CREATE TABLE public.${tbl} (id int)`);
|
||||
await conn.unsafe(`ALTER TABLE public.${tbl} DISABLE ROW LEVEL SECURITY`);
|
||||
await conn.unsafe(`COMMENT ON TABLE public.${tbl} IS 'Regular docs comment, not an exemption'`);
|
||||
|
||||
Bun.spawnSync({
|
||||
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!],
|
||||
cwd: cliCwd, env: cliEnv(), timeout: 15_000,
|
||||
});
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['bun', 'run', 'src/cli.ts', 'doctor', '--json'],
|
||||
cwd: cliCwd, env: cliEnv(), timeout: 20_000,
|
||||
});
|
||||
const stdout = new TextDecoder().decode(result.stdout);
|
||||
const parsed = JSON.parse(stdout);
|
||||
const rls = parsed.checks.find((c: any) => c.name === 'rls');
|
||||
expect(rls.status).toBe('fail');
|
||||
expect(result.exitCode).toBe(1);
|
||||
} finally {
|
||||
await conn.unsafe(`DROP TABLE IF EXISTS public.${tbl}`);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
// Regression test for the v24 self-healing guard. If an operator manually
|
||||
// drops budget_ledger and/or budget_reservations (they are migration-only
|
||||
// per v12, not in schema.sql, and the data is regenerable from resolver
|
||||
// logs — so dropping them is a reasonable cleanup), v24 must NOT fail
|
||||
// with 42P01. The information_schema.tables IF EXISTS guards around those
|
||||
// two ALTERs let the migration skip them and continue.
|
||||
//
|
||||
// Without the guard, a brain with dropped budget_* tables would get stuck
|
||||
// in an infinite retry loop: v24 fails → transaction rolls back →
|
||||
// schema_version stays at prior value → next initSchema re-runs v24 →
|
||||
// same failure forever.
|
||||
test('v24 self-heals when budget_ledger + budget_reservations are missing', async () => {
|
||||
const conn = getConn();
|
||||
let priorVersion: string | null = null;
|
||||
try {
|
||||
// Capture current version so we can restore after the test.
|
||||
const verRows = await conn.unsafe(`SELECT value FROM config WHERE key = 'version'`);
|
||||
priorVersion = (verRows[0] as any)?.value ?? null;
|
||||
|
||||
// Simulate an operator who dropped the budget_* tables for any reason
|
||||
// (cleanup, migration from an older gbrain, etc).
|
||||
await conn.unsafe(`DROP TABLE IF EXISTS public.budget_ledger CASCADE`);
|
||||
await conn.unsafe(`DROP TABLE IF EXISTS public.budget_reservations CASCADE`);
|
||||
|
||||
// Roll the version back to 23 so v24 re-runs on the next initSchema.
|
||||
// UPSERT so this works whether the key exists or not.
|
||||
await conn.unsafe(`
|
||||
INSERT INTO config (key, value) VALUES ('version', '23')
|
||||
ON CONFLICT (key) DO UPDATE SET value = '23'
|
||||
`);
|
||||
|
||||
// Re-trigger initSchema via the CLI. With the guard, this should
|
||||
// apply v24 cleanly and advance version to 24. Without the guard,
|
||||
// this would error out with 42P01 and leave version at 23.
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!],
|
||||
cwd: cliCwd, env: cliEnv(), timeout: 30_000,
|
||||
});
|
||||
const stdout = new TextDecoder().decode(result.stdout);
|
||||
const stderr = new TextDecoder().decode(result.stderr);
|
||||
|
||||
// Must succeed — no 42P01, no transaction rollback.
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(stderr + stdout).not.toMatch(/42P01|does not exist.*budget/i);
|
||||
|
||||
// Version must have advanced to 24.
|
||||
const afterRows = await conn.unsafe(`SELECT value FROM config WHERE key = 'version'`);
|
||||
expect((afterRows[0] as any).value).toBe('24');
|
||||
|
||||
// The tables stayed dropped (v12 didn't re-run because current=23 > 12
|
||||
// was already true before this test ran). That's intentional — we're
|
||||
// proving v24 doesn't require those tables to exist.
|
||||
const tblRows = await conn.unsafe(`
|
||||
SELECT tablename FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename IN ('budget_ledger', 'budget_reservations')
|
||||
`);
|
||||
expect(tblRows.length).toBe(0);
|
||||
} finally {
|
||||
// Restore: recreate the budget_* tables (minimal schema — just enough
|
||||
// to keep the rest of the test suite happy) and reset version.
|
||||
// Mirror migration v12's CREATE TABLE IF NOT EXISTS exactly so any
|
||||
// downstream test that touches these tables sees the original shape.
|
||||
await conn.unsafe(`
|
||||
CREATE TABLE IF NOT EXISTS budget_ledger (
|
||||
scope TEXT NOT NULL,
|
||||
resolver_id TEXT NOT NULL,
|
||||
local_date DATE NOT NULL,
|
||||
reserved_usd NUMERIC(12,4) NOT NULL DEFAULT 0,
|
||||
committed_usd NUMERIC(12,4) NOT NULL DEFAULT 0,
|
||||
cap_usd NUMERIC(12,4),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (scope, resolver_id, local_date)
|
||||
)
|
||||
`);
|
||||
await conn.unsafe(`
|
||||
CREATE TABLE IF NOT EXISTS budget_reservations (
|
||||
reservation_id TEXT PRIMARY KEY,
|
||||
scope TEXT NOT NULL,
|
||||
resolver_id TEXT NOT NULL,
|
||||
local_date DATE NOT NULL,
|
||||
estimate_usd NUMERIC(12,4) NOT NULL,
|
||||
reserved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'held'
|
||||
)
|
||||
`);
|
||||
// Enable RLS on the recreated tables so the "every public table has
|
||||
// RLS" assertion earlier in this block stays green if re-run.
|
||||
await conn.unsafe(`ALTER TABLE budget_ledger ENABLE ROW LEVEL SECURITY`);
|
||||
await conn.unsafe(`ALTER TABLE budget_reservations ENABLE ROW LEVEL SECURITY`);
|
||||
// Restore version so we don't leave the DB at a weird state for
|
||||
// subsequent test blocks.
|
||||
if (priorVersion !== null) {
|
||||
await conn.unsafe(
|
||||
`UPDATE config SET value = $1 WHERE key = 'version'`,
|
||||
[priorVersion],
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -172,6 +172,93 @@ describe('migrate — ordering guarantee (v15 must NOT be skipped by v16)', () =
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// v0.18.1 RLS hardening — structural guard for migration v24
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The base schema shipped 8 gbrain-managed public tables without RLS
|
||||
// enabled (access_tokens, mcp_request_log, minion_inbox,
|
||||
// minion_attachments, subagent_messages, subagent_tool_executions,
|
||||
// subagent_rate_leases, gbrain_cycle_locks). Migration v12 created
|
||||
// two more (budget_ledger, budget_reservations) without RLS.
|
||||
// Migration v24 backfills the ENABLE RLS statements for existing
|
||||
// brains. This test guards against regressions where the migration
|
||||
// gets truncated or the wrong tables get enabled.
|
||||
|
||||
describe('migration v24 — rls_backfill_missing_tables', () => {
|
||||
const RLS_BACKFILL_TABLES = [
|
||||
'access_tokens',
|
||||
'mcp_request_log',
|
||||
'minion_inbox',
|
||||
'minion_attachments',
|
||||
'subagent_messages',
|
||||
'subagent_tool_executions',
|
||||
'subagent_rate_leases',
|
||||
'gbrain_cycle_locks',
|
||||
'budget_ledger',
|
||||
'budget_reservations',
|
||||
];
|
||||
|
||||
test('exists with the expected name', () => {
|
||||
const v24 = MIGRATIONS.find(m => m.version === 24);
|
||||
expect(v24).toBeDefined();
|
||||
expect(v24?.name).toBe('rls_backfill_missing_tables');
|
||||
});
|
||||
|
||||
test('enables RLS on all 10 backfill tables', () => {
|
||||
const v24 = MIGRATIONS.find(m => m.version === 24);
|
||||
expect(v24).toBeDefined();
|
||||
const sql = v24!.sql || '';
|
||||
for (const tbl of RLS_BACKFILL_TABLES) {
|
||||
expect(sql).toContain(`ALTER TABLE ${tbl} ENABLE ROW LEVEL SECURITY`);
|
||||
}
|
||||
});
|
||||
|
||||
test('is gated on BYPASSRLS so it never locks a non-bypass session out of its data', () => {
|
||||
const v24 = MIGRATIONS.find(m => m.version === 24);
|
||||
const sql = v24!.sql || '';
|
||||
expect(sql).toContain('rolbypassrls');
|
||||
// The gate can be either IF has_bypass / early-raise pattern.
|
||||
expect(sql).toMatch(/IF (NOT )?has_bypass/);
|
||||
});
|
||||
|
||||
// Self-healing guard: the budget_* tables are migration-only (v12). If an
|
||||
// operator manually dropped them, or if a brain was somehow pinned to a
|
||||
// pre-v12 version when those tables didn't exist, a bare `ALTER TABLE
|
||||
// budget_ledger ...` would fail with 42P01 and abort v24. Wrapping those
|
||||
// two ALTERs in an `IF EXISTS (information_schema.tables ...)` check lets
|
||||
// the migration skip them silently instead of erroring out. The other 8
|
||||
// tables are created by schema.sql on every initSchema and don't need
|
||||
// the guard — bare ALTER is fine.
|
||||
test('guards budget_ledger + budget_reservations with information_schema.tables IF EXISTS', () => {
|
||||
const v24 = MIGRATIONS.find(m => m.version === 24);
|
||||
const sql = v24!.sql || '';
|
||||
// Both budget tables must be wrapped in an existence check.
|
||||
expect(sql).toMatch(
|
||||
/IF EXISTS \(SELECT 1 FROM information_schema\.tables[\s\S]{0,200}table_name = 'budget_ledger'\)[\s\S]{0,200}ALTER TABLE budget_ledger ENABLE ROW LEVEL SECURITY/,
|
||||
);
|
||||
expect(sql).toMatch(
|
||||
/IF EXISTS \(SELECT 1 FROM information_schema\.tables[\s\S]{0,200}table_name = 'budget_reservations'\)[\s\S]{0,200}ALTER TABLE budget_reservations ENABLE ROW LEVEL SECURITY/,
|
||||
);
|
||||
});
|
||||
|
||||
// Codex found: if v24 RAISE WARNINGs instead of raising on non-BYPASSRLS,
|
||||
// the migration runner still bumps schema_version to 24, permanently
|
||||
// skipping the backfill on future runs even after the role is fixed.
|
||||
// The fix is to raise loudly so the transaction aborts, version stays
|
||||
// at 23, and the next initSchema call retries after role reassignment.
|
||||
test('fails loudly on non-BYPASSRLS roles instead of silently bumping version', () => {
|
||||
const v24 = MIGRATIONS.find(m => m.version === 24);
|
||||
const sql = v24!.sql || '';
|
||||
expect(sql).toMatch(/RAISE EXCEPTION[^;]*BYPASSRLS/);
|
||||
expect(sql).not.toMatch(/RAISE WARNING[^;]*BYPASSRLS/);
|
||||
});
|
||||
|
||||
test('LATEST_VERSION has caught up to 24', () => {
|
||||
expect(LATEST_VERSION).toBeGreaterThanOrEqual(24);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// REGRESSION TESTS — migrations v8 + v9 perf on duplicate-heavy tables
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user