From 9f68eec9518eae0bb81f176ce4e2c325e262ecc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 09:14:29 +0000 Subject: [PATCH 1/2] docs(ops): showcase backup with a read-only backup role and a rehearsed restore (#108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit operations.md gets "Backup and restore (showcase)": what Supabase covers per plan, a dedicated read-only BYPASSRLS export role (app_owner cannot dump under forced RLS, by design), schedule, retention, encrypted storage outside the repo, the documents bucket as not backed up with its consequence, the restore steps into a separate database, and a local rehearsal protocol (counts, migration state, RLS guard, roles) – the showcase rehearsal stays open. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117LRiQKt8ycJEHW7vxLWG3 --- docs/technical/operations.md | 62 ++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/docs/technical/operations.md b/docs/technical/operations.md index 8b03204..2747e3a 100644 --- a/docs/technical/operations.md +++ b/docs/technical/operations.md @@ -160,6 +160,68 @@ endpoints are disabled except `set-active` (404) – every change goes through t confirm the notification; reset the variable afterwards. - Out of scope: error-rate alerts from log aggregation, monitoring of a customer environment. +## Backup and restore (showcase) + +Issue #108. Production (customer environment) uses managed PostgreSQL with PITR (ADR-0001 → production +path); this section covers only the showcase database on Supabase (ADR-0001 D11 amendment 2026-09-24). + +**What backs it up.** Supabase backs up Pro/Team/Enterprise projects daily (Pro: last 7 days); a Free +project has no automatic backup, and Supabase recommends regular exports. Database backups never contain +Storage objects. So the showcase needs its own export, whatever the plan: + +| Item | Value | +|---|---| +| What | `pg_dump -Fc` of the schemas `app`, `identity`, `drizzle` (migration state) and `pgboss` (waiting jobs) – not the Supabase-managed schemas | +| As whom | a dedicated read-only role `backup_reader` (Supabase's recommendation): `LOGIN BYPASSRLS`, `pg_read_all_data`, `default_transaction_read_only = on`; created once by the orchestrator as `postgres`, password in the password manager. **Not** `app_owner`: with forced RLS and `NOBYPASSRLS` its `pg_dump` fails ("query would be affected by row-level security policy") – by design (ADR-0001 D7) | +| How often | weekly and before every migration on the showcase | +| Kept | the last 4 exports | +| Where | encrypted (e.g. `age` or the password manager's file vault) in the orchestrator's storage – **never** in the repository, CI artifacts or a shared drive: an export holds every company's data and the user accounts (names, e-mail addresses – personal data, also on the showcase) | +| Client | `pg_dump` / `pg_restore` of the server's major version (showcase: PostgreSQL 17) or newer | + +```bash +pg_dump "postgresql://backup_reader.@:5432/postgres" -Fc \ + -n app -n identity -n drizzle -n pgboss -f requestflow-showcase-.dump +``` + +`backup_reader` can read every row of every company – it is as sensitive as the `postgres` password and +is used for nothing else. Status: **not set up yet** (account action, orchestrator). + +**Documents bucket: not backed up.** `requestflow-documents` (Supabase Storage) is outside the export. +After a restore, rows in `app.documents` may point to missing objects: the original download answers 404, +reprocessing such a request fails. Acceptable for the showcase (synthetic data only): sample requests are +rebuilt with `pnpm seed:samples`, other uploads are lost. A customer system needs versioned object storage +(production path). + +**Restore – always into a separate, empty database, never over the showcase:** + +1. Create the target (a new Supabase project, or a local PostgreSQL) and run + `scripts/supabase-bootstrap.sql` there as `postgres` – the dump keeps the owners, so `app_owner` and + `app_rw` (both `NOBYPASSRLS`) must exist first. +2. `pg_restore -d "" --exit-on-error requestflow-showcase-.dump` +3. `pnpm setup:deploy` against the target – the migrations are a no-op, the job queues are confirmed. +4. Check, source vs. target: `select count(*) from drizzle.__drizzle_migrations`, row counts of + `app.requests` and `identity."user"`, `pgboss.job`; the RLS guard (`TABLE_SECURITY_QUERY` + + `tenantIsolationViolations` from `src/features/tenancy`) reports no violation; `app_rw` sees 0 rows of + `app.requests` without a company context. + +**Rehearsal protocol (2026-10-16, local PostgreSQL 16 – not the showcase).** Source: a database built +with the real migration runner (20 migrations) and `installJobQueues`, synthetic rows for two companies +(11 requests, 3 users, 1 waiting job). Steps 1–4 as above, roles pre-existing in the same cluster. Result: + +| Check | Source | Restored | +|---|---|---| +| migrations | 20 | 20 (still 20 after `setup:deploy` steps) | +| `app.requests` / `identity."user"` / `pgboss.job` | 11 / 3 / 1 | 11 / 3 / 1 | +| tables not owned by `app_owner` (`app`, `identity`, `pgboss`) | 0 | 0 | +| RLS guard violations (8 `app` tables) | none | none | +| `app_owner` / `app_rw` superuser or BYPASSRLS | no / no | no / no | +| `app_rw` without / with company A context | 0 / 7 | 0 / 7 | + +Dump and restore took about 1 s (96 KB). `pg_dump` as `app_owner` failed as expected; as `backup_reader` +it exported all 27 data sections, and the role could not write (`cannot execute INSERT in a read-only +transaction`). **Still open:** one rehearsal against the showcase itself (export via the pooler, restore +into a separate project) once `backup_reader` exists – record date, duration and the same table here. + ## Frequent failures | Symptom | Cause | Action | From b041322f48b72050cfa5dc3c73100e4e3085066e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 09:17:37 +0000 Subject: [PATCH 2/2] docs(ops): restore as non-superuser, target env, backup role in the role table (#125 review) Restore on Supabase needs postgres to be a member of app_owner/app_rw (tested: without it pg_restore stops at OWNER TO; with it the restore is complete and passes the RLS guard). setup:deploy must point at the target's database and storage. backup_reader is listed in the role table and the exceptions register; read-only rests on missing write grants. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117LRiQKt8ycJEHW7vxLWG3 --- docs/technical/architecture.md | 2 ++ docs/technical/operations.md | 24 +++++++++++++++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md index c03e5e0..df26551 100644 --- a/docs/technical/architecture.md +++ b/docs/technical/architecture.md @@ -54,6 +54,7 @@ Deliberately accepted risks – without an entry here a deviation counts as a de | Upload cap per person only where configured | Local and CI run without `UPLOAD_MAX_PER_HOUR`; the showcase refuses to start without it (#59); concurrent uploads may pass the check together – a cost cap, not an exact quota | Fluory | 2026-12-31 (review at M3) | | `.msg` uploads checked by OLE signature only | Structure check of Outlook messages needs a CFB parser; files are served only as attachments with `nosniff` and parsed later by the stateless AI service | Fluory | with #23 (MSG parsing) | | Gemini API free-tier key on the showcase (#67) | Orchestrator decision 2026-09-26 (ADR-0001 D11 amendment): no GCP for now. Google's terms require paid services for API clients offered to users in the EEA and allow human review of free-tier content, so the showcase stays invite-only for the orchestrator, synthetic data only, demo banner on | Fluory | before anyone else gets a demo account, at the latest 2026-10-31 – then paid tier (same key with billing) or Vertex `eu` | +| Backup role `backup_reader` bypasses RLS (read-only, #108) | `pg_dump` of a table with forced RLS needs `BYPASSRLS`; `app_owner` deliberately cannot (D7). Used only for the showcase export, never by the app; password as protected as `postgres` | Fluory | with the production path (managed PITR replaces it), at the latest 2026-12-31 (review at M3) | | Gemini API free tier for local development | Synthetic data only; never showcase (except the #67 row above until its expiry) or customer data (D8) | Fluory | when a Vertex development budget exists | | Audit ignore for `braces` GHSA-vfj7-8cjw-p6xm (`package.json` → `pnpm.auditConfig.ignoreGhsas`, #115) | No patched version exists (2026-10-09). Reached only through lint tooling (`eslint-config-next` → `fast-glob` → `micromatch`), not in the production tree (`pnpm why braces --prod` is empty); the risk is a stack-exhaustion DoS of a local lint run on crafted glob patterns | Fluory | as soon as a patched `braces` or a `fast-glob`/`micromatch` without it is released, at the latest 2026-12-31 | @@ -87,5 +88,6 @@ No secrets in this document; every variable is documented in `.env.example`. |---|---|---|---| | `app_owner` | `docker/postgres/init/01-roles.sh` (password from env); Supabase: `scripts/supabase-bootstrap.sql` | migrations (`src/setup.ts`, `MIGRATION_DATABASE_URL`) | owns schema `app`; no superuser, NOBYPASSRLS | | `app_rw` | same | web + worker (`DATABASE_URL`) | USAGE on `app`, no CREATE; DML via default privileges; no superuser, NOBYPASSRLS – RLS always applies | +| `backup_reader` (showcase, proposed in #108) | orchestrator, once, as `postgres` – not in the repo | the export only ([operations.md → Backup and restore](operations.md#backup-and-restore-showcase)) | `LOGIN BYPASSRLS`, `pg_read_all_data`, no write grants, `default_transaction_read_only`; reads every company – the one deliberate RLS bypass, see the exceptions register | The first migration refuses to run if either role is missing or could bypass RLS. diff --git a/docs/technical/operations.md b/docs/technical/operations.md index 2747e3a..7543464 100644 --- a/docs/technical/operations.md +++ b/docs/technical/operations.md @@ -172,7 +172,7 @@ Storage objects. So the showcase needs its own export, whatever the plan: | Item | Value | |---|---| | What | `pg_dump -Fc` of the schemas `app`, `identity`, `drizzle` (migration state) and `pgboss` (waiting jobs) – not the Supabase-managed schemas | -| As whom | a dedicated read-only role `backup_reader` (Supabase's recommendation): `LOGIN BYPASSRLS`, `pg_read_all_data`, `default_transaction_read_only = on`; created once by the orchestrator as `postgres`, password in the password manager. **Not** `app_owner`: with forced RLS and `NOBYPASSRLS` its `pg_dump` fails ("query would be affected by row-level security policy") – by design (ADR-0001 D7) | +| As whom | a dedicated read-only role `backup_reader` ([Supabase's recommendation](https://supabase.com/docs/guides/troubleshooting/pg_dump-fails-with-query-would-be-affected-by-row-level-security-policy-1d5783)): `LOGIN BYPASSRLS`, `pg_read_all_data`, `default_transaction_read_only = on`; created once by the orchestrator as `postgres`, password in the password manager. Read-only because it holds **no write grants** – `default_transaction_read_only` is only a safety net a session could switch off. **Not** `app_owner`: with forced RLS and `NOBYPASSRLS` its `pg_dump` fails ("query would be affected by row-level security policy") – by design (ADR-0001 D7) | | How often | weekly and before every migration on the showcase | | Kept | the last 4 exports | | Where | encrypted (e.g. `age` or the password manager's file vault) in the orchestrator's storage – **never** in the repository, CI artifacts or a shared drive: an export holds every company's data and the user accounts (names, e-mail addresses – personal data, also on the showcase) | @@ -197,16 +197,21 @@ rebuilt with `pnpm seed:samples`, other uploads are lost. A customer system need 1. Create the target (a new Supabase project, or a local PostgreSQL) and run `scripts/supabase-bootstrap.sql` there as `postgres` – the dump keeps the owners, so `app_owner` and `app_rw` (both `NOBYPASSRLS`) must exist first. -2. `pg_restore -d "" --exit-on-error requestflow-showcase-.dump` -3. `pnpm setup:deploy` against the target – the migrations are a no-op, the job queues are confirmed. -4. Check, source vs. target: `select count(*) from drizzle.__drizzle_migrations`, row counts of +2. Where `postgres` is not a superuser (Supabase), it must be allowed to act as the owners, or + `pg_restore` stops at `ALTER SCHEMA app OWNER TO app_owner` ("must be able to SET ROLE"): + `GRANT app_owner, app_rw TO postgres;` – and `REVOKE app_owner, app_rw FROM postgres;` after step 3. +3. `pg_restore -d "" --exit-on-error requestflow-showcase-.dump` +4. `pnpm setup:deploy` with `MIGRATION_DATABASE_URL` **and** the storage variables (`S3_*`) pointing at + the target – never with the showcase's environment loaded, or it runs against the showcase and its + bucket. The migrations are a no-op, the job queues are confirmed. +5. Check, source vs. target: `select count(*) from drizzle.__drizzle_migrations`, row counts of `app.requests` and `identity."user"`, `pgboss.job`; the RLS guard (`TABLE_SECURITY_QUERY` + `tenantIsolationViolations` from `src/features/tenancy`) reports no violation; `app_rw` sees 0 rows of `app.requests` without a company context. **Rehearsal protocol (2026-10-16, local PostgreSQL 16 – not the showcase).** Source: a database built with the real migration runner (20 migrations) and `installJobQueues`, synthetic rows for two companies -(11 requests, 3 users, 1 waiting job). Steps 1–4 as above, roles pre-existing in the same cluster. Result: +(11 requests, 3 users, 1 waiting job). Steps 1, 3–5 as above (as a superuser, roles pre-existing in the same cluster). Result: | Check | Source | Restored | |---|---|---| @@ -219,8 +224,13 @@ with the real migration runner (20 migrations) and `installJobQueues`, synthetic Dump and restore took about 1 s (96 KB). `pg_dump` as `app_owner` failed as expected; as `backup_reader` it exported all 27 data sections, and the role could not write (`cannot execute INSERT in a read-only -transaction`). **Still open:** one rehearsal against the showcase itself (export via the pooler, restore -into a separate project) once `backup_reader` exists – record date, duration and the same table here. +transaction`). Step 2 was checked separately with a non-superuser +restoring role: without membership in `app_owner`/`app_rw` the restore stopped at the first `OWNER TO`; +with it (and without `BYPASSRLS`) the restore was complete – 20 migrations, 11 / 3 / 1 rows, all tables owned +by `app_owner`, RLS guard without violation, `app_rw` 0 / 7. **Still open:** step 1 on an empty +target (the bootstrap script on a new project) and one rehearsal against the showcase itself (export via +the pooler, restore into a separate project) once `backup_reader` exists – record date, duration and the +same table here. ## Frequent failures