import { SQL } from "drizzle-orm"; import { SecurityRule } from "@rebasepro/types"; import { REBASE_USER_ROLE } from "@rebasepro/common"; /** * Unified RLS enforcement — the "user context vs server context" model. * * Every operation runs in one of two contexts: * * - **User context** — a request authenticated (or anonymous) via * `driver.withAuth(user)`. Runs as the restricted `rebase_user` role: a * non-owner, NOSUPERUSER, NOBYPASSRLS role, so Postgres RLS binds *every* * statement (SELECT, INSERT, UPDATE, DELETE). The collection's * `securityRules` are the whole authorization model; app-layer callbacks * are validation/side-effects, not a security boundary. * * - **Server context** — the base (owner) connection: auth flows, migrations, * and raw `rebase.sql`. As table owner it bypasses RLS. This is the trusted * plane, equivalent to Supabase's `service_role`. * * `rebase.dataAsAdmin` is **not** in it, despite the name. `init.ts` scopes * that driver with `withAuth(SERVICE_IDENTITY)`, so it arrives as user * context above — `rebase_user`, `app.uid = 'service'`, policies evaluated — * and clears the default policies through their admin arm rather than the * `rebase.uid() IS NULL` one. * * This module provides the three pieces: * * 1. {@link detectConnectionPosture} — is the connection subject to RLS at * all? (superuser / BYPASSRLS / table owner ⇒ no) * 2. {@link ensureAppRole} — idempotently provision `rebase_user` with * SELECT/INSERT/UPDATE/DELETE grants (+ default privileges so future * tables stay covered). * 3. {@link applyAuthContext} — per-transaction: set the `app.*` GUCs the * policies read (`rebase.uid()` etc.) and `SET LOCAL ROLE rebase_user` so * RLS binds. Transaction-scoped, so it composes with poolers. * * Provisioning runs from the framework's own bootstrap/migrate (which already * self-creates the `auth` schema and functions) — enforcement is default-on, * not an operator opt-in. */ /** * The restricted role every authenticated (user-context) request runs as. * * Re-exported, not re-declared: the same name is needed by * `@rebasepro/common`'s internal-table revokes, and two spellings of a role name * fail as a silent no-op rather than an error. */ export { REBASE_USER_ROLE }; /** Minimal SQL runner so callers can adapt drizzle or pg.Client. */ export type RawSqlRunner = (sqlText: string) => Promise[]>; /** Minimal transaction surface needed by {@link applyAuthContext}. */ export interface SqlTx { execute(query: SQL): Promise; } export interface ConnectionPosture { /** The connection's `current_user`. */ role: string; superuser: boolean; bypassRLS: boolean; /** Owns at least one user table — owners bypass non-FORCE RLS. */ ownsTables: boolean; /** True when RLS would NOT constrain this connection. */ privileged: boolean; } export interface AuthContext { uid: string; /** Raw roles as carried on the user (strings or `{ id }` objects). */ roles: unknown[]; } /** * Warn when the connection role shares its name with an existing schema. * * Postgres resolves unqualified names through `search_path`, which defaults to * `"$user", public` — and `$user` is the connection ROLE. When a schema of that * name exists it sits ahead of `public`, so every unqualified statement * silently operates on it instead: * * CREATE TABLE posts (...); -- you meant public.posts; you got .posts * * Nothing errors. You get a second table of the same name in the wrong schema, * and reads that pin `public` cannot see it — which reads as "missing table" and * sends people to re-run a push that creates a *third* copy. The bootstrapper * has a whole branch dedicated to recognising the symptom after the fact. * * Rebase shipped straight into this: it creates a schema named `rebase` while * every template named the database role `rebase` too. The scaffold uses * `rebase_app` now, and every pool Rebase opens pins `search_path=public` * (`pinSearchPath`), which covers the paths the framework controls. This covers * the ones it does not — `psql`, `pg_dump`, drizzle-kit, a colleague's script, * a hand-written migration — because the hazard is a property of the two NAMES, * not of any one connection. * * A warning rather than a boot failure: the database works, the framework's own * traffic is pinned, and refusing to start over a naming choice a user may have * inherited would be worse than the risk. */ export declare function warnOnRoleSchemaCollision(run: RawSqlRunner): Promise; export declare function detectConnectionPosture(run: RawSqlRunner): Promise; /** * Human-actionable instructions for when the connection cannot provision the * user role itself (no CREATEROLE and role not pre-created by the platform). */ export declare function appRoleSetupInstructions(connectionRole: string, schemas: string[]): string; /** * Idempotently provision the `rebase_user` role, membership for the current * connection role, and DML grants (+ default privileges for future tables) * on every existing schema in `schemas`. * * Split into privilege tiers so it works both when the connection is a * superuser (creates everything) and when the platform pre-created the role * and membership (e.g. CNPG `postInitApplicationSQL`) and the connection is * merely the table owner — owners can always run the grant tier themselves. * * RLS still filters every row: these grants only make the tables *reachable* * by the role; the policies decide which rows/commands actually pass. * * Throws with precise setup instructions when the role is missing and the * connection cannot create it. */ export declare function ensureAppRole(run: RawSqlRunner, schemas: string[]): Promise; /** * Apply the authenticated context to a transaction: the `app.*` GUCs that RLS * policies read via `rebase.uid()` / `rebase.roles()` / `rebase.jwt()`, and — when * `userRole` is set — `SET LOCAL ROLE` so RLS binds every statement in this * transaction (reads *and* writes). * * GUCs are set with `is_local = true` and the role switch is `LOCAL`: both * reset at commit/rollback, so pooled connections are never polluted. * * Fails closed by construction: if the role switch errors, the transaction * aborts instead of proceeding privileged. * * SECURITY: this function is only ever called on the **user** path (the server * context uses the base/owner driver and never calls it). The default policies * treat `rebase.uid() IS NULL` as the trusted server context, and `rebase.uid()` * is `NULLIF(current_setting('app.uid'), '')` — so an EMPTY user id would * be read as NULL and silently escalate a user request to server privileges. * Coerce empty/blank ids to `ANONYMOUS_USER_ID` here, at the single chokepoint, * rather than trusting every caller (e.g. realtime subscription auth) to do it. * That sentinel is exported from `@rebasepro/types` because it leaks into rule * semantics: it is why `rebase.uid() IS NOT NULL` is true for anonymous requests. */ export declare function applyAuthContext(tx: SqlTx, auth: AuthContext, userRole?: string): Promise; /** * Warn about rules that read as "signed-in users only" but admit anonymous * callers — `rebase.uid() IS NOT NULL`, or a comparison against another * platform's magic user id such as `'anon'`. * * The sibling of {@link validatePolicyPgRoles}, for the more dangerous spelling * of the same habit. A foreign `pgRoles` value makes a policy unreachable and * the table reads empty — loud, and that guard throws. These do the opposite: * the rule compiles to a grant, and nothing looks wrong until the data is * already public. * * Warns rather than throws. Unlike an unreachable `pgRoles`, these rules are * serving traffic today: refusing to boot would take an app offline to report a * problem it already has, and on the read path it would take it offline * *because* its data was exposed. Rewriting the author's SQL is not an option * either — this is the escape hatch whose whole promise is that it means what it * says. So: say so, loudly, and leave the rule alone. */ export declare function warnOnAnonymousGrants(collections: { slug?: string; securityRules?: readonly SecurityRule[]; }[]): void; /** * Name the collections whose raw policy SQL still calls the pre-1.0 helpers. * * The compiler rewrites `auth.uid()` to `rebase.uid()` on the way into the * database, so nothing is broken and no policy is wrong — which is exactly why * this has to be said out loud. A silent rewrite that works forever is not a * migration, it is a second supported spelling nobody wrote down, and the next * person to read those rules will copy the old one. * * Only `raw` expressions can carry it. Structured rules (`policy.authUid()`, * `policy.rolesOverlap(...)`) compile from the model and were never affected. */ export declare function warnOnLegacyRlsFunctions(collections: { slug?: string; securityRules?: readonly SecurityRule[]; }[]): void; /** * Reject `pgRoles` that this server can never satisfy. * * `pgRoles` sets the `TO` clause of a generated policy, so a policy naming a * role the request never runs as simply never applies — and RLS then filters * every row. The table reads as empty, which is indistinguishable from having * no data, so the mistake survives review and ships. * * Requests run as `rebase_user`, so a policy is only reachable if it targets * `public` or a role `rebase_user` holds. Anything else is a configuration * error worth failing the boot for. */ export declare function validatePolicyPgRoles(run: RawSqlRunner, collections: { slug?: string; securityRules?: readonly { name?: string; pgRoles?: readonly string[]; }[]; }[], /** The role requests actually run as: `rebase_user` when the connection is * privileged enough to switch, otherwise the connection role itself. */ requestRole?: string): Promise;