@nodii/db-rls 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/migrate-gen.mjs +11 -0
- package/dist/auth-schema.d.ts +207 -1
- package/dist/auth-schema.d.ts.map +1 -1
- package/dist/auth-schema.js +331 -4
- package/dist/auth-schema.js.map +1 -1
- package/dist/context.d.ts +31 -8
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +103 -24
- package/dist/context.js.map +1 -1
- package/dist/drizzle-client.d.ts +17 -5
- package/dist/drizzle-client.d.ts.map +1 -1
- package/dist/drizzle-client.js +19 -18
- package/dist/drizzle-client.js.map +1 -1
- package/dist/fence-cli.d.ts +21 -0
- package/dist/fence-cli.d.ts.map +1 -0
- package/dist/fence-cli.js +191 -0
- package/dist/fence-cli.js.map +1 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -7
- package/dist/index.js.map +1 -1
- package/dist/postgres-js-client.d.ts +17 -10
- package/dist/postgres-js-client.d.ts.map +1 -1
- package/dist/postgres-js-client.js +19 -21
- package/dist/postgres-js-client.js.map +1 -1
- package/dist/rls-policy-gen.d.ts +43 -27
- package/dist/rls-policy-gen.d.ts.map +1 -1
- package/dist/rls-policy-gen.js +40 -15
- package/dist/rls-policy-gen.js.map +1 -1
- package/dist/rls-policy.d.ts +11 -1
- package/dist/rls-policy.d.ts.map +1 -1
- package/dist/rls-policy.js +31 -4
- package/dist/rls-policy.js.map +1 -1
- package/dist/tenant-fence-gate.d.ts +54 -0
- package/dist/tenant-fence-gate.d.ts.map +1 -0
- package/dist/tenant-fence-gate.js +291 -0
- package/dist/tenant-fence-gate.js.map +1 -0
- package/dist/tenant-fence.d.ts +165 -0
- package/dist/tenant-fence.d.ts.map +1 -0
- package/dist/tenant-fence.js +542 -0
- package/dist/tenant-fence.js.map +1 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +22 -0
- package/dist/types.js.map +1 -1
- package/dist/verify-rls-cli.d.ts.map +1 -1
- package/dist/verify-rls-cli.js +86 -10
- package/dist/verify-rls-cli.js.map +1 -1
- package/dist/verify-rls.d.ts +26 -5
- package/dist/verify-rls.d.ts.map +1 -1
- package/dist/verify-rls.js +73 -5
- package/dist/verify-rls.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installs `auth.assert_tenant_fence()`.
|
|
3
|
+
*
|
|
4
|
+
* The function RAISES `P0001` naming every offender when the fence is broken,
|
|
5
|
+
* and returns silently when it is intact. It is the runtime post-condition:
|
|
6
|
+
* cheap enough to run once per process, specific enough that the error text
|
|
7
|
+
* tells an on-call engineer what to fix.
|
|
8
|
+
*
|
|
9
|
+
* The rule is the POSITIVE one, identical to `classifyAuthPolicy`: every
|
|
10
|
+
* permissive policy applying to `authenticated` MUST resolve through
|
|
11
|
+
* `auth.session_tenant_ids()`. Absence of the bad shape is not presence of the
|
|
12
|
+
* good one — a policy mangled by a sweep into something that mentions neither
|
|
13
|
+
* helper is an offender, not a pass.
|
|
14
|
+
*
|
|
15
|
+
* Role resolution uses `pg_has_role(..., 'USAGE')` plus OID 0 for PUBLIC, for
|
|
16
|
+
* the same reason the TypeScript verifier does: a `{public}` policy applies to
|
|
17
|
+
* `authenticated` without containing the word, and role membership is
|
|
18
|
+
* transitive. A substring test over `pg_policies.roles` misses both, and each
|
|
19
|
+
* was proven to yield a live cross-tenant read AND write while the check
|
|
20
|
+
* reported zero offenders.
|
|
21
|
+
*/
|
|
22
|
+
export declare const TENANT_FENCE_GATE_SQL = "CREATE SCHEMA IF NOT EXISTS auth;\nCREATE OR REPLACE FUNCTION auth._fence_reason(p_expr text) RETURNS text\nLANGUAGE sql IMMUTABLE\nSET search_path = pg_catalog, public, pg_temp\nAS $reason$\n SELECT CASE\n WHEN p_expr ~* '(auth\\s*\\.\\s*)?user_tenant_ids\\s*\\('\n THEN 'consults auth.user_tenant_ids directly instead of auth.session_tenant_ids() \u2014 resolves across EVERY tenant the principal belongs to'\n WHEN p_expr IS NULL OR btrim(p_expr) = '' OR p_expr ~* '^\\(?\\s*true\\s*\\)?$'\n THEN 'is permissive with no tenant reference at all \u2014 every tenant, every row'\n ELSE 'does not resolve through auth.session_tenant_ids() \u2014 it may be fenced some other way, but nothing here proves it, and an unrecognized shape is not a safe shape'\n END\n$reason$;\nCREATE OR REPLACE FUNCTION auth.assert_tenant_fence(\n p_schemas text[] DEFAULT ARRAY['public'],\n p_exempt_policies text[] DEFAULT ARRAY[]::text[],\n p_exempt_tables text[] DEFAULT ARRAY[]::text[],\n p_tenant_columns text[] DEFAULT ARRAY['tenant_id','org_id','organization_id'],\n p_allow_degraded boolean DEFAULT true\n) RETURNS void\nLANGUAGE plpgsql STABLE SECURITY DEFINER\nSET search_path = pg_catalog, public, pg_temp\nAS $fence_check$\nDECLARE\n v_offenders text[] := ARRAY[]::text[];\n v_row record;\n v_resolver record;\n v_membership record;\n v_degraded boolean := false;\nBEGIN\n -- ---------------------------------------------------------------------\n -- 1. The resolver itself. If it is missing, every policy referencing it is\n -- already broken, and nothing below can be interpreted.\n -- ---------------------------------------------------------------------\n SELECT p.prosrc, p.prosecdef INTO v_resolver\n FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'auth' AND p.proname = 'session_tenant_ids' LIMIT 1;\n\n IF v_resolver IS NULL THEN\n RAISE EXCEPTION 'tenant_fence: auth.session_tenant_ids() is not installed'\n USING ERRCODE = 'P0001',\n HINT = 'Apply INSTALL_SESSION_RESOLVER_SQL (applyAuthSchemaMigrations ships it, and @nodii/saga + @nodii/dsar + @nodii/audit-chain install it before their policies).';\n END IF;\n\n v_degraded := v_resolver.prosrc LIKE '%DB_RLS_DEGRADED_NO_MEMBERSHIP_MIRROR%';\n\n IF v_resolver.prosrc !~* 'RAISE\\s+EXCEPTION' THEN\n v_offenders := v_offenders || 'auth.session_tenant_ids() [session_tenant_ids] does not RAISE on an unbound session \u2014 it was replaced with a NULL-returning getter, which restores silent failure';\n END IF;\n\n IF NOT v_resolver.prosecdef THEN\n v_offenders := v_offenders || 'auth.session_tenant_ids() [session_tenant_ids] is not SECURITY DEFINER \u2014 it cannot read the membership mirror (permission denied), or will recurse into the mirror''s own policy (54001)';\n END IF;\n\n IF v_degraded AND NOT p_allow_degraded THEN\n v_offenders := v_offenders || 'auth.session_tenant_ids() [session_tenant_ids] is the DEGRADED fence-only resolver \u2014 the session tenant is trusted WITHOUT proving membership';\n END IF;\n\n -- The stub check applies ONLY to the strict resolver. Under the degraded body\n -- auth.user_tenant_ids is never consulted, so a stub there is the expected,\n -- D415-sanctioned terminal state \u2014 not a defect. Flagging it would make every\n -- legitimately-degraded service permanently red, which is how a gate gets\n -- switched off.\n IF NOT v_degraded THEN\n SELECT p.prosrc INTO v_membership\n FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'auth' AND p.proname = 'user_tenant_ids' LIMIT 1;\n IF v_membership IS NOT NULL\n AND v_membership.prosrc ~* 'where\\s+false'\n AND v_membership.prosrc !~* 'from\\s+\\w' THEN\n v_offenders := v_offenders || 'auth.user_tenant_ids(uuid) [user_tenant_ids] is still the bootstrap stub returning the empty set \u2014 the strict resolver intersects to nothing, so the authenticated pool is silently deny-all';\n END IF;\n END IF;\n\n -- ---------------------------------------------------------------------\n -- 2. The positive rule over every authenticated-applicable policy.\n -- ---------------------------------------------------------------------\n FOR v_row IN\n SELECT n.nspname AS schemaname,\n c.relname AS tablename,\n pol.polname AS policyname,\n CASE pol.polcmd WHEN 'r' THEN 'SELECT' WHEN 'a' THEN 'INSERT'\n WHEN 'w' THEN 'UPDATE' WHEN 'd' THEN 'DELETE'\n ELSE 'ALL' END AS cmd,\n pg_get_expr(pol.polqual, pol.polrelid) AS qual,\n pg_get_expr(pol.polwithcheck, pol.polrelid) AS with_check\n FROM pg_policy pol\n JOIN pg_class c ON c.oid = pol.polrelid\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE n.nspname = ANY(p_schemas)\n -- RESTRICTIVE policies are ANDed; they can only narrow access, so they\n -- cannot be the source of a cross-tenant grant.\n AND pol.polpermissive\n AND (\n EXISTS (SELECT 1 FROM unnest(pol.polroles) r WHERE r = 0)\n OR (\n EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated')\n AND EXISTS (\n SELECT 1 FROM unnest(pol.polroles) r\n WHERE pg_has_role('authenticated', r, 'USAGE')\n )\n )\n )\n AND NOT (pol.polname = ANY(p_exempt_policies))\n AND NOT ((c.relname || '.' || pol.polname) = ANY(p_exempt_policies))\n AND NOT (c.relname = ANY(p_exempt_tables))\n AND NOT ((n.nspname || '.' || c.relname) = ANY(p_exempt_tables))\n ORDER BY c.relname, pol.polname\n LOOP\n -- A policy with NEITHER clause is permissive-everything.\n IF v_row.qual IS NULL AND v_row.with_check IS NULL THEN\n v_offenders := v_offenders || format(\n '%s.%s [%s %s] is permissive with no tenant reference at all \u2014 every tenant, every row',\n v_row.schemaname, v_row.tablename, v_row.policyname, v_row.cmd);\n CONTINUE;\n END IF;\n\n -- EVERY clause present must resolve. A fenced read with an unfenced\n -- WITH CHECK is a cross-tenant WRITE, which is what the refund incident\n -- actually exercised.\n IF v_row.qual IS NOT NULL\n AND v_row.qual !~* '(auth\\s*\\.\\s*)?session_tenant_ids\\s*\\(' THEN\n v_offenders := v_offenders || format('%s.%s [%s %s USING] %s :: %s',\n v_row.schemaname, v_row.tablename, v_row.policyname, v_row.cmd,\n auth._fence_reason(v_row.qual), v_row.qual);\n END IF;\n\n IF v_row.with_check IS NOT NULL\n AND v_row.with_check !~* '(auth\\s*\\.\\s*)?session_tenant_ids\\s*\\(' THEN\n v_offenders := v_offenders || format('%s.%s [%s %s WITH CHECK] %s :: %s',\n v_row.schemaname, v_row.tablename, v_row.policyname, v_row.cmd,\n auth._fence_reason(v_row.with_check), v_row.with_check);\n END IF;\n END LOOP;\n\n -- ---------------------------------------------------------------------\n -- 3. Table coverage. THE HOLE A POLICY-LEVEL CHECK CANNOT SEE: a table with\n -- a tenant column and RLS switched off has no rows in pg_policy to\n -- inspect, and under the canonical blanket grants it is readable AND\n -- writable across every tenant \u2014 strictly more open than the union\n -- predicate this release removes.\n --\n -- Discovered FROM THE CATALOG, never from a caller-supplied list. The\n -- author who forgets ENABLE ROW LEVEL SECURITY is the same author who\n -- forgets the list entry, in the same migration.\n -- ---------------------------------------------------------------------\n FOR v_row IN\n SELECT n.nspname AS schemaname, c.relname AS tablename,\n c.relkind::text AS relkind, c.relrowsecurity,\n COALESCE((SELECT true FROM unnest(COALESCE(c.reloptions,'{}')) o\n WHERE lower(replace(o,' ','')) = 'security_invoker=true'), false) AS security_invoker\n FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE c.relkind = ANY('{r,p,v,m,f}')\n AND n.nspname = ANY(p_schemas)\n AND NOT (c.relname = ANY(p_exempt_tables))\n AND NOT ((n.nspname || '.' || c.relname) = ANY(p_exempt_tables))\n AND EXISTS (SELECT 1 FROM pg_attribute a\n WHERE a.attrelid = c.oid AND a.attnum > 0\n AND NOT a.attisdropped AND a.attname = ANY(p_tenant_columns))\n ORDER BY n.nspname, c.relname\n LOOP\n IF v_row.relkind IN ('v','m','f') THEN\n -- A view with security_invoker=true executes as the CALLER, so the\n -- underlying table's RLS applies and the view is genuinely safe.\n IF v_row.relkind = 'v' AND v_row.security_invoker THEN CONTINUE; END IF;\n v_offenders := v_offenders || format(\n '%s.%s exposes a tenant column but cannot enforce RLS (relkind=%s) \u2014 a materialized/foreign relation has no row policies at all, and a view without security_invoker=true runs as its OWNER and bypasses the underlying table''s policies',\n v_row.schemaname, v_row.tablename, v_row.relkind);\n ELSIF NOT v_row.relrowsecurity THEN\n v_offenders := v_offenders || format(\n '%s.%s has a tenant column but ROW LEVEL SECURITY is NOT ENABLED \u2014 with the canonical blanket grants this table is readable and writable across EVERY tenant, and no policy is ever consulted',\n v_row.schemaname, v_row.tablename);\n END IF;\n END LOOP;\n\n IF array_length(v_offenders, 1) > 0 THEN\n RAISE EXCEPTION 'tenant_fence: % authenticated-pool policy/helper defect(s). A user session must reach exactly ONE tenant \u2014 the one the edge resolved from the request \u2014 and nothing else, ever.%',\n array_length(v_offenders, 1),\n E'\\n - ' || array_to_string(v_offenders, E'\\n - ')\n USING ERRCODE = 'P0001',\n HINT = 'Regenerate policies with @nodii/db-rls withRls() / generateRlsPolicy(), which emit \"<col> IN (SELECT auth.session_tenant_ids())\". Nodii-staff cross-tenant access is a different path entirely: the platform_ops pool + withPlatformOpsContext.';\n END IF;\nEND\n$fence_check$;\nGRANT EXECUTE ON FUNCTION auth._fence_reason(text) TO PUBLIC;\n";
|
|
23
|
+
/** Grant for the gate. Split from the DDL for the same reason every other
|
|
24
|
+
* grant in this package is: the roles must exist before the GRANT runs, and
|
|
25
|
+
* on a fresh database they do not yet. */
|
|
26
|
+
export declare const TENANT_FENCE_GATE_GRANTS_SQL = "GRANT EXECUTE ON FUNCTION auth.assert_tenant_fence(text[], text[], text[], text[], boolean) TO nodii_services, authenticated;\n";
|
|
27
|
+
/** The statement `withUserContext` runs once per process on first authenticated
|
|
28
|
+
* checkout. Kept as a named constant so the wiring is greppable from the call
|
|
29
|
+
* site and the test-suite asserts the exact text. */
|
|
30
|
+
export declare const TENANT_FENCE_GATE_CALL_SQL = "SELECT auth.assert_tenant_fence()";
|
|
31
|
+
/**
|
|
32
|
+
* THE DELIVERY UNIT, whole: the resolver the policies reference, plus the gate
|
|
33
|
+
* that proves they reference it.
|
|
34
|
+
*
|
|
35
|
+
* These ship together because they fail together. A database with the resolver
|
|
36
|
+
* but no gate has a fence nobody checks; a database with the gate but no
|
|
37
|
+
* resolver cannot create the policies in the first place. Splitting them would
|
|
38
|
+
* create a state where a service is half-delivered and neither half complains,
|
|
39
|
+
* which is the condition this whole release exists to end.
|
|
40
|
+
*
|
|
41
|
+
* Every delivery path applies exactly this constant:
|
|
42
|
+
* - `applyAuthSchemaMigrations` (@nodii/db-rls)
|
|
43
|
+
* - `applySagaMigrations` (@nodii/saga)
|
|
44
|
+
* - `applyDsarMigrations` (@nodii/dsar)
|
|
45
|
+
* - `applyAuditChainMigrations` (@nodii/audit-chain)
|
|
46
|
+
* - the `nodii-db-rls install-fence` CLI, for a service on none of the above
|
|
47
|
+
*
|
|
48
|
+
* The three mirrors inline a byte-equal copy rather than importing it, to
|
|
49
|
+
* preserve their deliberate zero-dependency posture on `@nodii/db-rls`. That
|
|
50
|
+
* duplication is guarded by a byte-equality test in each mirror's suite, not by
|
|
51
|
+
* a comment asking people to remember.
|
|
52
|
+
*/
|
|
53
|
+
export declare function sessionFenceBootstrapSql(installSql: string): string;
|
|
54
|
+
//# sourceMappingURL=tenant-fence-gate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tenant-fence-gate.d.ts","sourceRoot":"","sources":["../src/tenant-fence-gate.ts"],"names":[],"mappings":"AAkDA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,qBAAqB,0hUAyLjC,CAAC;AAEF;;2CAE2C;AAC3C,eAAO,MAAM,4BAA4B,oIACxC,CAAC;AAEF;;sDAEsD;AACtD,eAAO,MAAM,0BAA0B,sCAAsC,CAAC;AAE9E;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,wBAAwB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAEnE"}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// The RUNTIME half of the fence gate: `auth.assert_tenant_fence()`.
|
|
2
|
+
//
|
|
3
|
+
// `verifyTenantFence` (tenant-fence.ts) is the deploy-time gate. It is
|
|
4
|
+
// thorough, it reports every offender at once, and it is the right thing to run
|
|
5
|
+
// in CI and in a `db:verify` step. It also cannot be the only gate, for a
|
|
6
|
+
// reason that is structural rather than a matter of diligence:
|
|
7
|
+
//
|
|
8
|
+
// SIX OF THE TEN SERVICES THIS RELEASE HAS TO REACH RUN NO DATABASE STEP ON
|
|
9
|
+
// DEPLOY AT ALL.
|
|
10
|
+
//
|
|
11
|
+
// That is measured, not assumed — `.github/workflows/deploy-dev.yml` in
|
|
12
|
+
// billing, kyc, notification, task-tracking, tenant and geo contains no
|
|
13
|
+
// `db:setup` and no `db:migrate`. Their `db:setup` exists, and it runs in
|
|
14
|
+
// `ci.yml`, against a throwaway CI Postgres that is deleted minutes later. A
|
|
15
|
+
// gate wired into `db:setup` would be green on every one of those services
|
|
16
|
+
// forever while their real databases drifted, which is worse than no gate,
|
|
17
|
+
// because it would be REPORTED as a pass.
|
|
18
|
+
//
|
|
19
|
+
// So the runtime gate has to live somewhere every service reaches without
|
|
20
|
+
// anyone wiring it. There are only two such places: the library code a service
|
|
21
|
+
// calls to get a connection, and the database itself. This module uses both —
|
|
22
|
+
// a SECURITY DEFINER function in the database, invoked by `withUserContext` on
|
|
23
|
+
// first use (context.ts).
|
|
24
|
+
//
|
|
25
|
+
// WHY A SQL FUNCTION RATHER THAN THE TYPESCRIPT VERIFIER.
|
|
26
|
+
//
|
|
27
|
+
// `PgTx.exec` returns `Promise<void>`. The pool abstraction in this library
|
|
28
|
+
// deliberately yields no row-returning query surface, so a JavaScript catalog
|
|
29
|
+
// scan cannot run on the pool a service already has — it needs a separate
|
|
30
|
+
// `RlsQuerier` the caller supplies. "The caller supplies it" is precisely the
|
|
31
|
+
// property that left `verifyRls` unwired in every repo for a year. A check
|
|
32
|
+
// whose invocation depends on somebody passing something is a check that will
|
|
33
|
+
// not run.
|
|
34
|
+
//
|
|
35
|
+
// A SECURITY DEFINER function has none of that dependency. It is installed by
|
|
36
|
+
// the same delivery unit as the resolver, so a database that can evaluate the
|
|
37
|
+
// fence predicate can always also evaluate the fence check. It is reachable
|
|
38
|
+
// through `exec`. It works identically from the Go and Python ports, and from
|
|
39
|
+
// psql during an incident. And because it is SECURITY DEFINER it can read
|
|
40
|
+
// `pg_policy`, which `authenticated` cannot.
|
|
41
|
+
//
|
|
42
|
+
// TWO IMPLEMENTATIONS IS A REAL COST, AND IT IS PAID DELIBERATELY.
|
|
43
|
+
//
|
|
44
|
+
// This function and `verifyTenantFence` encode the same rule twice, which is
|
|
45
|
+
// the duplication failure this codebase keeps being bitten by. The control is
|
|
46
|
+
// not a comment asking people to keep them in step: `tests/fence-gate-agreement
|
|
47
|
+
// .integration.test.ts` stands up each offending shape in a real database and
|
|
48
|
+
// asserts BOTH implementations reach the same verdict on it. If one is fixed
|
|
49
|
+
// and the other is not, that test goes red.
|
|
50
|
+
/**
|
|
51
|
+
* Installs `auth.assert_tenant_fence()`.
|
|
52
|
+
*
|
|
53
|
+
* The function RAISES `P0001` naming every offender when the fence is broken,
|
|
54
|
+
* and returns silently when it is intact. It is the runtime post-condition:
|
|
55
|
+
* cheap enough to run once per process, specific enough that the error text
|
|
56
|
+
* tells an on-call engineer what to fix.
|
|
57
|
+
*
|
|
58
|
+
* The rule is the POSITIVE one, identical to `classifyAuthPolicy`: every
|
|
59
|
+
* permissive policy applying to `authenticated` MUST resolve through
|
|
60
|
+
* `auth.session_tenant_ids()`. Absence of the bad shape is not presence of the
|
|
61
|
+
* good one — a policy mangled by a sweep into something that mentions neither
|
|
62
|
+
* helper is an offender, not a pass.
|
|
63
|
+
*
|
|
64
|
+
* Role resolution uses `pg_has_role(..., 'USAGE')` plus OID 0 for PUBLIC, for
|
|
65
|
+
* the same reason the TypeScript verifier does: a `{public}` policy applies to
|
|
66
|
+
* `authenticated` without containing the word, and role membership is
|
|
67
|
+
* transitive. A substring test over `pg_policies.roles` misses both, and each
|
|
68
|
+
* was proven to yield a live cross-tenant read AND write while the check
|
|
69
|
+
* reported zero offenders.
|
|
70
|
+
*/
|
|
71
|
+
export const TENANT_FENCE_GATE_SQL = `CREATE SCHEMA IF NOT EXISTS auth;
|
|
72
|
+
CREATE OR REPLACE FUNCTION auth._fence_reason(p_expr text) RETURNS text
|
|
73
|
+
LANGUAGE sql IMMUTABLE
|
|
74
|
+
SET search_path = pg_catalog, public, pg_temp
|
|
75
|
+
AS $reason$
|
|
76
|
+
SELECT CASE
|
|
77
|
+
WHEN p_expr ~* '(auth\\s*\\.\\s*)?user_tenant_ids\\s*\\('
|
|
78
|
+
THEN 'consults auth.user_tenant_ids directly instead of auth.session_tenant_ids() — resolves across EVERY tenant the principal belongs to'
|
|
79
|
+
WHEN p_expr IS NULL OR btrim(p_expr) = '' OR p_expr ~* '^\\(?\\s*true\\s*\\)?$'
|
|
80
|
+
THEN 'is permissive with no tenant reference at all — every tenant, every row'
|
|
81
|
+
ELSE 'does not resolve through auth.session_tenant_ids() — it may be fenced some other way, but nothing here proves it, and an unrecognized shape is not a safe shape'
|
|
82
|
+
END
|
|
83
|
+
$reason$;
|
|
84
|
+
CREATE OR REPLACE FUNCTION auth.assert_tenant_fence(
|
|
85
|
+
p_schemas text[] DEFAULT ARRAY['public'],
|
|
86
|
+
p_exempt_policies text[] DEFAULT ARRAY[]::text[],
|
|
87
|
+
p_exempt_tables text[] DEFAULT ARRAY[]::text[],
|
|
88
|
+
p_tenant_columns text[] DEFAULT ARRAY['tenant_id','org_id','organization_id'],
|
|
89
|
+
p_allow_degraded boolean DEFAULT true
|
|
90
|
+
) RETURNS void
|
|
91
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
|
92
|
+
SET search_path = pg_catalog, public, pg_temp
|
|
93
|
+
AS $fence_check$
|
|
94
|
+
DECLARE
|
|
95
|
+
v_offenders text[] := ARRAY[]::text[];
|
|
96
|
+
v_row record;
|
|
97
|
+
v_resolver record;
|
|
98
|
+
v_membership record;
|
|
99
|
+
v_degraded boolean := false;
|
|
100
|
+
BEGIN
|
|
101
|
+
-- ---------------------------------------------------------------------
|
|
102
|
+
-- 1. The resolver itself. If it is missing, every policy referencing it is
|
|
103
|
+
-- already broken, and nothing below can be interpreted.
|
|
104
|
+
-- ---------------------------------------------------------------------
|
|
105
|
+
SELECT p.prosrc, p.prosecdef INTO v_resolver
|
|
106
|
+
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
107
|
+
WHERE n.nspname = 'auth' AND p.proname = 'session_tenant_ids' LIMIT 1;
|
|
108
|
+
|
|
109
|
+
IF v_resolver IS NULL THEN
|
|
110
|
+
RAISE EXCEPTION 'tenant_fence: auth.session_tenant_ids() is not installed'
|
|
111
|
+
USING ERRCODE = 'P0001',
|
|
112
|
+
HINT = 'Apply INSTALL_SESSION_RESOLVER_SQL (applyAuthSchemaMigrations ships it, and @nodii/saga + @nodii/dsar + @nodii/audit-chain install it before their policies).';
|
|
113
|
+
END IF;
|
|
114
|
+
|
|
115
|
+
v_degraded := v_resolver.prosrc LIKE '%DB_RLS_DEGRADED_NO_MEMBERSHIP_MIRROR%';
|
|
116
|
+
|
|
117
|
+
IF v_resolver.prosrc !~* 'RAISE\\s+EXCEPTION' THEN
|
|
118
|
+
v_offenders := v_offenders || 'auth.session_tenant_ids() [session_tenant_ids] does not RAISE on an unbound session — it was replaced with a NULL-returning getter, which restores silent failure';
|
|
119
|
+
END IF;
|
|
120
|
+
|
|
121
|
+
IF NOT v_resolver.prosecdef THEN
|
|
122
|
+
v_offenders := v_offenders || 'auth.session_tenant_ids() [session_tenant_ids] is not SECURITY DEFINER — it cannot read the membership mirror (permission denied), or will recurse into the mirror''s own policy (54001)';
|
|
123
|
+
END IF;
|
|
124
|
+
|
|
125
|
+
IF v_degraded AND NOT p_allow_degraded THEN
|
|
126
|
+
v_offenders := v_offenders || 'auth.session_tenant_ids() [session_tenant_ids] is the DEGRADED fence-only resolver — the session tenant is trusted WITHOUT proving membership';
|
|
127
|
+
END IF;
|
|
128
|
+
|
|
129
|
+
-- The stub check applies ONLY to the strict resolver. Under the degraded body
|
|
130
|
+
-- auth.user_tenant_ids is never consulted, so a stub there is the expected,
|
|
131
|
+
-- D415-sanctioned terminal state — not a defect. Flagging it would make every
|
|
132
|
+
-- legitimately-degraded service permanently red, which is how a gate gets
|
|
133
|
+
-- switched off.
|
|
134
|
+
IF NOT v_degraded THEN
|
|
135
|
+
SELECT p.prosrc INTO v_membership
|
|
136
|
+
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
137
|
+
WHERE n.nspname = 'auth' AND p.proname = 'user_tenant_ids' LIMIT 1;
|
|
138
|
+
IF v_membership IS NOT NULL
|
|
139
|
+
AND v_membership.prosrc ~* 'where\\s+false'
|
|
140
|
+
AND v_membership.prosrc !~* 'from\\s+\\w' THEN
|
|
141
|
+
v_offenders := v_offenders || 'auth.user_tenant_ids(uuid) [user_tenant_ids] is still the bootstrap stub returning the empty set — the strict resolver intersects to nothing, so the authenticated pool is silently deny-all';
|
|
142
|
+
END IF;
|
|
143
|
+
END IF;
|
|
144
|
+
|
|
145
|
+
-- ---------------------------------------------------------------------
|
|
146
|
+
-- 2. The positive rule over every authenticated-applicable policy.
|
|
147
|
+
-- ---------------------------------------------------------------------
|
|
148
|
+
FOR v_row IN
|
|
149
|
+
SELECT n.nspname AS schemaname,
|
|
150
|
+
c.relname AS tablename,
|
|
151
|
+
pol.polname AS policyname,
|
|
152
|
+
CASE pol.polcmd WHEN 'r' THEN 'SELECT' WHEN 'a' THEN 'INSERT'
|
|
153
|
+
WHEN 'w' THEN 'UPDATE' WHEN 'd' THEN 'DELETE'
|
|
154
|
+
ELSE 'ALL' END AS cmd,
|
|
155
|
+
pg_get_expr(pol.polqual, pol.polrelid) AS qual,
|
|
156
|
+
pg_get_expr(pol.polwithcheck, pol.polrelid) AS with_check
|
|
157
|
+
FROM pg_policy pol
|
|
158
|
+
JOIN pg_class c ON c.oid = pol.polrelid
|
|
159
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
160
|
+
WHERE n.nspname = ANY(p_schemas)
|
|
161
|
+
-- RESTRICTIVE policies are ANDed; they can only narrow access, so they
|
|
162
|
+
-- cannot be the source of a cross-tenant grant.
|
|
163
|
+
AND pol.polpermissive
|
|
164
|
+
AND (
|
|
165
|
+
EXISTS (SELECT 1 FROM unnest(pol.polroles) r WHERE r = 0)
|
|
166
|
+
OR (
|
|
167
|
+
EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated')
|
|
168
|
+
AND EXISTS (
|
|
169
|
+
SELECT 1 FROM unnest(pol.polroles) r
|
|
170
|
+
WHERE pg_has_role('authenticated', r, 'USAGE')
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
AND NOT (pol.polname = ANY(p_exempt_policies))
|
|
175
|
+
AND NOT ((c.relname || '.' || pol.polname) = ANY(p_exempt_policies))
|
|
176
|
+
AND NOT (c.relname = ANY(p_exempt_tables))
|
|
177
|
+
AND NOT ((n.nspname || '.' || c.relname) = ANY(p_exempt_tables))
|
|
178
|
+
ORDER BY c.relname, pol.polname
|
|
179
|
+
LOOP
|
|
180
|
+
-- A policy with NEITHER clause is permissive-everything.
|
|
181
|
+
IF v_row.qual IS NULL AND v_row.with_check IS NULL THEN
|
|
182
|
+
v_offenders := v_offenders || format(
|
|
183
|
+
'%s.%s [%s %s] is permissive with no tenant reference at all — every tenant, every row',
|
|
184
|
+
v_row.schemaname, v_row.tablename, v_row.policyname, v_row.cmd);
|
|
185
|
+
CONTINUE;
|
|
186
|
+
END IF;
|
|
187
|
+
|
|
188
|
+
-- EVERY clause present must resolve. A fenced read with an unfenced
|
|
189
|
+
-- WITH CHECK is a cross-tenant WRITE, which is what the refund incident
|
|
190
|
+
-- actually exercised.
|
|
191
|
+
IF v_row.qual IS NOT NULL
|
|
192
|
+
AND v_row.qual !~* '(auth\\s*\\.\\s*)?session_tenant_ids\\s*\\(' THEN
|
|
193
|
+
v_offenders := v_offenders || format('%s.%s [%s %s USING] %s :: %s',
|
|
194
|
+
v_row.schemaname, v_row.tablename, v_row.policyname, v_row.cmd,
|
|
195
|
+
auth._fence_reason(v_row.qual), v_row.qual);
|
|
196
|
+
END IF;
|
|
197
|
+
|
|
198
|
+
IF v_row.with_check IS NOT NULL
|
|
199
|
+
AND v_row.with_check !~* '(auth\\s*\\.\\s*)?session_tenant_ids\\s*\\(' THEN
|
|
200
|
+
v_offenders := v_offenders || format('%s.%s [%s %s WITH CHECK] %s :: %s',
|
|
201
|
+
v_row.schemaname, v_row.tablename, v_row.policyname, v_row.cmd,
|
|
202
|
+
auth._fence_reason(v_row.with_check), v_row.with_check);
|
|
203
|
+
END IF;
|
|
204
|
+
END LOOP;
|
|
205
|
+
|
|
206
|
+
-- ---------------------------------------------------------------------
|
|
207
|
+
-- 3. Table coverage. THE HOLE A POLICY-LEVEL CHECK CANNOT SEE: a table with
|
|
208
|
+
-- a tenant column and RLS switched off has no rows in pg_policy to
|
|
209
|
+
-- inspect, and under the canonical blanket grants it is readable AND
|
|
210
|
+
-- writable across every tenant — strictly more open than the union
|
|
211
|
+
-- predicate this release removes.
|
|
212
|
+
--
|
|
213
|
+
-- Discovered FROM THE CATALOG, never from a caller-supplied list. The
|
|
214
|
+
-- author who forgets ENABLE ROW LEVEL SECURITY is the same author who
|
|
215
|
+
-- forgets the list entry, in the same migration.
|
|
216
|
+
-- ---------------------------------------------------------------------
|
|
217
|
+
FOR v_row IN
|
|
218
|
+
SELECT n.nspname AS schemaname, c.relname AS tablename,
|
|
219
|
+
c.relkind::text AS relkind, c.relrowsecurity,
|
|
220
|
+
COALESCE((SELECT true FROM unnest(COALESCE(c.reloptions,'{}')) o
|
|
221
|
+
WHERE lower(replace(o,' ','')) = 'security_invoker=true'), false) AS security_invoker
|
|
222
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
223
|
+
WHERE c.relkind = ANY('{r,p,v,m,f}')
|
|
224
|
+
AND n.nspname = ANY(p_schemas)
|
|
225
|
+
AND NOT (c.relname = ANY(p_exempt_tables))
|
|
226
|
+
AND NOT ((n.nspname || '.' || c.relname) = ANY(p_exempt_tables))
|
|
227
|
+
AND EXISTS (SELECT 1 FROM pg_attribute a
|
|
228
|
+
WHERE a.attrelid = c.oid AND a.attnum > 0
|
|
229
|
+
AND NOT a.attisdropped AND a.attname = ANY(p_tenant_columns))
|
|
230
|
+
ORDER BY n.nspname, c.relname
|
|
231
|
+
LOOP
|
|
232
|
+
IF v_row.relkind IN ('v','m','f') THEN
|
|
233
|
+
-- A view with security_invoker=true executes as the CALLER, so the
|
|
234
|
+
-- underlying table's RLS applies and the view is genuinely safe.
|
|
235
|
+
IF v_row.relkind = 'v' AND v_row.security_invoker THEN CONTINUE; END IF;
|
|
236
|
+
v_offenders := v_offenders || format(
|
|
237
|
+
'%s.%s exposes a tenant column but cannot enforce RLS (relkind=%s) — a materialized/foreign relation has no row policies at all, and a view without security_invoker=true runs as its OWNER and bypasses the underlying table''s policies',
|
|
238
|
+
v_row.schemaname, v_row.tablename, v_row.relkind);
|
|
239
|
+
ELSIF NOT v_row.relrowsecurity THEN
|
|
240
|
+
v_offenders := v_offenders || format(
|
|
241
|
+
'%s.%s has a tenant column but ROW LEVEL SECURITY is NOT ENABLED — with the canonical blanket grants this table is readable and writable across EVERY tenant, and no policy is ever consulted',
|
|
242
|
+
v_row.schemaname, v_row.tablename);
|
|
243
|
+
END IF;
|
|
244
|
+
END LOOP;
|
|
245
|
+
|
|
246
|
+
IF array_length(v_offenders, 1) > 0 THEN
|
|
247
|
+
RAISE EXCEPTION 'tenant_fence: % authenticated-pool policy/helper defect(s). A user session must reach exactly ONE tenant — the one the edge resolved from the request — and nothing else, ever.%',
|
|
248
|
+
array_length(v_offenders, 1),
|
|
249
|
+
E'\\n - ' || array_to_string(v_offenders, E'\\n - ')
|
|
250
|
+
USING ERRCODE = 'P0001',
|
|
251
|
+
HINT = 'Regenerate policies with @nodii/db-rls withRls() / generateRlsPolicy(), which emit "<col> IN (SELECT auth.session_tenant_ids())". Nodii-staff cross-tenant access is a different path entirely: the platform_ops pool + withPlatformOpsContext.';
|
|
252
|
+
END IF;
|
|
253
|
+
END
|
|
254
|
+
$fence_check$;
|
|
255
|
+
GRANT EXECUTE ON FUNCTION auth._fence_reason(text) TO PUBLIC;
|
|
256
|
+
`;
|
|
257
|
+
/** Grant for the gate. Split from the DDL for the same reason every other
|
|
258
|
+
* grant in this package is: the roles must exist before the GRANT runs, and
|
|
259
|
+
* on a fresh database they do not yet. */
|
|
260
|
+
export const TENANT_FENCE_GATE_GRANTS_SQL = `GRANT EXECUTE ON FUNCTION auth.assert_tenant_fence(text[], text[], text[], text[], boolean) TO nodii_services, authenticated;
|
|
261
|
+
`;
|
|
262
|
+
/** The statement `withUserContext` runs once per process on first authenticated
|
|
263
|
+
* checkout. Kept as a named constant so the wiring is greppable from the call
|
|
264
|
+
* site and the test-suite asserts the exact text. */
|
|
265
|
+
export const TENANT_FENCE_GATE_CALL_SQL = "SELECT auth.assert_tenant_fence()";
|
|
266
|
+
/**
|
|
267
|
+
* THE DELIVERY UNIT, whole: the resolver the policies reference, plus the gate
|
|
268
|
+
* that proves they reference it.
|
|
269
|
+
*
|
|
270
|
+
* These ship together because they fail together. A database with the resolver
|
|
271
|
+
* but no gate has a fence nobody checks; a database with the gate but no
|
|
272
|
+
* resolver cannot create the policies in the first place. Splitting them would
|
|
273
|
+
* create a state where a service is half-delivered and neither half complains,
|
|
274
|
+
* which is the condition this whole release exists to end.
|
|
275
|
+
*
|
|
276
|
+
* Every delivery path applies exactly this constant:
|
|
277
|
+
* - `applyAuthSchemaMigrations` (@nodii/db-rls)
|
|
278
|
+
* - `applySagaMigrations` (@nodii/saga)
|
|
279
|
+
* - `applyDsarMigrations` (@nodii/dsar)
|
|
280
|
+
* - `applyAuditChainMigrations` (@nodii/audit-chain)
|
|
281
|
+
* - the `nodii-db-rls install-fence` CLI, for a service on none of the above
|
|
282
|
+
*
|
|
283
|
+
* The three mirrors inline a byte-equal copy rather than importing it, to
|
|
284
|
+
* preserve their deliberate zero-dependency posture on `@nodii/db-rls`. That
|
|
285
|
+
* duplication is guarded by a byte-equality test in each mirror's suite, not by
|
|
286
|
+
* a comment asking people to remember.
|
|
287
|
+
*/
|
|
288
|
+
export function sessionFenceBootstrapSql(installSql) {
|
|
289
|
+
return `${installSql}${TENANT_FENCE_GATE_SQL}`;
|
|
290
|
+
}
|
|
291
|
+
//# sourceMappingURL=tenant-fence-gate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tenant-fence-gate.js","sourceRoot":"","sources":["../src/tenant-fence-gate.ts"],"names":[],"mappings":"AAAA,oEAAoE;AACpE,EAAE;AACF,uEAAuE;AACvE,gFAAgF;AAChF,0EAA0E;AAC1E,+DAA+D;AAC/D,EAAE;AACF,8EAA8E;AAC9E,mBAAmB;AACnB,EAAE;AACF,wEAAwE;AACxE,wEAAwE;AACxE,0EAA0E;AAC1E,6EAA6E;AAC7E,2EAA2E;AAC3E,2EAA2E;AAC3E,0CAA0C;AAC1C,EAAE;AACF,0EAA0E;AAC1E,+EAA+E;AAC/E,8EAA8E;AAC9E,+EAA+E;AAC/E,0BAA0B;AAC1B,EAAE;AACF,0DAA0D;AAC1D,EAAE;AACF,4EAA4E;AAC5E,8EAA8E;AAC9E,0EAA0E;AAC1E,8EAA8E;AAC9E,2EAA2E;AAC3E,8EAA8E;AAC9E,WAAW;AACX,EAAE;AACF,8EAA8E;AAC9E,8EAA8E;AAC9E,4EAA4E;AAC5E,8EAA8E;AAC9E,0EAA0E;AAC1E,6CAA6C;AAC7C,EAAE;AACF,mEAAmE;AACnE,EAAE;AACF,6EAA6E;AAC7E,8EAA8E;AAC9E,gFAAgF;AAChF,8EAA8E;AAC9E,6EAA6E;AAC7E,4CAA4C;AAE5C;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyLpC,CAAC;AAEF;;2CAE2C;AAC3C,MAAM,CAAC,MAAM,4BAA4B,GAAG;CAC3C,CAAC;AAEF;;sDAEsD;AACtD,MAAM,CAAC,MAAM,0BAA0B,GAAG,mCAAmC,CAAC;AAE9E;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,wBAAwB,CAAC,UAAkB;IACzD,OAAO,GAAG,UAAU,GAAG,qBAAqB,EAAE,CAAC;AACjD,CAAC"}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { DbRlsError } from "./types.js";
|
|
2
|
+
import type { RlsQuerier } from "./verify-rls.js";
|
|
3
|
+
/** Why one policy (or helper) failed the fence check. */
|
|
4
|
+
export type TenantFenceReason =
|
|
5
|
+
/** THE HOLE. The policy consults `auth.user_tenant_ids` directly — the
|
|
6
|
+
* membership union — instead of resolving through the session resolver, so
|
|
7
|
+
* it spans every tenant the principal belongs to. This is the shape that
|
|
8
|
+
* released a refund. */
|
|
9
|
+
"cross_tenant_union"
|
|
10
|
+
/** Permissive, granted to `authenticated`, with no tenant reference at all —
|
|
11
|
+
* `USING (true)` or equivalent. Every tenant, every row. */
|
|
12
|
+
| "unfenced_permissive"
|
|
13
|
+
/** The positive rule failing in the general case: there IS a predicate, but
|
|
14
|
+
* nothing in it resolves through `auth.session_tenant_ids()`. Covers the
|
|
15
|
+
* mangled-by-a-sweep case that an absence check would wave through. */
|
|
16
|
+
| "not_resolved_through_session"
|
|
17
|
+
/** `auth.session_tenant_ids()` is not installed, so every policy referencing
|
|
18
|
+
* it is broken. (In practice this surfaces at CREATE POLICY time, since
|
|
19
|
+
* Postgres validates the reference then — but a function dropped AFTER the
|
|
20
|
+
* policies were created lands here.) */
|
|
21
|
+
| "resolver_missing"
|
|
22
|
+
/** The resolver exists but does not RAISE on an unbound session — someone
|
|
23
|
+
* replaced it with a NULL-returning getter, restoring silent failure. */
|
|
24
|
+
| "resolver_not_loud"
|
|
25
|
+
/** The resolver exists but is not SECURITY DEFINER, so it cannot read the
|
|
26
|
+
* membership mirror and will fail with `permission denied` (or recurse into
|
|
27
|
+
* the mirror's own policy and die at stack depth). */
|
|
28
|
+
| "resolver_not_security_definer"
|
|
29
|
+
/** The DEGRADED, fence-only resolver is installed: the session tenant is
|
|
30
|
+
* accepted WITHOUT proving the principal holds it. Legitimate for a service
|
|
31
|
+
* with no membership mirror, but never something to be on by accident.
|
|
32
|
+
* Reported unless `allowDegradedResolver` is set. */
|
|
33
|
+
| "resolver_degraded"
|
|
34
|
+
/** `auth.user_tenant_ids` is still the bootstrap stub returning the empty
|
|
35
|
+
* set, so the STRICT resolver intersects to nothing and the whole
|
|
36
|
+
* authenticated pool is silently deny-all. (P0 drift 199feabc — crm's 430
|
|
37
|
+
* dead policies — turned into a loud failure.) */
|
|
38
|
+
| "membership_helper_is_stub"
|
|
39
|
+
/** THE HOLE A POLICY-LEVEL CHECK CANNOT SEE. The table has a `tenant_id`
|
|
40
|
+
* column but `relrowsecurity = false`, so no policy is ever consulted. Since
|
|
41
|
+
* the canonical grants are deliberately blanket —
|
|
42
|
+
* `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO
|
|
43
|
+
* authenticated`, plus an `ALTER DEFAULT PRIVILEGES` that arms every FUTURE
|
|
44
|
+
* table the same way — a table that simply never enables RLS is readable and
|
|
45
|
+
* writable across every tenant. It is STRICTLY MORE OPEN than the union
|
|
46
|
+
* predicate this release removes, and it passes any check that inspects
|
|
47
|
+
* `pg_policies`, because it has no rows there to inspect. */
|
|
48
|
+
| "rls_disabled_on_tenant_table"
|
|
49
|
+
/** A UNIQUE index on a tenant-scoped table that does NOT include the tenant
|
|
50
|
+
* column. RLS filters which rows you can SEE; it does not partition the
|
|
51
|
+
* uniqueness namespace. A caller-supplied key unique on its own lets one
|
|
52
|
+
* tenant collide with another's row — `INSERT … ON CONFLICT` then either
|
|
53
|
+
* raises 23505 (leaking existence) or takes an UPDATE path against a row the
|
|
54
|
+
* session cannot see. An idempotent-mint `SELECT` with no tenant predicate
|
|
55
|
+
* on such a key returns the other tenant's row outright. */
|
|
56
|
+
| "tenant_unscoped_unique_index"
|
|
57
|
+
/** A VIEW, MATERIALIZED VIEW, or FOREIGN TABLE exposing a tenant column to
|
|
58
|
+
* `authenticated`. A matview cannot have RLS AT ALL; a foreign table's
|
|
59
|
+
* policies are not enforced remotely; and a plain view without
|
|
60
|
+
* `security_invoker = true` executes with the VIEW OWNER's privileges, so
|
|
61
|
+
* the underlying table's RLS is bypassed entirely. Each is a routine
|
|
62
|
+
* migration artifact, and the justification for flagging an unprotected
|
|
63
|
+
* table applies verbatim: it is strictly more open than the union this
|
|
64
|
+
* release removes. */
|
|
65
|
+
| "unprotected_relation";
|
|
66
|
+
/** One policy (or helper) that failed the fence check. */
|
|
67
|
+
export interface TenantFenceOffender {
|
|
68
|
+
/** `schema.table`, or `auth.<fn>()` for a helper-level offender. */
|
|
69
|
+
object: string;
|
|
70
|
+
/** Policy name, or the helper function name. */
|
|
71
|
+
policy: string;
|
|
72
|
+
/** `SELECT` / `INSERT` / `UPDATE` / `DELETE` / `ALL`, or `-` for helpers. */
|
|
73
|
+
command: string;
|
|
74
|
+
reason: TenantFenceReason;
|
|
75
|
+
/** The deparsed expression that failed, straight from the catalog. */
|
|
76
|
+
expression: string | null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Thrown by {@link verifyTenantFence}. Carries the structured offender list so
|
|
80
|
+
* a `db:setup` gate can render a precise failure — and so a caller can tell
|
|
81
|
+
* "this is a stale service that needs regenerating" apart from "someone
|
|
82
|
+
* hand-wrote a cross-tenant policy in prod".
|
|
83
|
+
*/
|
|
84
|
+
export declare class TenantFenceError extends DbRlsError {
|
|
85
|
+
readonly offenders: readonly TenantFenceOffender[];
|
|
86
|
+
constructor(offenders: readonly TenantFenceOffender[]);
|
|
87
|
+
}
|
|
88
|
+
export interface VerifyTenantFenceOpts {
|
|
89
|
+
/** Schema(s) to check. Defaults to `["public"]`. */
|
|
90
|
+
schema?: string | readonly string[];
|
|
91
|
+
/** Restrict to these table names (bare or `schema.table`). Omit to check
|
|
92
|
+
* every table in `schema` that has an `authenticated` policy. */
|
|
93
|
+
tables?: readonly string[];
|
|
94
|
+
/**
|
|
95
|
+
* Policies exempt from the POSITIVE rule, as `table.policy_name` or just
|
|
96
|
+
* `policy_name`. Use sparingly, and argue it at the call site — the whole
|
|
97
|
+
* value of a positive assertion is that the exemptions are enumerated in one
|
|
98
|
+
* reviewable place instead of being implicit in a regex.
|
|
99
|
+
*
|
|
100
|
+
* The legitimate case is a policy that is genuinely not tenant-scoped: a
|
|
101
|
+
* public-listing row, a platform-global reference table exposed to
|
|
102
|
+
* `authenticated` (08-rls § 7.6), or a self-row policy keyed on
|
|
103
|
+
* `auth.get_user_id()` with no tenant dimension.
|
|
104
|
+
*/
|
|
105
|
+
exemptPolicies?: readonly string[];
|
|
106
|
+
/** Accept the degraded fence-only resolver. Defaults to `false`. */
|
|
107
|
+
allowDegradedResolver?: boolean;
|
|
108
|
+
/** Also verify the `auth.*` helper shapes. Defaults to `true`. */
|
|
109
|
+
checkHelpers?: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Also assert that every table carrying a tenant column has RLS ENABLED.
|
|
112
|
+
* Defaults to `true`. This is the check that catches the table a migration
|
|
113
|
+
* simply never protected — see `rls_disabled_on_tenant_table`.
|
|
114
|
+
*/
|
|
115
|
+
checkTableCoverage?: boolean;
|
|
116
|
+
/**
|
|
117
|
+
* Also flag UNIQUE indexes on tenant-scoped tables that omit the tenant
|
|
118
|
+
* column. Defaults to `true`.
|
|
119
|
+
*/
|
|
120
|
+
checkUniqueIndexes?: boolean;
|
|
121
|
+
/** An ADDITIONAL tenant column name to recognise, on top of
|
|
122
|
+
* {@link tenantColumns}. Kept for source compatibility. */
|
|
123
|
+
tenantColumn?: string;
|
|
124
|
+
/** Column names that mark a relation as tenant-scoped. Defaults to
|
|
125
|
+
* `["tenant_id", "org_id", "organization_id"]`. Keying coverage on a SINGLE
|
|
126
|
+
* configured name exempted every table using one of the others — including
|
|
127
|
+
* `org_id`, which `withRls` explicitly supports and ships a test for. */
|
|
128
|
+
tenantColumns?: readonly string[];
|
|
129
|
+
/**
|
|
130
|
+
* Tables exempt from the table-level checks, bare or `schema.table`.
|
|
131
|
+
*
|
|
132
|
+
* The honest case is a tenant-scoped table deliberately left RLS-free
|
|
133
|
+
* because it is owner-only and no non-owner role is granted on it. Argue it
|
|
134
|
+
* at the call site — this list is the record of every place the fleet has
|
|
135
|
+
* decided a tenant column does not imply a tenant boundary.
|
|
136
|
+
*/
|
|
137
|
+
exemptTables?: readonly string[];
|
|
138
|
+
}
|
|
139
|
+
/** Does this expression resolve through the session resolver? */
|
|
140
|
+
export declare function resolvesThroughSession(expr: string | null): boolean;
|
|
141
|
+
/** Does this expression consult the raw membership union? */
|
|
142
|
+
export declare function consultsMembershipUnion(expr: string | null): boolean;
|
|
143
|
+
/**
|
|
144
|
+
* Classify ONE authenticated policy. Exported so the test-suite can assert the
|
|
145
|
+
* classification directly without standing up every offending shape in the
|
|
146
|
+
* catalog, and so a caller can reuse the rule in a bespoke gate.
|
|
147
|
+
*
|
|
148
|
+
* The order matters: the two SPECIFIC diagnoses come first because they tell a
|
|
149
|
+
* reader what to do, and the general positive-rule failure is the fallback.
|
|
150
|
+
*/
|
|
151
|
+
export declare function classifyAuthPolicy(expr: string | null): TenantFenceReason | null;
|
|
152
|
+
/**
|
|
153
|
+
* Asserts that EVERY policy granted to `authenticated` resolves through
|
|
154
|
+
* `auth.session_tenant_ids()`, and that the resolver itself is installed, loud,
|
|
155
|
+
* and SECURITY DEFINER.
|
|
156
|
+
*
|
|
157
|
+
* @throws {TenantFenceError} listing every offender, with the deparsed
|
|
158
|
+
* expression straight from the catalog.
|
|
159
|
+
*/
|
|
160
|
+
export declare function verifyTenantFence(querier: RlsQuerier, opts?: VerifyTenantFenceOpts): Promise<void>;
|
|
161
|
+
/** The bootstrap stub is `SELECT NULL::uuid WHERE false` — any body that can
|
|
162
|
+
* never return a row. Matched structurally rather than by exact string so a
|
|
163
|
+
* reformatted copy is still caught. */
|
|
164
|
+
export declare function isStubMembershipHelper(prosrc: string): boolean;
|
|
165
|
+
//# sourceMappingURL=tenant-fence.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tenant-fence.d.ts","sourceRoot":"","sources":["../src/tenant-fence.ts"],"names":[],"mappings":"AAsDA,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAElD,yDAAyD;AACzD,MAAM,MAAM,iBAAiB;AAC3B;;;yBAGyB;AACvB,oBAAoB;AACtB;6DAC6D;GAC3D,qBAAqB;AACvB;;wEAEwE;GACtE,8BAA8B;AAChC;;;yCAGyC;GACvC,kBAAkB;AACpB;0EAC0E;GACxE,mBAAmB;AACrB;;uDAEuD;GACrD,+BAA+B;AACjC;;;sDAGsD;GACpD,mBAAmB;AACrB;;;mDAGmD;GACjD,2BAA2B;AAC7B;;;;;;;;8DAQ8D;GAC5D,8BAA8B;AAChC;;;;;;6DAM6D;GAC3D,8BAA8B;AAChC;;;;;;;uBAOuB;GACrB,sBAAsB,CAAC;AAE3B,0DAA0D;AAC1D,MAAM,WAAW,mBAAmB;IAClC,oEAAoE;IACpE,MAAM,EAAE,MAAM,CAAC;IACf,gDAAgD;IAChD,MAAM,EAAE,MAAM,CAAC;IACf,6EAA6E;IAC7E,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,sEAAsE;IACtE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED;;;;;GAKG;AACH,qBAAa,gBAAiB,SAAQ,UAAU;IAC9C,QAAQ,CAAC,SAAS,EAAE,SAAS,mBAAmB,EAAE,CAAC;gBACvC,SAAS,EAAE,SAAS,mBAAmB,EAAE;CAKtD;AAqCD,MAAM,WAAW,qBAAqB;IACpC,oDAAoD;IACpD,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAAC;IACpC;sEACkE;IAClE,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B;;;;;;;;;;OAUG;IACH,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,oEAAoE;IACpE,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,kEAAkE;IAClE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B;;;OAGG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B;gEAC4D;IAC5D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;8EAG0E;IAC1E,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC;AAmCD,iEAAiE;AACjE,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAEnE;AAED,6DAA6D;AAC7D,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAEpE;AAiBD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,GAAG,IAAI,GAClB,iBAAiB,GAAG,IAAI,CAO1B;AAED;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,UAAU,EACnB,IAAI,GAAE,qBAA0B,GAC/B,OAAO,CAAC,IAAI,CAAC,CAkHf;AAqVD;;wCAEwC;AACxC,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAE9D"}
|