import type { ClaimsFunction, TableInfo } from "../types.js"; /** * Read ownership out of the row-level security policies the developer already * wrote. * * WHY THIS EXISTS. Every other ownership signal in this tool is a foreign key, * which works right up until the users are not in the database. Clerk deprecated * its Supabase JWT template in April 2025 and the native integration has Clerk * issue the session token directly; the documented policy is * * using ((select auth.jwt()->>'sub') = (user_id)::text) * * and `user_id` is a bare `text` column with no users table anywhere in the * schema to point at. Auth0, Cognito and Firebase on Postgres are the same * shape. Foreign-key inference degrades every table there to `unknown` and the * run establishes nothing — while the developer had written down the answer, in * SQL, in the policy, which we introspected and threw away. * * WHAT STOPS A BAD POLICY FROM TEACHING US THE WRONG MODEL. A policy is written * by a human who may have written it wrongly, and a permissive one becoming the * ownership model is a new road to the false-roster class of error: believe the * wrong thing about who owns a row and the run can come back clean against a * database that is wide open. Four rules, all structural: * * 1. NOTHING IS INFERRED FROM ONE POLICY IN ISOLATION. Permissive policies are * OR-ed together by Postgres, so the weakest one decides what a role can * reach. Every permissive expression the signed-in role can use — every * `USING` and every `WITH CHECK` — has to confine rows to the same single * column, or nothing at all is learned. One `using (true)` beside a perfect * Clerk policy yields silence, because `true` is what the database will * actually honour. * * 2. ONLY AN EXACT EQUALITY BETWEEN A COLUMN OF THIS TABLE AND THE CALLER'S * OWN IDENTIFIER COUNTS. Not "mentions auth.uid()", not "looks scoped" — * the expression, after peeling parentheses, `( SELECT … )` wrappers and * casts, has to *be* ` = `. Anything with a subquery over * another table, an `ANY(ARRAY[…])`, a nested claim, or a shape we do not * recognise yields nothing. Extra `AND` conjuncts are tolerated because a * conjunct can only narrow the visible set; a top-level `OR` is refused * outright because a disjunct can only widen it. * * 3. THE IDENTITY HAS TO BE THE CALLER'S IDENTITY, PROVEN FROM THE CATALOG. * `auth.uid()`, the `sub` claim of the request's JWT, the PostgREST * per-claim GUC, or a zero-argument function the engine already discovered * structurally *and* whose body reads exactly the `sub` claim. That last * condition matters: `auth.role()` is discovered by the same structural rule * as `auth.uid()` and returns `'authenticated'`, so `role = auth.role()` * would otherwise be read as "the role column holds the caller's identity" * and hand every row of the table to everybody. * * 4. FOREIGN KEYS STILL WIN. This is consulted only where no key could be * followed, and where both exist and disagree the disagreement is printed * rather than silently resolved (see infer/ownership.ts). * * The residual risk is a policy that is honestly written and simply stale — it * names a column that is no longer the owner. What we then assert is that rows * carrying the caller's own identifier in the column the developer's own policy * gates on are not reachable by an unrelated user. On a table where that is * false, the table is reachable by everyone regardless, which is a hole under * every reading of it. * * ORG SCOPING, AND WHY IT IS TWO CLAIMS RATHER THAN ONE. Most real multi-tenant * schemas do not gate on the caller directly; they gate on the caller's * membership of a tenant, and the two shapes Postgres renders for that are * * EXISTS ( SELECT 1 FROM memberships m * WHERE ((m.org_id = documents.org_id) * AND (m.user_id = ( SELECT (auth.jwt() ->> 'sub'::text))))) * (org_id IN ( SELECT my_org_ids() AS my_org_ids)) * * The first states three things at once — which column of *this* table is the * tenant, which table is the roster, and which of the roster's columns are the * tenant and the member. The second states only the first of those. They are * read separately, because they are not equally safe to believe: * * THE TENANT COLUMN is safe to take from the policy alone. The two personas * are seeded into disjoint orgs, so the only claim that follows is "a row * carrying org A is unreachable by a caller who is in org B and nothing else". * That claim survives the policy being wrong in every other respect: if the * roster is not really a roster, or the set-returning function hands out every * org in the database, then the table really is reachable by strangers and the * finding is true. A wrong tenant column costs us a positive check, never a * false finding. * * THE ROSTER is not. "Table R lists who is in which org" is the belief that * produced this project's worst near-miss: `tickets` was scored as the roster, * and the generated helper read "you are in this org if you filed a ticket in * it", which grants every user who filed one ticket read/update/delete over * every ticket in the company — shipped with a green tick, because the two * personas are in different orgs by construction and verification therefore * cannot test whether org-wide sharing was intended at all. That error is * widening and invisible, so a developer *naming* a table in a policy is not * enough to establish it. The database still has to prove it, with a primary * key or unique constraint on the (org, user) pair; the policy is allowed only * to say which two columns to look at. Where the constraint is absent we take * the tenant column and nothing else. * * Four structural refusals separate a tenancy join from the two things that look * exactly like one. A share table (`EXISTS … FROM shares s WHERE s.doc_id = * documents.id AND s.user_id = me`) and a parent join (`EXISTS … FROM tasks t * WHERE t.id = comments.task_id AND t.user_id = me`) render identically to a * roster join; nothing in the SQL distinguishes them. So the *catalog* is asked: * the outer column must not be a key of the outer table, the inner column must * not be a key of the inner table, and the inner column must not be a foreign * key pointing back at the outer table. A tenant column is many-rows-to-one on * both sides and points at a third table; a share or a parent link is not and * does not. A parent join is already handled properly by foreign-key inference, * so refusing it here loses nothing. * * RESTRICTIVE POLICIES ARE READ TOO, AND THEY ARE THE EASY DIRECTION. Postgres * ANDs them with whatever the permissive policies allowed, so a restrictive * policy that confines rows to the caller confines the table however open the * permissive ones are — `USING (true)` beside `AS RESTRICTIVE USING (user_id = * auth.uid())` is a table where each row belongs to exactly one user, and * reading only the permissive half left us with nothing to say about it. The * unanimity rule inverts accordingly: permissive expressions are OR-ed so *all* * of them must confine, restrictive ones are AND-ed so any one of them confining * is enough. What is still required is that they agree — two restrictive * policies naming different columns describe a row that must carry the caller in * both, which is not a row this tool knows how to plant, so we say nothing. A * restrictive expression we cannot parse is likewise fatal to the restrictive * reading only, never to the permissive one: it can narrow what a permissive * policy admitted but it cannot widen it. */ /** * The roster a policy named *and* the database proved: a table holding at most * one row per (org, user) pair, which is what makes "you are in this org" a * question with a bounded answer rather than a content table being read as one. */ export interface PolicyRoster { /** Schema-qualified id of the table the policy joins to. */ tableId: string; /** The roster's tenant column — the one compared to this table's. */ orgColumn: string; /** The roster's member column — the one compared to the caller. */ userColumn: string; } /** What a policy confines this table's rows to. */ export interface PolicyScope { /** * `user` — the column holds the caller's own identifier, so each row belongs * to exactly one person. `org` — the column holds a tenant the caller is a * member of, so rows belong to an org. */ kind: "user" | "org"; column: string; /** How the caller's identity, or their set of orgs, was expressed. */ identity: string; /** * Only ever set for `org`, and only when a unique constraint proved it. A * policy naming a table is not enough; see the header. */ roster: PolicyRoster | null; } export interface PolicyOwnership extends PolicyScope { /** Names of the policies that said so. */ policies: string[]; } /** * What the policies for `role` confine this table's rows to, or `null` when they * do not unanimously confine it to one column. * * `tables` is the rest of the schema, needed only to ask the catalog whether a * table a policy joins to is really a roster. Called without it, every org shape * is refused — which is the correct answer when there is nothing to check * against. */ export declare function ownerColumnFromPolicies(table: TableInfo, claimsFunctions: readonly ClaimsFunction[], role: string | null, tables?: readonly TableInfo[]): PolicyOwnership | null; /** * Does this expression confine the rows it admits to ones the caller owns, and * if so through which column? */ export declare function confinesToCaller(expression: string, table: TableInfo, claimsFunctions: readonly ClaimsFunction[], tables?: readonly TableInfo[]): PolicyScope | null; /** * Is this expression the identifier of whoever is making the request? * * Returns a short description for the rationale, or null. Deliberately * exhaustive rather than fuzzy: an expression we do not recognise is not the * caller's identity, and the caller then learns nothing from this policy. */ export declare function callerIdentity(expression: string, claimsFunctions: readonly ClaimsFunction[], depth?: number): string | null; /** * Is this expression a plain reference to a column of `table`? * * Casts and parentheses are peeled — `(user_id)::text` is how Postgres renders a * text comparison against a uuid column — but nothing else is. A reference * qualified with a *different* table's name belongs to a join we are not * parsing, so it is refused. */ export declare function columnOfTable(expression: string, table: TableInfo): string | null; /** * Split on a top-level operator or keyword, respecting parentheses and both * kinds of quoting. Keywords are matched on word boundaries; `=` is not matched * as part of `<=`, `>=`, `<>` or `!=`. */ export declare function splitTopLevel(expression: string, op: "AND" | "OR" | "="): string[];