/** * Compare the RLS policies a database actually has against the ones the * collections describe. * * Policies live in Postgres; the collection config is only their *source*. * Nothing reconciled the two, which is how the demo database served empty * collections indefinitely: its policies granted `TO authenticated` (a Supabase * role name) while requests run as `rebase_user`, so RLS filtered every row. * The config was later corrected and the database never noticed — an empty * table is indistinguishable from a table with no data. * * Expected policies are parsed from `generatePostgresPoliciesDdl`, the same * function `db push` uses to write `drizzle/policies.sql`, so this compares * against exactly what would be applied rather than a reimplementation. */ import { type CollectionConfig } from "@rebasepro/types"; export interface PolicyRef { schema: string; table: string; name: string; /** Roles in the TO clause. */ roles: string[]; /** SELECT / INSERT / UPDATE / DELETE / ALL. */ command: string; /** Whether a USING clause is present at all (not what it says). */ hasUsing: boolean; /** Whether a WITH CHECK clause is present at all (not what it says). */ hasWithCheck: boolean; /** * PERMISSIVE or RESTRICTIVE — the `AS` clause. * * An exact catalogue value on both sides, so it belongs with roles and * command rather than with the expression text. It matters more than either: * permissive policies are ORed together and restrictive ones ANDed, so a rule * declared `mode: "restrictive"` whose live policy is PERMISSIVE has had its * gate turned from a requirement into an alternative — the maximally * permissive way for this to be wrong. * * The DDL regex captured this from the start and the destructuring threw it * away; `pg_policies.permissive` was never selected. */ mode?: "PERMISSIVE" | "RESTRICTIVE"; /** * The live clause text, when read from `pg_policies`. Present only for live * policies (the expected side is parsed from DDL and does not carry it). * Used solely for the insecure-tautology scan, not for divergence — Postgres * rewrites this text, so it is not safe to diff against expected. */ qual?: string | null; withCheck?: string | null; } export interface PolicyDrift { /** Described by the collections, absent from the database. */ missing: PolicyRef[]; /** In the database, described by no collection — stale pushes live here. */ orphaned: PolicyRef[]; /** Same policy name, different roles or command. */ diverged: { expected: PolicyRef; actual: PolicyRef; differences: string[]; }[]; /** * A live policy whose expression is the known-permissive tautology * `rebase.uid() IS NOT NULL` — true for anonymous visitors too, because the * user path coerces a blank id to the `'anonymous'` sentinel. This is what * `policy.authenticated()` used to compile to, so a database pushed before * that fix carries it, and neither the name, roles, command nor clause * *presence* differs from the corrected policy — the only thing that changed * is the expression text, which this checker otherwise (correctly) ignores. * So it is the one drift that hides from every other check here. * * @see reason a sentence naming the clause and what to do. */ insecure: { policy: PolicyRef; reason: string; }[]; /** * A table the collections describe whose RLS switch is off. * * `ALTER TABLE posts DISABLE ROW LEVEL SECURITY` leaves every row in * `pg_policies` untouched, so before this category every expected policy * still matched on name, roles, command and clause presence and the checker * reported clean — on a table Postgres was applying no filter to at all. * Requests run as `rebase_user`, which holds full DML, so the table is wide * open while `doctor` certifies it. * * `forced` reports `relforcerowsecurity`, which is what also subjects the * table's *owner* to its policies. Its absence is not drift on its own — * Rebase does not connect as the owner in the request path — so it is * reported for context rather than raised as a failure. */ rlsDisabled: { schema: string; table: string; forced: boolean; }[]; } export interface Queryable { query(text: string, values?: unknown[]): Promise<{ rows: R[]; }>; } /** Parse the generated DDL rather than rebuilding the shape by hand. */ export declare function parseExpectedPolicies(ddl: string): PolicyRef[]; /** * Diff expected against live. * * Compares names, roles, command, and whether each clause exists — all exact * values. Policy expression *text* is deliberately not compared: Postgres * rewrites `qual`/`with_check` when storing them (parenthesising, casting, * schema-qualifying), so text comparison reports drift that does not exist, and * a check that cries wolf gets ignored. * * Presence is not text, though. A NULL `qual` is not a rewrite of an * expression, it is the absence of one, and absence has no false-positive risk: * either the generator emitted a clause or it did not. That distinction is worth * the extra comparison — a production database was found with a SELECT policy * whose `qual` was NULL, matching on every field this checked and denying 100% * of reads. The same blindness would hide a policy that fails open. */ export declare function checkPolicyDrift(client: Queryable, collections: CollectionConfig[]): Promise; /** * Does this name look like one the generator produced for this table? * * Unnamed rules compile to `__` (plus `_` when one * rule spans several operations), and the hash covers the rule's semantics — so * *editing* a rule renames its policy. The policy under the old name is left * behind by `db push`, which only DROPs the names it is about to CREATE, and * Postgres ORs PERMISSIVE policies together: a superseded `USING (true)` keeps * granting everything no matter how tight its replacement is. * * Matching the shape is what makes dropping them safe. A hand-written policy * would have to collide with a 7-hex digest to be mistaken for generated one; * a policy named anything else is left alone and merely reported, because a * custom name is indistinguishable from one someone wrote in SQL on purpose. */ export declare function isGeneratedPolicyName(name: string, table: string): boolean; export interface OrphanCleanup { /** Superseded generated policies that were dropped. */ dropped: PolicyRef[]; /** Orphans left in place because their names are not generator-shaped. */ kept: PolicyRef[]; } /** * Drop the policies an earlier push superseded but never removed. * * Only touches tables the collections describe — a table with no expected * policy is not ours to reconcile, and scanning by schema alone would sweep up * policies belonging to something else sharing the database. */ export declare function dropOrphanedPolicies(client: Queryable, drift: PolicyDrift, collections: CollectionConfig[]): Promise; export declare const hasDrift: (d: PolicyDrift) => boolean; /** Human-readable report; empty string when the database matches the config. */ export declare function formatPolicyDrift(drift: PolicyDrift): string;