diff --git a/README.md b/README.md index ba1cd6f..3f401a6 100644 --- a/README.md +++ b/README.md @@ -438,6 +438,45 @@ export default { } ``` +## Postgres (RLS-scoped queries) + +When PostgREST isn't the right tool — joins, CTEs, window functions — `withPostgresClient` puts a direct Postgres connection on `ctx.postgres`, scoped to the caller by RLS: + +```ts +import { withSupabase } from '@supabase/server' +import { withPostgresClient } from '@supabase/server/middleware/postgres' + +export default { + fetch: withSupabase( + { auth: 'user', middleware: [withPostgresClient()] }, + async (_req, ctx) => { + // No WHERE clause — RLS scopes the rows to the caller. + const notes = await ctx.postgres.query('select id, body from notes') + return Response.json(notes) + }, + ), +} +``` + +Each query runs in its own transaction that injects the caller's claims and drops to their role, exactly like PostgREST — so `auth.uid()` resolves and your policies enforce. Only `authenticated` and `anon` are assumed; a token naming any other role (including `service_role`, and custom roles) is refused with `code: 'UNSUPPORTED_ROLE'` rather than silently downgraded to `anon`. + +When a handler legitimately needs to cross user boundaries, `withPostgresAdminClient` is the explicit opt-out — it contributes `ctx.postgresAdmin`, which bypasses RLS and needs no caller identity, so it works under `auth: 'secret'` and `auth: 'none'` too: + +```ts +import { withPostgresAdminClient } from '@supabase/server/middleware/postgres-admin' + +withSupabase( + { auth: 'secret', middleware: [withPostgresAdminClient()] }, + handler, +) +``` + +The pair mirrors `ctx.supabase` / `ctx.supabaseAdmin`, and they share one connection pool. Keeping them as two middleware is deliberate: bypassing RLS stays visible at the composition site, so you can grep for every handler that can do it. + +Needs `pg` installed (optional peer dependency) and a raw TCP socket: Node, Deno, Bun, and the Supabase Edge runtime — **not** Workers-style isolates. Reads `SUPABASE_DB_URL` by default. Remember that `authenticated` also needs table grants, not just policies. + +See [`docs/postgres.md`](docs/postgres.md) for standalone composition with `withClaims`, the grants requirement, and current limits. + ## Environment Variables Automatically available in Supabase Edge Functions: @@ -456,6 +495,7 @@ Also supported (for local dev, self-hosted, or other runtimes): | `SUPABASE_PUBLISHABLE_KEY` | `sb_publishable_...` | Single publishable key | | `SUPABASE_SECRET_KEY` | `sb_secret_...` | Single secret key | | `SUPABASE_JWKS_URL` | `https://...` | Remote JWKS endpoint (used when `SUPABASE_JWKS` is unset) | +| `SUPABASE_DB_URL` | `postgresql://...` | Postgres connection string, read by `withPostgresClient` | When both singular and plural forms are set, plural takes priority. @@ -481,14 +521,21 @@ No. `@supabase/ssr` handles cookie-based session management for frameworks like ## Exports -| Export | What's in it | -| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `@supabase/server` | `withSupabase`, `createSupabaseContext` | -| `@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `createContextClient`, `createAdminClient`, `resolveEnv` | -| `@supabase/server/adapters/hono` | `withSupabase` (Hono middleware) | -| `@supabase/server/adapters/h3` | `withSupabase` (H3 / Nuxt middleware) | -| `@supabase/server/adapters/elysia` | `withSupabase` (Elysia plugin) | -| `@supabase/server/adapters/nestjs` | `withSupabase` (NestJS guard), `SupabaseCtx` (param decorator) | +| Export | What's in it | +| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `@supabase/server` | `withSupabase`, `createSupabaseContext` | +| `@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `createContextClient`, `createAdminClient`, `resolveEnv` | +| `@supabase/server/adapters/hono` | `withSupabase` (Hono middleware) | +| `@supabase/server/adapters/h3` | `withSupabase` (H3 / Nuxt middleware) | +| `@supabase/server/adapters/elysia` | `withSupabase` (Elysia plugin) | +| `@supabase/server/adapters/nestjs` | `withSupabase` (NestJS guard), `SupabaseCtx` (param decorator) | +| `@supabase/server/middleware/client` | `withSupabaseClient` (RLS-scoped `ctx.supabase` client) | +| `@supabase/server/middleware/admin-client` | `withSupabaseAdminClient` (`ctx.supabaseAdmin`, bypasses RLS) | +| `@supabase/server/middleware/claims` | `withClaims` (JWKS-verified `ctx.jwtClaims`) | +| `@supabase/server/middleware/postgres` | `withPostgresClient` (RLS-scoped `ctx.postgres` client) | +| `@supabase/server/middleware/postgres-admin` | `withPostgresAdminClient` (`ctx.postgresAdmin`, bypasses RLS) | +| `@supabase/server/oauth-protected-resource` | `withOAuthProtectedResource`, `resourceMetadataResponse`, `unauthorizedResponse` | +| `@supabase/server/peer/supabase-js` | Re-exported `supabase-js` types (`SupabaseClient`, `PostgrestError`, …) | ## Documentation @@ -505,6 +552,7 @@ No. `@supabase/ssr` handles cookie-based session management for frameworks like | How do environment variables work across runtimes? | [`docs/environment-variables.md`](docs/environment-variables.md) | | How do I handle errors? What codes exist? | [`docs/error-handling.md`](docs/error-handling.md) | | How do I get typed database queries? | [`docs/typescript-generics.md`](docs/typescript-generics.md) | +| How do I run raw SQL scoped to the caller by RLS? | [`docs/postgres.md`](docs/postgres.md) | | How do I use this with `@supabase/ssr` (Next.js, SvelteKit, Remix)? | [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md) | | What's the complete API surface? | [`docs/api-reference.md`](docs/api-reference.md) | diff --git a/docs/api-reference.md b/docs/api-reference.md index ed7105f..83d0b4e 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -166,6 +166,100 @@ Defaults to `auth: 'user'` when config is omitted. --- +## @supabase/server/middleware/postgres + +### withPostgresClient + +```ts +const withPostgresClient: Middleware< + 'postgres', + WithPostgresClientConfig | void, + { jwtClaims: RequestClaims | null }, + PostgresApi +> +``` + +Contributes `ctx.postgres` — a `pg` client scoped to the caller by RLS. Each query runs in its own transaction that sets `request.jwt.claims` and drops to the caller's role before the statement, so `auth.uid()` resolves and policies enforce. + +Only `authenticated` and `anon` are assumed. A verified token naming any other role — `service_role` or a custom role — short-circuits with a 500 and `{ message, code: 'UNSUPPORTED_ROLE' }` naming the role, rather than being downgraded to `anon`. A missing or absent `role` claim is `anon`. + +Requires `ctx.jwtClaims` upstream — supplied by `withSupabase` or by `withClaims` in a standalone `pipeline`. Composing it without one is a compile-time error. + +Short-circuits with a 500 and `{ message, code: 'ENV_ERROR' }` when no connection string is available. + +Needs raw TCP: Node, Deno, Bun, and the Supabase Edge runtime, not Workers-style isolates. `pg` is an optional peer dependency. + +See [`docs/postgres.md`](postgres.md). + +### PostgresApi + +```ts +interface PostgresApi { + query>( + text: string, + params?: unknown[], + ): Promise +} +``` + +The value at `ctx.postgres`. `query` returns the result rows directly (not a `pg` `Result`). Use `params` for placeholders (`$1`, `$2`, …) rather than interpolating values into `text`. + +### WithPostgresClientConfig + +```ts +interface WithPostgresClientConfig { + connectionString?: string +} +``` + +Defaults to the `SUPABASE_DB_URL` environment variable. Pools are created lazily, one per connection string per process. + +### RequestClaims + +```ts +interface RequestClaims { + role?: string + [key: string]: unknown +} +``` + +The minimal claims shape `withPostgresClient` requires upstream at `ctx.jwtClaims`. Satisfied by `withSupabase`'s JWKS-verified claims and by `withClaims`. Only `role` is read; the whole object is serialized into `request.jwt.claims`. + +--- + +## @supabase/server/middleware/postgres-admin + +### withPostgresAdminClient + +```ts +const withPostgresAdminClient: Middleware< + 'postgresAdmin', + WithPostgresAdminClientConfig | void, + Record, + PostgresApi +> +``` + +Contributes `ctx.postgresAdmin` — a `pg` client that **bypasses RLS**. Queries run as-is, as the role in the connection string: no claim injection, no role switching, no wrapping transaction. + +Declares no upstream prerequisite, so it composes in any auth mode including `'secret'` and `'none'`. Shares the pool cache with `withPostgresClient` — same connection string, one pool. + +Short-circuits with a 500 and `{ message, code: 'ENV_ERROR' }` when no connection string is available. + +Authorization is the caller's responsibility: RLS is not consulted, so per-user scoping must be an explicit `where` clause. + +### WithPostgresAdminClientConfig + +```ts +interface WithPostgresAdminClientConfig { + connectionString?: string +} +``` + +Defaults to the `SUPABASE_DB_URL` environment variable. + +--- + ## Types ### AuthMode @@ -353,17 +447,18 @@ class AuthError extends Error { ## Error Code Constants -| Constant | Value | Class | Meaning | -| ----------------------------------- | ----------------------------------- | ----------- | ------------------------------------------------- | -| `EnvGenericError` | `'ENV_ERROR'` | `EnvError` | Generic environment error | -| `MissingSupabaseURLError` | `'MISSING_SUPABASE_URL'` | `EnvError` | `SUPABASE_URL` not set | -| `MissingPublishableKeyError` | `'MISSING_PUBLISHABLE_KEY'` | `EnvError` | Named publishable key not found | -| `MissingDefaultPublishableKeyError` | `'MISSING_DEFAULT_PUBLISHABLE_KEY'` | `EnvError` | No default publishable key | -| `MissingSecretKeyError` | `'MISSING_SECRET_KEY'` | `EnvError` | Named secret key not found | -| `MissingDefaultSecretKeyError` | `'MISSING_DEFAULT_SECRET_KEY'` | `EnvError` | No default secret key | -| `AuthGenericError` | `'AUTH_ERROR'` | `AuthError` | Generic auth error | -| `InvalidCredentialsError` | `'INVALID_CREDENTIALS'` | `AuthError` | No credential matched, or JWT failed verification | -| `CreateSupabaseClientError` | `'CREATE_SUPABASE_CLIENT_ERROR'` | `AuthError` | Client creation failed after auth | +| Constant | Value | Class | Meaning | +| ----------------------------------- | ----------------------------------- | ----------- | -------------------------------------------------------------- | +| `EnvGenericError` | `'ENV_ERROR'` | `EnvError` | Generic environment error | +| `MissingSupabaseURLError` | `'MISSING_SUPABASE_URL'` | `EnvError` | `SUPABASE_URL` not set | +| `MissingPublishableKeyError` | `'MISSING_PUBLISHABLE_KEY'` | `EnvError` | Named publishable key not found | +| `MissingDefaultPublishableKeyError` | `'MISSING_DEFAULT_PUBLISHABLE_KEY'` | `EnvError` | No default publishable key | +| `MissingSecretKeyError` | `'MISSING_SECRET_KEY'` | `EnvError` | Named secret key not found | +| `MissingDefaultSecretKeyError` | `'MISSING_DEFAULT_SECRET_KEY'` | `EnvError` | No default secret key | +| `AuthGenericError` | `'AUTH_ERROR'` | `AuthError` | Generic auth error | +| `InvalidCredentialsError` | `'INVALID_CREDENTIALS'` | `AuthError` | No credential matched, or JWT failed verification | +| `CreateSupabaseClientError` | `'CREATE_SUPABASE_CLIENT_ERROR'` | `AuthError` | Client creation failed after auth | +| `UnsupportedRoleError` | `'UNSUPPORTED_ROLE'` | — | `withPostgresClient` will not assume the caller's `role` claim | --- diff --git a/docs/postgres.md b/docs/postgres.md new file mode 100644 index 0000000..b9f2916 --- /dev/null +++ b/docs/postgres.md @@ -0,0 +1,189 @@ +# Postgres (`ctx.postgres`) + +Two middleware give you a direct Postgres connection, mirroring the `ctx.supabase` / `ctx.supabaseAdmin` pair: + +| Middleware | Subpath | Contributes | RLS | +| ------------------------- | ----------------------------- | ------------------- | ------------------------------ | +| `withPostgresClient` | `./middleware/postgres` | `ctx.postgres` | Enforced, scoped to the caller | +| `withPostgresAdminClient` | `./middleware/postgres-admin` | `ctx.postgresAdmin` | **Bypassed** | + +Reach for the scoped one by default. The admin one is a deliberate opt-out, covered [below](#bypassing-rls). + +`withPostgresClient` puts a direct Postgres connection on `ctx.postgres`, scoped to the calling user by RLS. It is the safe version of "authenticate, then query as the user": you write plain SQL, and Postgres — not your application code — decides which rows the caller may see. + +```ts +import { withSupabase } from '@supabase/server' +import { withPostgresClient } from '@supabase/server/middleware/postgres' + +export default { + fetch: withSupabase( + { auth: 'user', middleware: [withPostgresClient()] }, + async (_req, ctx) => { + // No WHERE clause — RLS scopes the rows to the caller. + const notes = await ctx.postgres.query('select id, body from notes') + return Response.json(notes) + }, + ), +} +``` + +Use this when PostgREST is not the right tool: multi-table joins, window functions, CTEs, `insert ... returning` with computed columns, or any query that is simply easier to express in SQL. For ordinary CRUD, `ctx.supabase` is still the better choice. + +## What each query runs + +Every `ctx.postgres.query()` call takes a connection from the pool and runs your SQL inside its own transaction, injecting the caller's claims exactly the way PostgREST does: + +```sql +begin; +select set_config('request.jwt.claims', $claims, true); -- auth.uid() resolves +set local role authenticated; -- RLS now enforces +-- your query +commit; +``` + +Both `set_config`'s third argument and `set local` are transaction-local, so nothing leaks onto the pooled connection when it goes back to the pool. + +## Which roles are assumed + +Only `authenticated` and `anon`. A verified token naming any other role is **refused** — a 500 with `code: 'UNSUPPORTED_ROLE'` and a message naming the role — rather than quietly downgraded: + +| `role` claim | Result | +| ---------------------------- | ---------------------------------------------- | +| absent, or no token at all | `anon` | +| `anon` | `anon` | +| `authenticated` | `authenticated` | +| `service_role` | Refused, pointing at `withPostgresAdminClient` | +| anything else (custom roles) | Refused, naming the role | + +Refusing rather than downgrading is deliberate. Running someone's query under the wrong identity returns **zero rows instead of an error**, which is close to undebuggable — you see an empty array and no indication that the role was the problem. + +### Custom roles are not supported yet + +Supabase lets you [define custom Postgres roles](https://supabase.com/docs/guides/storage/schema/custom-roles) and put them in the `role` claim, with RLS policies written `to manager`. That is a legitimate pattern and RLS still applies — custom roles are a dimension of RLS, not a way around it. + +They are not supported here yet, and the reason is worth knowing. PostgREST connects as the unprivileged `authenticator` role, so `grant manager to authenticator` _is_ the authorization — Postgres itself decides which roles are reachable. This middleware connects with `SUPABASE_DB_URL`, which on Supabase is `postgres`: a role that already bypasses RLS and can `SET ROLE` into almost anything. With no equivalent boundary to lean on, v1 assumes a fixed pair of roles instead of trusting the claim. + +Until custom-role support lands, issue tokens with `authenticated` or `anon`, or use `withPostgresAdminClient` and do the scoping in your own `where` clause. + +### Write policies with the `auth.*` helpers + +The single `request.jwt.claims` setting is the whole claim payload as JSON, and it is the only one this middleware sets. `auth.uid()`, `auth.role()`, and `auth.jwt()` all read it, so policies written the normal way work unchanged: + +```sql +create policy "users read their own notes" + on public.notes for select to authenticated + using ((select auth.uid()) = user_id); +``` + +Reach for those helpers rather than reading settings by hand. In particular, the older singular GUCs — `current_setting('request.jwt.claim.sub')` and friends — are **not** set here; they are a legacy PostgREST convention that PostgREST itself has since removed. A policy that reads them directly sees `NULL` and quietly matches nothing. + +## Composition + +`withPostgresClient` needs the caller's verified claims at `ctx.jwtClaims`. That prerequisite is enforced at compile time, so there are exactly two ways to satisfy it. + +**Inside `withSupabase`** — the context already carries `jwtClaims`, so compose it directly: + +```ts +withSupabase({ auth: 'user', middleware: [withPostgresClient()] }, handler) +``` + +**Standalone** — in a Supabase-agnostic `pipeline`, pair it with [`withClaims`](../src/middleware/claims/index.ts), which verifies the Bearer token against the project JWKS: + +```ts +import { pipeline } from '@supabase/middleware' +import { withClaims } from '@supabase/server/middleware/claims' +import { withPostgresClient } from '@supabase/server/middleware/postgres' + +export default { + fetch: pipeline([withClaims(), withPostgresClient()], async (_req, ctx) => { + const rows = await ctx.postgres.query('select id, title from posts') + return Response.json({ rows, caller: ctx.jwtClaims?.sub ?? 'anon' }) + }), +} +``` + +Order matters. `withPostgresClient` before `withClaims` is a compile-time error: + +``` +middleware-prereq: key 'jwtClaims' is not yet on the context (check ordering) +``` + +## Table grants + +Queries run as `authenticated` or `anon`, and on current Supabase projects new tables grant those roles nothing. RLS policies are not enough on their own — a policy filters rows the role is already allowed to touch. + +```sql +grant select, insert on public.notes to authenticated; +``` + +Without the grant the query fails with `permission denied` (SQLSTATE `42501`) _before_ RLS is consulted. `withPostgresClient` recognizes that code and appends the role and the missing-grant hint to the error message, so the fix is in the error you actually see. + +## Bypassing RLS + +When a handler legitimately needs to cross user boundaries — an admin dashboard, a cron aggregate, a background job — compose `withPostgresAdminClient` instead. It contributes `ctx.postgresAdmin`, which runs queries as-is under the connection-string role: no claim injection, no role switch, no wrapping transaction. + +```ts +import { withSupabase } from '@supabase/server' +import { withPostgresAdminClient } from '@supabase/server/middleware/postgres-admin' + +export default { + fetch: withSupabase( + { auth: 'secret', middleware: [withPostgresAdminClient()] }, + async (_req, ctx) => { + const rows = await ctx.postgresAdmin.query( + 'select user_id, count(*) from notes group by user_id', + ) + return Response.json(rows) + }, + ), +} +``` + +Unlike the scoped half it declares **no upstream prerequisite** — it never reads `ctx.jwtClaims`, so it works under `auth: 'secret'` and `auth: 'none'` where there is no caller identity at all. + +Compose both when a handler needs each in turn. They share one pool, and `ctx.postgres` stays RLS-scoped regardless: + +```ts +middleware: [withPostgresClient(), withPostgresAdminClient()] +``` + +Two things worth being deliberate about: + +- **Authorization becomes yours.** RLS is not consulted, so any per-user scoping has to be a `where` clause you write. The failure mode is silent — a forgotten clause returns every row rather than raising an error. +- **The split is the safety feature.** These are two middleware rather than one object with an `.admin` property so that bypassing RLS is visible at the composition site. You can grep a codebase for `withPostgresAdminClient` and find every handler that can cross user boundaries. + +## Configuration + +Both middleware take the same option: + +```ts +withPostgresClient({ connectionString: 'postgresql://...' }) +withPostgresAdminClient({ connectionString: 'postgresql://...' }) +``` + +`connectionString` defaults to the `SUPABASE_DB_URL` environment variable, which Supabase Edge Functions provide automatically. If neither is set the middleware short-circuits with a 500 and `{ message, code: 'ENV_ERROR' }`. + +Connections are pooled per process, lazily, one pool per connection string (max 4 connections). The pool outlives individual requests — that is what makes this viable on a per-request runtime. + +Both middleware share that cache, so composing the pair opens one pool, not two. Sharing is safe because everything the scoped half sets is transaction-local: a connection always returns to the pool clean, and an admin query can never inherit a previous caller's claims or role. + +## Runtime support + +`pg` opens a raw TCP socket, so both middleware run on **Node, Deno, Bun, and the Supabase Edge runtime** — but **not** on Workers-style isolates, which have no TCP. On those, use `ctx.supabase`, which talks HTTP to PostgREST. + +`pg` is an optional peer dependency. Install it alongside the package when you use this middleware: + +```sh +npm install pg +``` + +## Limits in this version + +- **One transaction per `query()` call.** There is no multi-statement transaction API, so you cannot yet span several `query()` calls in one atomic unit. Put multi-statement logic in a database function and call it in a single query. +- **No read-replica routing** and **no trace propagation** — both are tracked separately. +- **No composing wrapper.** There is no `withPostgres()` that gives you both clients at once; list the two entries you want. The name is reserved in case that changes. + +## See also + +- [`docs/api-reference.md`](api-reference.md) — `withPostgresClient`, `withPostgresAdminClient`, `PostgresApi`, config types +- [`docs/security.md`](security.md) — how RLS fits the rest of the auth model diff --git a/e2e/README.md b/e2e/README.md index 7e64101..444f5f4 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -41,6 +41,10 @@ Run a single adapter with `pnpm test:e2e h3`. client is not scoped to the caller) - `apps/core/app.ts` — same surface on the core `withSupabase(config, handler)` fetch wrapper (no adapter) — what an Edge Function deploys, running on Node. + Plus `GET /my-notes-pg` (user, `middleware: [withPostgresClient()]` — a real `pg` + connection, same unfiltered query, rows scoped by RLS alone). Only the core + and edge apps carry this route: `pg` needs raw TCP, which is precisely the + runtime claim under test. - `supabase/functions/server-e2e/` — the same surface again, but on the real Deno edge runtime, served by `supabase start` through the Kong gateway and covered by `edge.e2e.ts`. Imports the library from a vendored `pnpm pack` @@ -52,6 +56,8 @@ Run a single adapter with `pnpm test:e2e h3`. (`SUPABASE_PUBLISHABLE_KEYS` / `SUPABASE_SECRET_KEYS` / `SUPABASE_JWKS`) the library reads. - `scenarios.ts` — the single scenario set run against every adapter + (`runAdapterScenarios`), plus `runPostgresScenarios` for the `ctx.postgres` + route, run only by `core.e2e.ts` and `edge.e2e.ts` - `setup/global-setup.ts` — checks the stack is up, signs in two test users, provides their tokens to the tests - `scripts/gen-env.sh` — writes `.env` (gitignored) from the running stack diff --git a/e2e/apps/core/app.ts b/e2e/apps/core/app.ts index 81688ba..7b5d899 100644 --- a/e2e/apps/core/app.ts +++ b/e2e/apps/core/app.ts @@ -7,6 +7,9 @@ // The core wrapper has no router, so routes are dispatched on pathname and // each auth mode gets its own wrapped handler. import { withSupabase } from '../../../dist/index.mjs' +import { withPostgresClient } from '../../../dist/middleware/postgres/index.mjs' +import { withPostgresAdminClient } from '../../../dist/middleware/postgres-admin/index.mjs' +import type { NoteRow } from '../notes.ts' import { insertNote, listAllNotes, listNotes, listOwnNotes } from '../notes.ts' import { startFetchServer } from '../../setup/serve.ts' @@ -35,6 +38,32 @@ const userHandler = withSupabase({ auth: 'user' }, async (req, ctx) => { return Response.json({ error: 'not found' }, { status: 404 }) }) +// ctx.postgres — a direct pg connection scoped to the caller by RLS. The query +// has no WHERE clause: the rows come back scoped because withPostgresClient injects +// the caller's claims and drops to their role inside the transaction, so +// auth.uid() resolves and the notes policy applies. Reads SUPABASE_DB_URL, +// which the Supabase CLI injects into the edge runtime and pnpm gen:env +// writes into e2e/.env for this Node app. +// Both halves composed together — one pool, two clients. /my-notes-pg reads +// through the RLS-scoped one and /all-notes-pg through the admin one, running +// the *identical* SQL. That the same query returns different rows is the whole +// security boundary under test. +const PG_QUERY = 'select id, user_id, body from notes order by created_at' + +const postgresHandler = withSupabase( + { + auth: 'user', + middleware: [withPostgresClient(), withPostgresAdminClient()], + }, + async (req, ctx) => { + const { pathname } = new URL(req.url) + if (pathname === '/all-notes-pg') { + return Response.json(await ctx.postgresAdmin.query(PG_QUERY)) + } + return Response.json(await ctx.postgres.query(PG_QUERY)) + }, +) + const optionalHandler = withSupabase( { auth: ['user', 'none'] }, async (_req, ctx) => Response.json({ userClaims: ctx.userClaims }), @@ -44,6 +73,8 @@ function fetchHandler(req: Request): Response | Promise { const { pathname } = new URL(req.url) if (pathname === '/health') return Response.json({ status: 'ok' }) if (pathname === '/me-optional') return optionalHandler(req) + if (pathname === '/my-notes-pg' || pathname === '/all-notes-pg') + return postgresHandler(req) return userHandler(req) } diff --git a/e2e/core.e2e.ts b/e2e/core.e2e.ts index 303a3bc..60f5d0f 100644 --- a/e2e/core.e2e.ts +++ b/e2e/core.e2e.ts @@ -1,9 +1,10 @@ import { afterAll, beforeAll } from 'vitest' import { start } from './apps/core/app.ts' -import { runAdapterScenarios } from './scenarios.ts' +import { runAdapterScenarios, runPostgresScenarios } from './scenarios.ts' const PORT = 8795 +const baseUrl = `http://localhost:${PORT}` let close: () => Promise @@ -13,4 +14,5 @@ beforeAll(async () => { afterAll(() => close()) -runAdapterScenarios('core', `http://localhost:${PORT}`) +runAdapterScenarios('core', baseUrl) +runPostgresScenarios('core', baseUrl) diff --git a/e2e/edge.e2e.ts b/e2e/edge.e2e.ts index 9b11184..1724c1a 100644 --- a/e2e/edge.e2e.ts +++ b/e2e/edge.e2e.ts @@ -1,6 +1,6 @@ import { beforeAll } from 'vitest' -import { runAdapterScenarios } from './scenarios.ts' +import { runAdapterScenarios, runPostgresScenarios } from './scenarios.ts' // Served by the local stack's edge runtime (`supabase start` with // [edge_runtime] enabled in e2e/supabase/config.toml) and reached through @@ -33,3 +33,4 @@ beforeAll(async () => { }, 120_000) runAdapterScenarios('edge', baseUrl) +runPostgresScenarios('edge', baseUrl) diff --git a/e2e/scenarios.ts b/e2e/scenarios.ts index 38f86f4..c368718 100644 --- a/e2e/scenarios.ts +++ b/e2e/scenarios.ts @@ -150,3 +150,75 @@ export function runAdapterScenarios(adapter: string, baseUrl: string): void { }) }) } + +/** + * `withPostgresClient` scenarios — a real `pg` connection to the local stack's + * Postgres, RLS-scoped by the caller's claims. + * + * Run only against the apps that expose `/my-notes-pg` (core on Node, and the + * edge function on the real Deno runtime), not the four framework adapters: + * `pg` needs raw TCP, so this is the middleware's runtime claim under test. + * + * The route runs `select ... from notes` with no WHERE clause, so the same + * assertions that hold for `/my-notes` (PostgREST + RLS) must hold here + * (direct connection + RLS). That the two agree is the point. + */ +export function runPostgresScenarios(app: string, baseUrl: string): void { + const { user1, user2 } = inject('e2eUsers') + + describe(`${app}: ctx.postgres`, () => { + const noteBody = `e2e pg note from ${app} ${crypto.randomUUID()}` + let created: NoteRow + + it('seeds a note for user1 through the admin client', async () => { + const res = await fetch(`${baseUrl}/notes`, { + method: 'POST', + headers: { ...bearer(user1), 'Content-Type': 'application/json' }, + body: JSON.stringify({ body: noteBody }), + }) + expect(res.status).toBe(201) + created = (await res.json()) as NoteRow + }) + + it('GET /my-notes-pg without a token → 401 before any query runs', async () => { + const res = await fetch(`${baseUrl}/my-notes-pg`) + expect(res.status).toBe(401) + }) + + it('GET /my-notes-pg returns the caller rows, scoped by RLS alone', async () => { + const res = await fetch(`${baseUrl}/my-notes-pg`, { + headers: bearer(user1), + }) + expect(res.status).toBe(200) + const rows = (await res.json()) as NoteRow[] + expect(rows.some((row) => row.id === created.id)).toBe(true) + expect(rows.every((row) => row.user_id === user1.id)).toBe(true) + }) + + it('GET /my-notes-pg as a different user cannot see them', async () => { + // user2 runs the identical unfiltered query — auth.uid() resolves to + // user2, so the policy hides user1's row. If claim injection or the role + // drop regressed, this would return every note in the table. + const res = await fetch(`${baseUrl}/my-notes-pg`, { + headers: bearer(user2), + }) + expect(res.status).toBe(200) + const rows = (await res.json()) as NoteRow[] + expect(rows.some((row) => row.id === created.id)).toBe(false) + expect(rows.every((row) => row.user_id === user2.id)).toBe(true) + }) + + it('GET /all-notes-pg via ctx.postgresAdmin sees other users rows', async () => { + // The security boundary, stated as a single contrast: user2 issues the + // *same* SQL as the assertion above, through the admin client instead of + // the scoped one — and user1's row comes back. Scoping is Postgres + // enforcing RLS, not the query text. + const res = await fetch(`${baseUrl}/all-notes-pg`, { + headers: bearer(user2), + }) + expect(res.status).toBe(200) + const rows = (await res.json()) as NoteRow[] + expect(rows.some((row) => row.id === created.id)).toBe(true) + }) + }) +} diff --git a/e2e/scripts/gen-env.sh b/e2e/scripts/gen-env.sh index 65b95e4..1fe6177 100755 --- a/e2e/scripts/gen-env.sh +++ b/e2e/scripts/gen-env.sh @@ -16,6 +16,10 @@ SUPABASE_PUBLISHABLE_KEY=$SB_PUBLISHABLE_KEY SUPABASE_SECRET_KEY=$SB_SECRET_KEY SUPABASE_JWKS_URL=$SB_API_URL/auth/v1/.well-known/jwks.json SUPABASE_ANON_KEY=$SB_ANON_KEY +# Read by withPostgresClient (@supabase/server/middleware/postgres) for its direct +# pg connection. On the edge runtime the CLI injects its own container- +# reachable value; this one is for the Node apps on the host. +SUPABASE_DB_URL=$SB_DB_URL EOF echo "Wrote e2e/.env" diff --git a/e2e/supabase/config.toml b/e2e/supabase/config.toml index 92ad72d..58c10a4 100644 --- a/e2e/supabase/config.toml +++ b/e2e/supabase/config.toml @@ -39,6 +39,19 @@ enabled = false [edge_runtime] enabled = true +# withPostgresClient defaults to SUPABASE_DB_URL, which the CLI injects on its own — +# but locally that value addresses the database by container name +# (`supabase_db_`), and Deno's DNS resolver rejects hostnames +# containing underscores, so `pg` fails with ENOTFOUND before opening a socket. +# `db` is the same container's network alias and resolves cleanly. The CLI +# refuses to let secrets override reserved SUPABASE_* names, so the function +# reads this one and passes it as `connectionString` instead. +# +# Local-stack artifact only: a deployed Edge Function gets a real pooler +# hostname with no underscores and needs none of this. +[edge_runtime.secrets] +E2E_DB_URL = "postgresql://postgres:postgres@db:5432/postgres" + # The middleware's own 401 behavior is under test — the gateway's JWT # pre-check must not answer first. [functions.server-e2e] diff --git a/e2e/supabase/functions/server-e2e/deno.json b/e2e/supabase/functions/server-e2e/deno.json index 4b208a8..9cdef01 100644 --- a/e2e/supabase/functions/server-e2e/deno.json +++ b/e2e/supabase/functions/server-e2e/deno.json @@ -1,9 +1,12 @@ { "imports": { "@supabase/server": "../_vendor/package/dist/index.mjs", + "@supabase/server/middleware/postgres": "../_vendor/package/dist/middleware/postgres/index.mjs", + "@supabase/server/middleware/postgres-admin": "../_vendor/package/dist/middleware/postgres-admin/index.mjs", "@supabase/supabase-js": "npm:@supabase/supabase-js@2", "@supabase/supabase-js/cors": "npm:@supabase/supabase-js@2/cors", "@supabase/middleware": "npm:@supabase/middleware@0.3.0", - "jose": "npm:jose@6" + "jose": "npm:jose@6", + "pg": "npm:pg@8" } } diff --git a/e2e/supabase/functions/server-e2e/index.ts b/e2e/supabase/functions/server-e2e/index.ts index 6c9d5f4..3c38547 100644 --- a/e2e/supabase/functions/server-e2e/index.ts +++ b/e2e/supabase/functions/server-e2e/index.ts @@ -5,6 +5,8 @@ // out of reach and the small dispatch + queries are duplicated by design. // Keep the two files in sync when the route surface changes. import { withSupabase } from '@supabase/server' +import { withPostgresClient } from '@supabase/server/middleware/postgres' +import { withPostgresAdminClient } from '@supabase/server/middleware/postgres-admin' const COLUMNS = 'id, user_id, body' @@ -74,9 +76,43 @@ const optionalHandler = withSupabase( async (_req, ctx) => Response.json({ userClaims: ctx.userClaims }), ) +// The runtime claim under test: `pg` needs raw TCP, and this is the real Deno +// edge runtime. +// +// withPostgresClient defaults to SUPABASE_DB_URL, which is what a deployed function +// would use. Locally the CLI injects that variable pointing at the database's +// container name (`supabase_db_`), whose underscores Deno's DNS +// resolver rejects — so e2e/supabase/config.toml supplies the equivalent URL +// under the `db` network alias and it is passed explicitly here. The Node core +// app covers the SUPABASE_DB_URL default path. +const connectionString = Deno.env.get('E2E_DB_URL') +const pgConfig = connectionString ? { connectionString } : {} +const PG_QUERY = `select ${COLUMNS} from notes order by created_at` + +// Both halves composed together, running the identical query: /my-notes-pg is +// RLS-scoped, /all-notes-pg bypasses RLS. Same security boundary as the core +// app, but on the real Deno runtime. +const postgresHandler = withSupabase( + { + auth: 'user', + middleware: [ + withPostgresClient(pgConfig), + withPostgresAdminClient(pgConfig), + ], + }, + async (req, ctx) => { + if (route(req) === '/all-notes-pg') { + return Response.json(await ctx.postgresAdmin.query(PG_QUERY)) + } + return Response.json(await ctx.postgres.query(PG_QUERY)) + }, +) + Deno.serve((req) => { const pathname = route(req) if (pathname === '/health') return Response.json({ status: 'ok' }) if (pathname === '/me-optional') return optionalHandler(req) + if (pathname === '/my-notes-pg' || pathname === '/all-notes-pg') + return postgresHandler(req) return userHandler(req) }) diff --git a/jsr.json b/jsr.json index 4a6c062..14db0ed 100644 --- a/jsr.json +++ b/jsr.json @@ -12,6 +12,7 @@ "./middleware/client": "./src/middleware/client/index.ts", "./middleware/admin-client": "./src/middleware/admin-client/index.ts", "./middleware/postgres": "./src/middleware/postgres/index.ts", + "./middleware/postgres-admin": "./src/middleware/postgres-admin/index.ts", "./middleware/claims": "./src/middleware/claims/index.ts", "./oauth-protected-resource": "./src/oauth-protected-resource/index.ts" }, diff --git a/package.json b/package.json index 8ca811a..417331c 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,16 @@ "default": "./dist/middleware/postgres/index.cjs" } }, + "./middleware/postgres-admin": { + "import": { + "types": "./dist/middleware/postgres-admin/index.d.mts", + "default": "./dist/middleware/postgres-admin/index.mjs" + }, + "require": { + "types": "./dist/middleware/postgres-admin/index.d.cts", + "default": "./dist/middleware/postgres-admin/index.cjs" + } + }, "./middleware/claims": { "import": { "types": "./dist/middleware/claims/index.d.mts", diff --git a/src/core/postgres-pool.ts b/src/core/postgres-pool.ts new file mode 100644 index 0000000..e165137 --- /dev/null +++ b/src/core/postgres-pool.ts @@ -0,0 +1,70 @@ +import { getEnv } from '@supabase/middleware' +import pg from 'pg' + +import { EnvGenericError } from '../errors.js' + +const { Pool } = pg + +/** + * The shape of `ctx.postgres` and `ctx.postgresAdmin`. + * + * Both halves expose the same surface — they differ in what runs around the + * query, not in how you call it. `withPostgresClient` wraps every query in a + * transaction that injects the caller's claims and drops to their role; + * `withPostgresAdminClient` runs it as-is, as the connection-string role. + * + * @category Middleware + */ +export interface PostgresApi { + /** Run a query and return its rows. */ + query>( + text: string, + params?: unknown[], + ): Promise +} + +// One pool per connection string per process, lazily created. Keyed rather than +// a bare singleton so two handlers pointed at different databases in the same +// process don't share one pool. +// +// The scoped and admin middleware deliberately share this cache: they use the +// same connection string, and the only difference is whether the transaction +// preamble runs. Both `set_config(..., true)` and `SET LOCAL` are +// transaction-local, so a connection always returns to the pool clean — an +// admin query can never inherit a previous caller's claims or role. +const pools = new Map() + +/** @internal */ +export function getPool(connectionString: string): pg.Pool { + let pool = pools.get(connectionString) + if (!pool) { + pool = new Pool({ connectionString, max: 4 }) + pools.set(connectionString, pool) + } + return pool +} + +/** @internal */ +export function resolveConnectionString( + configured?: string, +): string | undefined { + return configured ?? getEnv('SUPABASE_DB_URL') +} + +/** + * The 500 both middleware short-circuit with when no connection string is + * available, in the package's standard `{ message, code }` error shape. + * + * @internal + */ +export function missingConnectionStringResponse( + middlewareName: string, +): Response { + return Response.json( + { + message: `A Postgres connection string is required. Set SUPABASE_DB_URL, or pass \`connectionString\` to ${middlewareName}.`, + code: EnvGenericError, + }, + { status: 500 }, + ) +} diff --git a/src/errors.ts b/src/errors.ts index 825f811..7da9f07 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -170,6 +170,15 @@ export const InvalidCredentialsError = 'INVALID_CREDENTIALS' */ export const CreateSupabaseClientError = 'CREATE_SUPABASE_CLIENT_ERROR' +/** + * The caller's verified `role` claim names a Postgres role the middleware + * will not assume — either one that would bypass RLS, or one it does not + * support yet. Returned by `withPostgresClient` instead of silently running + * the query as `anon`. + * @category Errors + */ +export const UnsupportedRoleError = 'UNSUPPORTED_ROLE' + const AuthErrorMap = { [InvalidCredentialsError]: (): AuthError => new AuthError('Invalid credentials', InvalidCredentialsError, 401), diff --git a/src/index.ts b/src/index.ts index e27a406..7f9ea9d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -127,4 +127,5 @@ export { MissingPublishableKeyError, MissingSecretKeyError, MissingSupabaseURLError, + UnsupportedRoleError, } from './errors.js' diff --git a/src/middleware/claims/index.ts b/src/middleware/claims/index.ts index 9fd8cbf..03ac360 100644 --- a/src/middleware/claims/index.ts +++ b/src/middleware/claims/index.ts @@ -30,7 +30,7 @@ export interface WithClaimsConfig { * Use this when composing a standalone `pipeline([...], handler)` that is * **not** wrapped by `withSupabase` — for example a Supabase-agnostic Edge * Function that still wants the caller's verified claims available to a - * downstream middleware such as `withPostgres`. Inside `withSupabase`, the + * downstream middleware such as `withPostgresClient`. Inside `withSupabase`, the * context already carries `jwtClaims`, so `withClaims` is unnecessary. * * Behavior: @@ -45,10 +45,10 @@ export interface WithClaimsConfig { * ```ts * import { pipeline } from '@supabase/middleware' * import { withClaims } from '@supabase/server/middleware/claims' - * import { withPostgres } from '@supabase/server/middleware/postgres' + * import { withPostgresClient } from '@supabase/server/middleware/postgres' * * export default { - * fetch: pipeline([withClaims(), withPostgres()], async (req, ctx) => { + * fetch: pipeline([withClaims(), withPostgresClient()], async (req, ctx) => { * const rows = await ctx.postgres.query('select id, title from posts') * return Response.json({ rows, caller: ctx.jwtClaims?.sub ?? 'anon' }) * }), diff --git a/src/middleware/postgres-admin/index.test.ts b/src/middleware/postgres-admin/index.test.ts new file mode 100644 index 0000000..b46bece --- /dev/null +++ b/src/middleware/postgres-admin/index.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Shared mock state, hoisted so the vi.mock factory can close over it. +const h = vi.hoisted(() => { + const issued: string[] = [] + const params: (unknown[] | undefined)[] = [] + const pooled: string[] = [] + // pool.query — the admin path never checks a client out itself. + const poolQuery = vi.fn(async (text: string, p?: unknown[]) => { + issued.push(text) + params.push(p) + return { rows: [{ ok: true }] } + }) + const connect = vi.fn() + return { issued, params, pooled, poolQuery, connect } +}) + +vi.mock('pg', () => { + class Pool { + query = h.poolQuery + connect = h.connect + constructor(config: { connectionString: string }) { + h.pooled.push(config.connectionString) + } + } + return { default: { Pool }, Pool } +}) + +const { seedContext } = await import('@supabase/middleware') +const { withPostgresAdminClient } = await import('./index.js') + +describe('withPostgresAdminClient', () => { + beforeEach(() => { + h.issued.length = 0 + h.params.length = 0 + h.poolQuery.mockClear() + h.connect.mockClear() + vi.stubEnv('SUPABASE_DB_URL', 'postgres://localhost/test') + }) + afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + }) + + it('returns 500 when no connection string is available', async () => { + vi.stubEnv('SUPABASE_DB_URL', undefined) + const handler = withPostgresAdminClient( + { connectionString: undefined }, + async () => Response.json({ ok: true }), + ) + + const res = await handler(new Request('http://localhost'), seedContext()) + + expect(res.status).toBe(500) + expect(await res.json()).toEqual({ + message: expect.stringContaining('withPostgresAdminClient'), + code: 'ENV_ERROR', + }) + }) + + it('runs the query as-is — no transaction, no claims, no role switch', async () => { + const handler = withPostgresAdminClient(async (_req, ctx) => { + await ctx.postgresAdmin.query('select * from notes') + return Response.json({ ok: true }) + }) + + await handler(new Request('http://localhost'), seedContext()) + + // The whole point: exactly one statement reaches Postgres. + expect(h.issued).toEqual(['select * from notes']) + expect(h.issued).not.toContain('begin') + expect(h.issued.some((s) => s.includes('set_config'))).toBe(false) + expect(h.issued.some((s) => s.startsWith('set local role'))).toBe(false) + }) + + it('passes query parameters through', async () => { + const handler = withPostgresAdminClient(async (_req, ctx) => { + await ctx.postgresAdmin.query('select * from notes where user_id = $1', [ + 'u1', + ]) + return Response.json({ ok: true }) + }) + + await handler(new Request('http://localhost'), seedContext()) + + expect(h.params[0]).toEqual(['u1']) + }) + + it('needs no upstream claims — composes with no jwtClaims on the context', async () => { + // The scoped half requires ctx.jwtClaims; this one must not, so it can be + // used under auth: 'secret' / 'none' where there is no caller identity. + const handler = withPostgresAdminClient(async (_req, ctx) => { + const rows = await ctx.postgresAdmin.query('select 1') + return Response.json({ rows }) + }) + + const res = await handler(new Request('http://localhost'), seedContext()) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ rows: [{ ok: true }] }) + }) + + it('shares the pool cache with the scoped middleware', async () => { + // Same connection string as the scoped half would use: one pool, not two. + const { withPostgresClient } = await import('../postgres/index.js') + const before = h.pooled.length + + const admin = withPostgresAdminClient( + { connectionString: 'postgres://localhost/shared' }, + async (_req, ctx) => { + await ctx.postgresAdmin.query('select 1') + return Response.json({ ok: true }) + }, + ) + await admin(new Request('http://localhost'), seedContext()) + + const scoped = withPostgresClient( + { connectionString: 'postgres://localhost/shared' }, + async () => Response.json({ ok: true }), + ) + await scoped(new Request('http://localhost'), { + ...seedContext(), + jwtClaims: { role: 'authenticated' }, + }) + + expect(h.pooled.slice(before)).toEqual(['postgres://localhost/shared']) + }) +}) diff --git a/src/middleware/postgres-admin/index.ts b/src/middleware/postgres-admin/index.ts new file mode 100644 index 0000000..ccc7701 --- /dev/null +++ b/src/middleware/postgres-admin/index.ts @@ -0,0 +1,105 @@ +import { defineMiddleware } from '@supabase/middleware' +import type { Middleware } from '@supabase/middleware' + +import { + getPool, + missingConnectionStringResponse, + resolveConnectionString, +} from '../../core/postgres-pool.js' +import type { PostgresApi } from '../../core/postgres-pool.js' + +export type { PostgresApi } + +/** + * Configuration for {@link withPostgresAdminClient}. + * + * @category Middleware + */ +export interface WithPostgresAdminClientConfig { + /** Defaults to `getEnv('SUPABASE_DB_URL')` (from `@supabase/middleware`). */ + connectionString?: string +} + +/** + * Contributes `ctx.postgresAdmin` — a `pg` client that **bypasses RLS**, for + * full-table access. The direct-connection counterpart to + * `withSupabaseAdminClient`, and the deliberate opt-out from the guardrails + * `withPostgresClient` (`@supabase/server/middleware/postgres`) enforces. + * + * Queries run as-is, as the role in the connection string: no claim injection, + * no role switching, no wrapping transaction. Whatever that role may read, the + * caller may read. + * + * Unlike `withPostgresClient` this declares **no upstream prerequisite** — it + * never looks at `ctx.jwtClaims`, so it composes in any auth mode, including + * `auth: 'secret'` and `auth: 'none'`: + * + * ```ts + * import { withSupabase } from '@supabase/server' + * import { withPostgresAdminClient } from '@supabase/server/middleware/postgres-admin' + * + * export default { + * fetch: withSupabase( + * { auth: 'secret', middleware: [withPostgresAdminClient()] }, + * async (_req, ctx) => { + * const rows = await ctx.postgresAdmin.query( + * 'select user_id, count(*) from notes group by user_id', + * ) + * return Response.json(rows) + * }, + * ), + * } + * ``` + * + * Compose both halves when a handler needs each in turn — they share one pool, + * and `ctx.postgres` stays RLS-scoped regardless: + * + * ```ts + * middleware: [withPostgresClient(), withPostgresAdminClient()] + * ``` + * + * > **Authorization is yours now.** RLS is not consulted, so any per-user + * > scoping has to be a `where` clause you write. Reach for + * > `withPostgresClient` unless you specifically need to cross user + * > boundaries. + * + * > **Runtime note.** `pg` needs raw TCP, so this runs on Node/Deno (including + * > the Supabase Edge runtime), **not** on Workers-style isolates. + * + * @category Middleware + */ +export const withPostgresAdminClient: Middleware< + 'postgresAdmin', + WithPostgresAdminClientConfig | void, + Record, + PostgresApi +> = defineMiddleware< + 'postgresAdmin', + WithPostgresAdminClientConfig | void, + Record, + PostgresApi +>({ + key: 'postgresAdmin', + run: (config) => async () => { + const connectionString = resolveConnectionString(config?.connectionString) + if (!connectionString) { + return missingConnectionStringResponse('withPostgresAdminClient') + } + + const p = getPool(connectionString) + + const api: PostgresApi = { + async query>( + text: string, + params?: unknown[], + ) { + // No transaction preamble: pool.query checks a connection out and back + // for us, and there is no session state to set up or tear down. + const res = await p.query(text, params) + return res.rows as T[] + }, + } + + return { postgresAdmin: api } + }, +}) diff --git a/src/middleware/postgres/index.test.ts b/src/middleware/postgres/index.test.ts index a7757b3..14c2991 100644 --- a/src/middleware/postgres/index.test.ts +++ b/src/middleware/postgres/index.test.ts @@ -3,28 +3,66 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Shared mock state, hoisted so the vi.mock factory can close over it. const h = vi.hoisted(() => { const issued: string[] = [] - const clientQuery = vi.fn(async (text: string) => { + const params: (unknown[] | undefined)[] = [] + // Every connection string a Pool was constructed with, in order. The pool + // cache lives at module scope, so this accumulates across the whole file. + const pooled: string[] = [] + const clientQuery = vi.fn(async (text: string, p?: unknown[]) => { issued.push(text) + params.push(p) return { rows: [{ ok: true }] } }) const release = vi.fn() const connect = vi.fn(async () => ({ query: clientQuery, release })) - return { issued, clientQuery, release, connect } + return { issued, params, pooled, clientQuery, release, connect } }) vi.mock('pg', () => { class Pool { connect = h.connect + constructor(config: { connectionString: string }) { + h.pooled.push(config.connectionString) + } } return { default: { Pool }, Pool } }) -const { seedContext } = await import('@supabase/middleware') -const { withPostgres } = await import('./index.js') +const { pipeline, seedContext } = await import('@supabase/middleware') +const { withClaims } = await import('../claims/index.js') +const { withPostgresClient } = await import('./index.js') +type PostgresApi = import('./index.js').PostgresApi -describe('withPostgres', () => { +/** + * Compile-time coverage for the `withClaims` prerequisite. Never called — the + * assertions are `pnpm typecheck` failing, not vitest. Without an upstream + * contributor for `jwtClaims`, `pipeline`'s `Validate` resolves the handler + * parameter to a `middleware-prereq` sentinel string, so passing a function + * there is an error. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function _prerequisiteIsCompileTimeChecked() { + pipeline([withClaims(), withPostgresClient()], async (_req, ctx) => + Response.json({ rows: await ctx.postgres.query('') }), + ) + + pipeline( + [withPostgresClient()], + // @ts-expect-error withPostgresClient requires an upstream `jwtClaims` + async (_req, ctx) => Response.json({ rows: await ctx.postgres.query('') }), + ) + + // Ordering matters too: withClaims must run before withPostgresClient. + pipeline( + [withPostgresClient(), withClaims()], + // @ts-expect-error `jwtClaims` is not on the context yet at this point + async (_req, ctx) => Response.json({ rows: await ctx.postgres.query('') }), + ) +} + +describe('withPostgresClient', () => { beforeEach(() => { h.issued.length = 0 + h.params.length = 0 h.clientQuery.mockClear() h.connect.mockClear() h.release.mockClear() @@ -39,8 +77,9 @@ describe('withPostgres', () => { it('returns 500 when no connection string is available', async () => { vi.stubEnv('SUPABASE_DB_URL', undefined) - const handler = withPostgres({ connectionString: undefined }, async () => - Response.json({ ok: true }), + const handler = withPostgresClient( + { connectionString: undefined }, + async () => Response.json({ ok: true }), ) const res = await handler(new Request('http://localhost'), { @@ -49,11 +88,75 @@ describe('withPostgres', () => { }) expect(res.status).toBe(500) - expect(await res.json()).toEqual({ error: 'no SUPABASE_DB_URL' }) + expect(await res.json()).toEqual({ + message: expect.stringContaining('SUPABASE_DB_URL'), + code: 'ENV_ERROR', + }) + }) + + it('prefers config.connectionString over SUPABASE_DB_URL', async () => { + const handler = withPostgresClient( + { connectionString: 'postgres://localhost/from-config' }, + async (_req, ctx) => { + await ctx.postgres.query('select 1') + return Response.json({ ok: true }) + }, + ) + + await handler(new Request('http://localhost'), { + ...seedContext(), + jwtClaims: { role: 'authenticated' }, + }) + + expect(h.pooled).toContain('postgres://localhost/from-config') + expect(h.pooled).not.toContain('postgres://localhost/test') + }) + + it('gives each connection string its own pool, and reuses it', async () => { + const run = async (connectionString: string) => { + const handler = withPostgresClient( + { connectionString }, + async (_req, ctx) => { + await ctx.postgres.query('select 1') + return Response.json({ ok: true }) + }, + ) + await handler(new Request('http://localhost'), { + ...seedContext(), + jwtClaims: { role: 'authenticated' }, + }) + } + + const before = h.pooled.length + await run('postgres://localhost/db-a') + await run('postgres://localhost/db-b') + // A repeat of db-a must hit the cache, not construct a third pool. + await run('postgres://localhost/db-a') + + expect(h.pooled.slice(before)).toEqual([ + 'postgres://localhost/db-a', + 'postgres://localhost/db-b', + ]) + }) + + it('falls back to anon with empty claims when there is no caller', async () => { + const handler = withPostgresClient(async (_req, ctx) => { + await ctx.postgres.query('select 1') + return Response.json({ ok: true }) + }) + + await handler(new Request('http://localhost'), { + ...seedContext(), + jwtClaims: null, + }) + + expect(h.issued).toContain('set local role anon') + // The set_config parameter is the second query issued. + expect(h.params[1]).toEqual(['{}']) }) it('injects the caller claims and drops to the authenticated role', async () => { - const handler = withPostgres(async (_req, ctx) => { + const handler = withPostgresClient(async (_req, ctx) => { await ctx.postgres.query('select 1') return Response.json({ ok: true }) }) @@ -73,21 +176,96 @@ describe('withPostgres', () => { expect(h.release).toHaveBeenCalled() }) - it('clamps any non-authenticated role (incl. a forged service_role) to anon', async () => { - const handler = withPostgres(async (_req, ctx) => { + it('treats claims with no role at all as anon', async () => { + const handler = withPostgresClient(async (_req, ctx) => { await ctx.postgres.query('select 1') return Response.json({ ok: true }) }) - await handler(new Request('http://localhost'), { + const res = await handler(new Request('http://localhost'), { ...seedContext(), - jwtClaims: { sub: 'attacker', role: 'service_role' }, + jwtClaims: { sub: 'u1' }, + }) + + expect(res.status).toBe(200) + expect(h.issued).toContain('set local role anon') + }) + + it('honours an explicit anon role', async () => { + const handler = withPostgresClient(async (_req, ctx) => { + await ctx.postgres.query('select 1') + return Response.json({ ok: true }) + }) + + const res = await handler(new Request('http://localhost'), { + ...seedContext(), + jwtClaims: { sub: 'u1', role: 'anon' }, }) + expect(res.status).toBe(200) expect(h.issued).toContain('set local role anon') + }) + + it('refuses a service_role token and points at withPostgresAdminClient', async () => { + const handler = withPostgresClient(async (_req, ctx) => { + await ctx.postgres.query('select 1') + return Response.json({ ok: true }) + }) + + const res = await handler(new Request('http://localhost'), { + ...seedContext(), + jwtClaims: { sub: 'attacker', role: 'service_role' }, + }) + + expect(res.status).toBe(500) + const body = (await res.json()) as { message: string; code: string } + expect(body.code).toBe('UNSUPPORTED_ROLE') + expect(body.message).toContain('withPostgresAdminClient') + // Never silently downgraded to anon, and never actually used. + expect(h.issued).not.toContain('set local role anon') expect(h.issued).not.toContain('set local role service_role') }) + it('refuses an unsupported custom role by name instead of downgrading', async () => { + const handler = withPostgresClient(async (_req, ctx) => { + await ctx.postgres.query('select 1') + return Response.json({ ok: true }) + }) + + const res = await handler(new Request('http://localhost'), { + ...seedContext(), + jwtClaims: { sub: 'u1', role: 'manager' }, + }) + + expect(res.status).toBe(500) + const body = (await res.json()) as { message: string; code: string } + expect(body.code).toBe('UNSUPPORTED_ROLE') + // Naming the role is the whole point — the old behaviour returned zero + // rows as anon and left the caller with nothing to debug. + expect(body.message).toContain('manager') + }) + + it('short-circuits before the handler runs when the role is refused', async () => { + const inner = vi.fn( + async (_req: Request, ctx: { postgres: PostgresApi }) => { + await ctx.postgres.query('select 1') + return Response.json({ ok: true }) + }, + ) + const handler = withPostgresClient(inner) + + await handler(new Request('http://localhost'), { + ...seedContext(), + jwtClaims: { sub: 'u1', role: 'manager' }, + }) + + // The refusal happens in run(), so the handler never executes and no + // connection is checked out of the pool. + expect(inner).not.toHaveBeenCalled() + expect(h.connect).not.toHaveBeenCalled() + expect(h.issued).toEqual([]) + }) + it('rolls back when the query throws', async () => { // begin, set_config, set role succeed; the user query throws. h.clientQuery @@ -107,7 +285,7 @@ describe('withPostgres', () => { throw new Error('boom') }) - const handler = withPostgres(async (_req, ctx) => { + const handler = withPostgresClient(async (_req, ctx) => { await ctx.postgres.query('select bad') return Response.json({ ok: true }) }) @@ -123,6 +301,44 @@ describe('withPostgres', () => { expect(h.release).toHaveBeenCalled() }) + it('surfaces the original error even when the rollback itself fails', async () => { + // begin, set_config, set role succeed; the user query throws; and the + // connection is broken enough that the rollback throws too. + const ok = async (t: string) => { + h.issued.push(t) + return { rows: [] } + } + h.clientQuery + .mockImplementationOnce(ok) + .mockImplementationOnce(ok) + .mockImplementationOnce(ok) + .mockImplementationOnce(async () => { + throw new Error('boom') + }) + .mockImplementationOnce(async () => { + throw new Error('connection terminated') + }) + + const handler = withPostgresClient(async (_req, ctx) => { + await ctx.postgres.query('select bad') + return Response.json({ ok: true }) + }) + + await expect( + handler(new Request('http://localhost'), { + ...seedContext(), + jwtClaims: { role: 'authenticated' }, + }), + // 'boom' — not 'connection terminated'. + ).rejects.toThrow('boom') + + // The connection may still be inside the caller's transaction with their + // role set. Returning it to the pool would leak that onto the next + // checkout — including withPostgresAdminClient, which shares this pool and + // sets up no session state of its own. release(truthy) discards it. + expect(h.release).toHaveBeenCalledWith(true) + }) + it('appends a grants hint to permission-denied (42501) errors', async () => { // begin, set_config, set role succeed; the user query hits missing grants. h.clientQuery @@ -146,7 +362,7 @@ describe('withPostgres', () => { throw err }) - const handler = withPostgres(async (_req, ctx) => { + const handler = withPostgresClient(async (_req, ctx) => { await ctx.postgres.query('select * from notes') return Response.json({ ok: true }) }) diff --git a/src/middleware/postgres/index.ts b/src/middleware/postgres/index.ts index 8e4e05e..d7af26b 100644 --- a/src/middleware/postgres/index.ts +++ b/src/middleware/postgres/index.ts @@ -1,56 +1,82 @@ -import { defineMiddleware, getEnv } from '@supabase/middleware' +import { defineMiddleware } from '@supabase/middleware' import type { Middleware } from '@supabase/middleware' -import pg from 'pg' -const { Pool } = pg +import { + getPool, + missingConnectionStringResponse, + resolveConnectionString, +} from '../../core/postgres-pool.js' +import type { PostgresApi } from '../../core/postgres-pool.js' +import { UnsupportedRoleError } from '../../errors.js' -// One pool per process, lazily created (config or SUPABASE_DB_URL). -let pool: pg.Pool | undefined -function getPool(connectionString: string): pg.Pool { - if (!pool) pool = new Pool({ connectionString, max: 4 }) - return pool -} +export type { PostgresApi } /** - * Minimal claims shape {@link withPostgres} needs on the upstream context. + * Roles this middleware will drop into. Deliberately not a denylist: we + * connect with `SUPABASE_DB_URL`, which on Supabase is `postgres` — a role + * with `BYPASSRLS` that can `SET ROLE` into almost anything. PostgREST needs + * no such list because it connects as the unprivileged `authenticator`, where + * `grant to authenticator` *is* the authorization. * - * Satisfied both by `withSupabase`'s JWKS-verified `ctx.jwtClaims` and by the - * standalone `withClaims` middleware — `withPostgres` only reads `role` and - * serializes the whole object into `request.jwt.claims`. + * Custom roles are legitimate on Supabase and RLS still applies to them, so + * this list is a v1 limitation rather than a security boundary — see the + * refusal message below. */ -interface RequestClaims { - role?: string - [key: string]: unknown +const SUPPORTED_ROLES = new Set(['authenticated', 'anon']) + +/** + * Resolve the role to assume, or a short-circuit `Response` explaining why we + * will not. Never silently downgrades a role the caller explicitly asked for: + * that returns zero rows and leaves nothing to debug. + */ +function resolveRole(claims: RequestClaims | null): string | Response { + const requested = typeof claims?.role === 'string' ? claims.role : undefined + + // No verified caller, or a token that names no role at all — anonymous is + // the expected outcome, not a downgrade. + if (!requested) return 'anon' + if (SUPPORTED_ROLES.has(requested)) return requested + + const message = + requested === 'service_role' + ? "The caller's token carries the 'service_role' role. withPostgresClient will not assume it — that role bypasses RLS, which is the guarantee this middleware exists to provide. If bypassing RLS is intended, compose withPostgresAdminClient from '@supabase/server/middleware/postgres-admin'." + : `The caller's token carries the role '${requested}', which withPostgresClient does not support yet — it assumes 'authenticated' or 'anon' only. Custom roles are on the roadmap; until then, issue tokens with one of the supported roles.` + + return Response.json({ message, code: UnsupportedRoleError }, { status: 500 }) } /** - * The `ctx.postgres` client contributed by {@link withPostgres}. + * Minimal claims shape {@link withPostgresClient} needs on the upstream context. + * + * Satisfied both by `withSupabase`'s JWKS-verified `ctx.jwtClaims` and by the + * standalone `withClaims` middleware — `withPostgresClient` only reads `role` + * and serializes the whole object into `request.jwt.claims`. * * @category Middleware */ -export interface PostgresApi { - /** Run a query inside the caller's RLS-scoped transaction. */ - query>( - text: string, - params?: unknown[], - ): Promise +export interface RequestClaims { + role?: string + [key: string]: unknown } /** - * Configuration for {@link withPostgres}. + * Configuration for {@link withPostgresClient}. * * @category Middleware */ -export interface WithPostgresConfig { +export interface WithPostgresClientConfig { /** Defaults to `getEnv('SUPABASE_DB_URL')` (from `@supabase/middleware`). */ connectionString?: string } /** * Contributes `ctx.postgres` — an RLS-scoped `pg` client, the safe version of - * "authenticate, then query as the user". Every query runs in its own short - * transaction that injects the caller's claims and drops to their role, exactly - * like PostgREST: + * "authenticate, then query as the user". This is the direct-connection + * counterpart to `withSupabaseClient`, and its service-role companion is + * `withPostgresAdminClient` (`@supabase/server/middleware/postgres-admin`). + * + * Every query runs in its own short transaction that injects the caller's + * claims and drops to their role, exactly like PostgREST: * * ```sql * begin; @@ -62,11 +88,22 @@ export interface WithPostgresConfig { * * Everything is transaction-local, so nothing leaks onto the pooled connection. * + * Only `authenticated` and `anon` are assumed. A token naming any other role — + * including `service_role` — is **refused** with a 500 and + * `code: 'UNSUPPORTED_ROLE'`, never silently downgraded to `anon`: running the + * query as the wrong identity would return zero rows and leave nothing to + * debug. Bypassing RLS is a separate, explicit opt-in: compose + * `withPostgresAdminClient`. + * + * > **Custom roles.** Supabase supports custom Postgres roles via the `role` + * > claim, and RLS still applies to them. They are not supported here yet, so + * > such a token is refused rather than downgraded. + * * Reads the caller's claims from `ctx.jwtClaims`, which `withSupabase` already * populates (JWKS-verified) — so inside `withSupabase` you compose it directly: * * ```ts - * withSupabase({ auth: 'user', middleware: [withPostgres()] }, handler) + * withSupabase({ auth: 'user', middleware: [withPostgresClient()] }, handler) * ``` * * Standalone (no `withSupabase`), pair it with `withClaims` so `ctx.jwtClaims` @@ -82,30 +119,32 @@ export interface WithPostgresConfig { * * @category Middleware */ -export const withPostgres: Middleware< +export const withPostgresClient: Middleware< 'postgres', - WithPostgresConfig | void, + WithPostgresClientConfig | void, { jwtClaims: RequestClaims | null }, PostgresApi > = defineMiddleware< 'postgres', - WithPostgresConfig | void, + WithPostgresClientConfig | void, { jwtClaims: RequestClaims | null }, PostgresApi >({ key: 'postgres', run: (config) => async (_req, ctx) => { - const connectionString = - config?.connectionString ?? getEnv('SUPABASE_DB_URL') + const connectionString = resolveConnectionString(config?.connectionString) if (!connectionString) { - return Response.json({ error: 'no SUPABASE_DB_URL' }, { status: 500 }) + return missingConnectionStringResponse('withPostgresClient') } - const p = getPool(connectionString) const claims = ctx.jwtClaims - // Clamp the role — a token can never flip the client into an RLS-bypassing - // role. service_role is deliberately not reachable here. - const role = claims?.role === 'authenticated' ? 'authenticated' : 'anon' + const role = resolveRole(claims) + // Refused before the handler runs and before a connection is checked out. + if (role instanceof Response) return role + + const p = getPool(connectionString) + // Fixed for the request, so serialize once rather than per query. + const claimsJson = JSON.stringify(claims ?? {}) const api: PostgresApi = { async query>( @@ -113,25 +152,43 @@ export const withPostgres: Middleware< params?: unknown[], ) { const client = await p.connect() + // Set when the transaction could not be unwound, so the connection is + // discarded rather than pooled — see the catch below. + let poisoned = false try { await client.query('begin') await client.query( `select set_config('request.jwt.claims', $1, true)`, - [JSON.stringify(claims ?? {})], + [claimsJson], ) - await client.query(`set local role ${role}`) // role is a clamped literal + // `role` is one of SUPPORTED_ROLES — never caller-supplied text. + await client.query(`set local role ${role}`) const res = await client.query(text, params) await client.query('commit') return res.rows as T[] } catch (e) { - await client.query('rollback') + // A broken connection makes the rollback throw too; that failure + // must not replace the error the caller actually needs to see. + try { + await client.query('rollback') + } catch { + // The original error wins — but we can no longer assume the + // session is clean. The transaction may still be open with the + // caller's role set, and this pool is shared with + // withPostgresAdminClient, which begins no transaction and would + // inherit that state on the next checkout. Discard the connection + // instead of pooling it. + poisoned = true + } // 42501 insufficient_privilege: the role lacks table grants. if (e instanceof Error && (e as { code?: string }).code === '42501') { e.message += ` (RLS-scoped queries run as the caller's role '${role}' — grant that role the table privileges it needs, e.g. "grant select on to ${role}")` } throw e } finally { - client.release() + // pg-pool removes the client instead of reusing it when release() + // gets a truthy argument. + client.release(poisoned) } }, } diff --git a/tsdown.config.ts b/tsdown.config.ts index 05ec5be..6bc16d9 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ 'src/adapters/elysia/index.ts', 'src/adapters/nestjs/index.ts', 'src/middleware/postgres/index.ts', + 'src/middleware/postgres-admin/index.ts', 'src/middleware/claims/index.ts', 'src/middleware/client/index.ts', 'src/middleware/admin-client/index.ts', diff --git a/typedoc.json b/typedoc.json index dc42b45..3009aa6 100644 --- a/typedoc.json +++ b/typedoc.json @@ -5,7 +5,9 @@ "src/core/index.ts", "src/adapters/hono/index.ts", "src/adapters/h3/index.ts", - "src/adapters/elysia/index.ts" + "src/adapters/elysia/index.ts", + "src/middleware/postgres/index.ts", + "src/middleware/postgres-admin/index.ts" ], "out": "api-docs", "json": "api-docs/spec.json", @@ -27,6 +29,7 @@ "docs/error-handling.md", "docs/security.md", "docs/ssr-frameworks.md", + "docs/postgres.md", "docs/typescript-generics.md" ], "highlightLanguages": [ @@ -36,7 +39,8 @@ "bash", "sh", "toml", - "json" + "json", + "sql" ], "sort": ["source-order"], "kindSortOrder": ["Function", "Class", "Interface", "TypeAlias", "Enum"],