mcp-session.ts was building its own combinedSchema from cloudSchema only, dropping every executor-schema table — every /mcp request failed with [storage-drizzle] unknown model "source". Export combinedSchema from services/db.ts and consume it in both DbService.Live and the MCP Durable Object so drift can't happen. Regression coverage: - db.schema.test.ts asserts combinedSchema (and drizzle's _.fullSchema) contain every cloud + executor export - mcp-session.e2e.node.test.ts drives a real @modelcontextprotocol/sdk Client through listTools / callTool / form elicitation round-trips
Showing the changes to notes (1 of 7 files). Show all
Unified · Split
| @@ -0,0 +1,80 @@ | |||
| 1 | # Postgres RLS — optional, not required | ||
| 2 | |||
| 3 | ## TL;DR | ||
| 4 | |||
| 5 | **RLS is not needed for the current architecture.** Tenant isolation is | ||
| 6 | enforced at the application layer: every query against a scoped table | ||
| 7 | (see `notes/scopes.md`) is routed through the scope adapter which ANDs | ||
| 8 | `scope_id = <current scope>` into every read and stamps it on every | ||
| 9 | write. Only the worker talks to the DB; clients authenticate to the | ||
| 10 | worker via WorkOS JWT, never to Postgres directly. One DB role | ||
| 11 | (`postgres`) owns every row. RLS would be a second lock on a door that's | ||
| 12 | already locked. | ||
| 13 | |||
| 14 | The only reason to turn RLS on is **defense-in-depth** — a belt against | ||
| 15 | a future bug where a drizzle query forgets to filter by `scope_id`. | ||
| 16 | Without RLS, such a query silently returns cross-tenant rows. With RLS, | ||
| 17 | it returns zero rows. | ||
| 18 | |||
| 19 | ## When RLS matters | ||
| 20 | |||
| 21 | RLS earns its weight when untrusted clients connect directly to Postgres | ||
| 22 | with per-user credentials (the Supabase / PostgREST model). Not our | ||
| 23 | shape. | ||
| 24 | |||
| 25 | Consider turning it on if any of these change: | ||
| 26 | |||
| 27 | - Clients start hitting Postgres directly (JWT-authed session, row-level | ||
| 28 | policies as the only authorisation layer). | ||
| 29 | - We run multiple DB roles that need row-level isolation between them. | ||
| 30 | - We decide the cost of a missed `scope_id` filter in app code is high | ||
| 31 | enough to want a DB-level backstop. | ||
| 32 | |||
| 33 | ## How it would work with scope merging | ||
| 34 | |||
| 35 | Scope merging (`ScopeStack` in `notes/scopes.md`) means reads fan out | ||
| 36 | across multiple scopes while writes target exactly one. RLS policies | ||
| 37 | mirror that asymmetry by reading two settings per request: | ||
| 38 | |||
| 39 | ```sql | ||
| 40 | CREATE POLICY scope_read ON source FOR SELECT | ||
| 41 | USING (scope_id = ANY(current_setting('app.scope_chain', true)::text[])); | ||
| 42 | |||
| 43 | CREATE POLICY scope_write ON source FOR INSERT | ||
| 44 | WITH CHECK (scope_id = current_setting('app.write_scope', true)); | ||
| 45 | |||
| 46 | CREATE POLICY scope_update ON source FOR UPDATE | ||
| 47 | USING (scope_id = current_setting('app.write_scope', true)) | ||
| 48 | WITH CHECK (scope_id = current_setting('app.write_scope', true)); | ||
| 49 | |||
| 50 | ALTER TABLE source ENABLE ROW LEVEL SECURITY; | ||
| 51 | ``` | ||
| 52 | |||
| 53 | And app-side, once per request inside a transaction (so `SET LOCAL` | ||
| 54 | takes): | ||
| 55 | |||
| 56 | ```ts | ||
| 57 | yield* sql`SET LOCAL app.scope_chain = ${toPgArray(chain)}`; | ||
| 58 | yield* sql`SET LOCAL app.write_scope = ${writeScope}`; | ||
| 59 | ``` | ||
| 60 | |||
| 61 | `current_setting('…', true)` returns null when the setting is missing, | ||
| 62 | which is what you want — a connection that forgets to configure the | ||
| 63 | scope sees zero rows, not an error that might be handled into a 500. | ||
| 64 | |||
| 65 | ## Rollout sketch (if we ever decide to enable it) | ||
| 66 | |||
| 67 | 1. Add a migration that `ENABLE ROW LEVEL SECURITY` on every scoped | ||
| 68 | table and installs the read/write policies above. The list of scoped | ||
| 69 | tables is whatever `executor-schema.ts` exports with a `scope_id` | ||
| 70 | column — same set the scope adapter already knows about. | ||
| 71 | 2. Add an Effect layer that wraps the DbService to issue the two `SET | ||
| 72 | LOCAL`s at connection acquire. `postgres.js`'s `sql.begin` already | ||
| 73 | gives us the transaction boundary. | ||
| 74 | 3. Leave `BYPASSRLS` off the worker's role so we actually get the | ||
| 75 | enforcement; grant it on the role used by the migration / admin | ||
| 76 | scripts. | ||
| 77 | |||
| 78 | Ballpark effort: a day including the migration + the Effect layer + | ||
| 79 | tests. Worth doing only if we hit a scope-leak bug in prod or change | ||
| 80 | the access model. | ||