Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/technical/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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.
72 changes: 72 additions & 0 deletions docs/technical/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,78 @@ 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](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) |
| Client | `pg_dump` / `pg_restore` of the server's major version (showcase: PostgreSQL 17) or newer |

```bash
pg_dump "postgresql://backup_reader.<project-ref>@<pooler-host>:5432/postgres" -Fc \
-n app -n identity -n drizzle -n pgboss -f requestflow-showcase-<date>.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. 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 "<target as postgres>" --exit-on-error requestflow-showcase-<date>.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, 3–5 as above (as a superuser, 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`). 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

| Symptom | Cause | Action |
Expand Down
Loading