@nodii/db-rls 0.12.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.
@@ -7,12 +7,15 @@
7
7
  // Subcommands:
8
8
  // migrate-gen <serviceName> [--out <dir>] [--stub]
9
9
  // verify-rls --tables <a,b,c> [--ref <x,y>] [--schema <name>] [--db <url>]
10
+ // install-fence [--db <url>] [--skip-grants]
11
+ // verify-fence [--db <url>] [--schema <name>] [--strict-only]
10
12
  //
11
13
  // (Filename kept as migrate-gen.mjs for the package.json `bin` mapping;
12
14
  // it is the single `nodii-db-rls` entrypoint, not migrate-gen only.)
13
15
 
14
16
  import { runMigrateGenCli } from "../dist/migrate-gen.js";
15
17
  import { runVerifyRlsCli } from "../dist/verify-rls-cli.js";
18
+ import { runInstallFenceCli, runVerifyFenceCli } from "../dist/fence-cli.js";
16
19
 
17
20
  const argv = process.argv.slice(2);
18
21
  const sub = argv[0];
@@ -22,6 +25,14 @@ if (sub === "verify-rls") {
22
25
  // Coverage failures (1) + usage (2) go to stderr; success (0) to stdout.
23
26
  (code === 0 ? console.log : console.error)(message);
24
27
  process.exit(code);
28
+ } else if (sub === "install-fence") {
29
+ const { code, message } = await runInstallFenceCli(argv.slice(1));
30
+ (code === 0 ? console.log : console.error)(message);
31
+ process.exit(code);
32
+ } else if (sub === "verify-fence") {
33
+ const { code, message } = await runVerifyFenceCli(argv.slice(1));
34
+ (code === 0 ? console.log : console.error)(message);
35
+ process.exit(code);
25
36
  } else {
26
37
  // migrate-gen (default + back-compat: runMigrateGenCli accepts the
27
38
  // leading "migrate-gen" token and prints usage on a missing serviceName).
@@ -4,7 +4,7 @@ import type { Pool, PoolClient } from "pg";
4
4
  * compliance requirement. Identical byte-equal payload across TS / Python
5
5
  * / Go ports (asserted by the parity-fence CI).
6
6
  */
7
- export declare const AUTH_SCHEMA_SQL = "CREATE SCHEMA IF NOT EXISTS auth;\n\nCREATE OR REPLACE FUNCTION auth.set_user_id(p_user_id uuid) RETURNS void\nLANGUAGE sql AS $$ SELECT set_config('app.user_id', p_user_id::text, true) $$;\n\nCREATE OR REPLACE FUNCTION auth.set_tenant_id(p_tenant_id uuid) RETURNS void\nLANGUAGE sql AS $$ SELECT set_config('app.tenant_id', p_tenant_id::text, true) $$;\n\nCREATE OR REPLACE FUNCTION auth.init(p_user_id uuid, p_tenant_id uuid) RETURNS void\nLANGUAGE sql AS $$\n SELECT set_config('app.user_id', p_user_id::text, true);\n SELECT set_config('app.tenant_id', p_tenant_id::text, true);\n$$;\n\nCREATE OR REPLACE FUNCTION auth.init_tenant(p_tenant_id uuid) RETURNS void\nLANGUAGE sql AS $$ SELECT set_config('app.tenant_id', p_tenant_id::text, true) $$;\n\nCREATE OR REPLACE FUNCTION auth.get_user_id() RETURNS uuid\nLANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('app.user_id', true), '')::uuid $$;\n\nCREATE OR REPLACE FUNCTION auth.get_tenant_id() RETURNS uuid\nLANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('app.tenant_id', true), '')::uuid $$;\n\n-- Stub variant. Replaced in-place by migrate-gen's 0001_user_tenant_ids.sql\n-- once the service has employees_mirror + customers_mirror tables.\nCREATE OR REPLACE FUNCTION auth.user_tenant_ids(p_user_id uuid) RETURNS SETOF uuid\nLANGUAGE sql STABLE AS $$ SELECT NULL::uuid WHERE false $$;\n";
7
+ export declare const AUTH_SCHEMA_SQL = "CREATE SCHEMA IF NOT EXISTS auth;\n\nCREATE OR REPLACE FUNCTION auth.set_user_id(p_user_id uuid) RETURNS void\nLANGUAGE sql AS $$ SELECT set_config('app.user_id', p_user_id::text, true) $$;\n\nCREATE OR REPLACE FUNCTION auth.set_tenant_id(p_tenant_id uuid) RETURNS void\nLANGUAGE sql AS $$ SELECT set_config('app.tenant_id', p_tenant_id::text, true) $$;\n\nCREATE OR REPLACE FUNCTION auth.init(p_user_id uuid, p_tenant_id uuid) RETURNS void\nLANGUAGE sql AS $$\n SELECT set_config('app.user_id', p_user_id::text, true);\n SELECT set_config('app.tenant_id', p_tenant_id::text, true);\n$$;\n\nCREATE OR REPLACE FUNCTION auth.init_tenant(p_tenant_id uuid) RETURNS void\nLANGUAGE sql AS $$ SELECT set_config('app.tenant_id', p_tenant_id::text, true) $$;\n\nCREATE OR REPLACE FUNCTION auth.get_user_id() RETURNS uuid\nLANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('app.user_id', true), '')::uuid $$;\n\nCREATE OR REPLACE FUNCTION auth.get_tenant_id() RETURNS uuid\nLANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('app.tenant_id', true), '')::uuid $$;\n\n-- Stub variant, installed ONLY IF ABSENT. Replaced in-place by migrate-gen's\n-- 0001_user_tenant_ids.sql once the service has employees_mirror +\n-- customers_mirror tables.\n--\n-- IT IS A GUARDED CREATE, NOT A `CREATE OR REPLACE`, AND THAT IS LOAD-BEARING.\n-- As an unguarded REPLACE this statement silently overwrote a service's REAL\n-- membership helper with the dead stub every time the auth schema was\n-- re-applied \u2014 turning a correctly-fenced service into a deny-all one with no\n-- error, which is the same fail-silent shape this release exists to remove.\n-- It also made `INSTALL_SESSION_RESOLVER_SQL` unable to classify: running\n-- immediately after this block, it would read the stub this block had just\n-- stamped and conclude EVERY service needed the degraded body.\n--\n-- A bootstrap default must never win against the real thing it is standing in\n-- for.\nDO $ensure_user_tenant_ids$\nBEGIN\n IF to_regprocedure('auth.user_tenant_ids(uuid)') IS NULL THEN\n EXECUTE $utid$CREATE FUNCTION auth.user_tenant_ids(p_user_id uuid) RETURNS SETOF uuid\nLANGUAGE sql STABLE AS $utid_body$ SELECT NULL::uuid WHERE false $utid_body$;$utid$;\n END IF;\nEND\n$ensure_user_tenant_ids$;\n";
8
8
  /**
9
9
  * `auth.session_tenant_ids()` — THE authenticated-pool resolver.
10
10
  *
@@ -124,6 +124,64 @@ export declare const SESSION_TENANT_IDS_DEGRADED_SQL = "CREATE OR REPLACE FUNCTI
124
124
  /** Grant for the resolver. Separate from {@link AUTH_SCHEMA_GRANTS_SQL} for the
125
125
  * same reason: the roles must exist before the GRANT runs. */
126
126
  export declare const SESSION_TENANT_IDS_GRANTS_SQL = "GRANT EXECUTE ON FUNCTION auth.session_tenant_ids() TO nodii_services, authenticated;\n";
127
+ /**
128
+ * THE DELIVERY UNIT. One idempotent, re-runnable, SELF-CLASSIFYING statement
129
+ * that installs whichever resolver body this database can actually support.
130
+ *
131
+ * WHY IT SELF-CLASSIFIES RATHER THAN TAKING A FLAG.
132
+ *
133
+ * The strict and degraded bodies are not interchangeable, and picking the wrong
134
+ * one is silent in BOTH directions:
135
+ *
136
+ * - strict on a service whose `auth.user_tenant_ids` is the bootstrap stub
137
+ * (`SELECT NULL::uuid WHERE false`) intersects to the EMPTY SET, so every
138
+ * authenticated policy denies every row. No error. Measured live on
139
+ * `nodii-dev-postgres`: crm carries 507 authenticated policies, and a strict
140
+ * install there takes ALL 507 dark, not just the 85 already dead.
141
+ * - degraded on a service that HAS a real membership mirror silently drops
142
+ * the principal check, which is the defence-in-depth this release exists to
143
+ * add.
144
+ *
145
+ * A caller-supplied boolean makes that choice a thing somebody has to know. The
146
+ * library shipped exactly such a list in prose — and it was WRONG BY SIX
147
+ * SERVICES on the first pass and had to be re-derived from the repos. A list
148
+ * that was wrong once, maintained by hand, across fifteen repos, on a
149
+ * fail-silent switch, is not a control. It is a countdown.
150
+ *
151
+ * So the choice is made from what the database ACTUALLY CONTAINS, at the moment
152
+ * of install, by the same structural test `isStubMembershipHelper` applies in
153
+ * TypeScript: a membership helper that is absent, or whose body can never
154
+ * return a row, cannot support the intersect. Nobody has to know anything, and
155
+ * there is no list to get wrong.
156
+ *
157
+ * D415 (LOCKED) is the governing decision: a single-tenant-session service
158
+ * whose `user_tenant_ids` is absent, stubbed or uncalled MAY fence on the
159
+ * session tenant — isolation-equivalent, forfeiting no cross-tenant protection.
160
+ * The predicate at every call site is IDENTICAL for both bodies, which is what
161
+ * makes this substitutable at all.
162
+ *
163
+ * IT IS ALSO D415's SUNSET, AUTOMATED. D415's exit path is "land a membership
164
+ * mirror → repoint the helper → swap in the strict body". Because this block
165
+ * re-reads the catalog on EVERY run and is `CREATE OR REPLACE`, the swap
166
+ * happens by itself on the next boot after the mirror lands. Nobody files a
167
+ * follow-up ticket, because there is nothing to follow up.
168
+ *
169
+ * THE ONE-WAY RATCHET. The condition is "absent OR stub", so a real helper
170
+ * ALWAYS yields strict. This block can upgrade degraded → strict; it can never
171
+ * downgrade strict → degraded. That direction is the one that matters: it means
172
+ * this delivery path cannot overwrite a correctly-strict service, and cannot
173
+ * overwrite crm's deliberately-degraded body with a strict one either, because
174
+ * crm's helper is still the stub and will keep classifying as such until it
175
+ * genuinely isn't.
176
+ *
177
+ * Byte-identical across the TS / Go / Python ports (asserted by the parity
178
+ * test). The nested dollar-quote tags are all distinct — `$fence_install$`
179
+ * outer, `$fence_strict$` / `$fence_degraded$` for the EXECUTE literals,
180
+ * `$session$` inside the bodies — so the statement survives every dollar-quote
181
+ * -aware splitter in the fleet, including the ones in @nodii/saga and
182
+ * @nodii/dsar that split the mirrors' DDL on `;\n`.
183
+ */
184
+ export declare const INSTALL_SESSION_RESOLVER_SQL: string;
127
185
  /**
128
186
  * Grants for the 7 helper functions. Emitted separately because they
129
187
  * reference roles that must exist before the GRANT runs — applied last
@@ -154,6 +212,34 @@ export declare function applyAuthSchemaMigrations(client: AnyExecutor, opts?: {
154
212
  includeGrants?: boolean;
155
213
  degradedSessionResolver?: boolean;
156
214
  }): Promise<void>;
215
+ /**
216
+ * THE WHOLE DELIVERY UNIT AS ONE STRING — resolver + gate, self-classifying,
217
+ * idempotent, re-runnable, and safe to apply on every boot.
218
+ *
219
+ * This is what the three mirrors inline byte-equal, what the CLI applies, and
220
+ * what a service with no other delivery path should call directly from its boot
221
+ * sequence, BEFORE any code that opens an authenticated transaction:
222
+ *
223
+ * import { applySessionFenceBootstrap } from "@nodii/db-rls";
224
+ * await applySessionFenceBootstrap(ownerSql);
225
+ *
226
+ * Grants are separate (as everywhere else in this package) because the runtime
227
+ * roles must exist before a GRANT naming them can run, and on a fresh database
228
+ * they do not yet.
229
+ */
230
+ export declare const SESSION_FENCE_BOOTSTRAP_SQL: string;
231
+ /**
232
+ * Apply {@link SESSION_FENCE_BOOTSTRAP_SQL} against an owner-privileged client.
233
+ *
234
+ * Idempotent and safe on every boot: the resolver and gate are
235
+ * `CREATE OR REPLACE`, the schema is `IF NOT EXISTS`, and the body selection
236
+ * re-derives from the catalog each run — so a service that lands a membership
237
+ * mirror upgrades itself from the degraded body to the strict one on its next
238
+ * restart, with no migration and no policy change (D415's exit path).
239
+ */
240
+ export declare function applySessionFenceBootstrap(client: AnyExecutor, opts?: {
241
+ includeGrants?: boolean;
242
+ }): Promise<void>;
157
243
  /**
158
244
  * Composite full DDL — the schema + helpers + grants, joined.
159
245
  * Provided for migrate-gen + parity-fence byte-equality.
@@ -1 +1 @@
1
- {"version":3,"file":"auth-schema.d.ts","sourceRoot":"","sources":["../src/auth-schema.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,IAAI,CAAC;AAE3C;;;;GAIG;AACH,eAAO,MAAM,eAAe,s1CA2B3B,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AACH,eAAO,MAAM,sBAAsB,iqCAuBlC,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,kCAAkC,yCACP,CAAC;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,eAAO,MAAM,+BAA+B,isCAwB3C,CAAC;AAEF;+DAC+D;AAC/D,eAAO,MAAM,6BAA6B,4FACzC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,8oBAQlC,CAAC;AAEF;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACtC;AAED,KAAK,WAAW,GAAG,IAAI,GAAG,UAAU,GAAG,WAAW,CAAC;AAEnD;;;;;;;;;;;GAWG;AACH,wBAAsB,yBAAyB,CAC7C,MAAM,EAAE,WAAW,EACnB,IAAI,GAAE;IAAE,aAAa,CAAC,EAAE,OAAO,CAAC;IAAC,uBAAuB,CAAC,EAAE,OAAO,CAAA;CAAO,GACxE,OAAO,CAAC,IAAI,CAAC,CAqBf;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,GAAE;IAAE,aAAa,CAAC,EAAE,OAAO,CAAA;CAAO,GACrC,MAAM,CAKR"}
1
+ {"version":3,"file":"auth-schema.d.ts","sourceRoot":"","sources":["../src/auth-schema.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,IAAI,CAAC;AAO3C;;;;GAIG;AACH,eAAO,MAAM,eAAe,0uEA8C3B,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AACH,eAAO,MAAM,sBAAsB,iqCAuBlC,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,kCAAkC,yCACP,CAAC;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,eAAO,MAAM,+BAA+B,isCAwB3C,CAAC;AAEF;+DAC+D;AAC/D,eAAO,MAAM,6BAA6B,4FACzC,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AACH,eAAO,MAAM,4BAA4B,QA4BxC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,8oBAQlC,CAAC;AAEF;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACtC;AAED,KAAK,WAAW,GAAG,IAAI,GAAG,UAAU,GAAG,WAAW,CAAC;AAEnD;;;;;;;;;;;GAWG;AACH,wBAAsB,yBAAyB,CAC7C,MAAM,EAAE,WAAW,EACnB,IAAI,GAAE;IAAE,aAAa,CAAC,EAAE,OAAO,CAAC;IAAC,uBAAuB,CAAC,EAAE,OAAO,CAAA;CAAO,GACxE,OAAO,CAAC,IAAI,CAAC,CAgCf;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,2BAA2B,EAAE,MAEzC,CAAC;AAEF;;;;;;;;GAQG;AACH,wBAAsB,0BAA0B,CAC9C,MAAM,EAAE,WAAW,EACnB,IAAI,GAAE;IAAE,aAAa,CAAC,EAAE,OAAO,CAAA;CAAO,GACrC,OAAO,CAAC,IAAI,CAAC,CAOf;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,GAAE;IAAE,aAAa,CAAC,EAAE,OAAO,CAAA;CAAO,GACrC,MAAM,CAKR"}
@@ -19,6 +19,7 @@
19
19
  // fails closed if a service applies the helpers but hasn't yet emitted
20
20
  // the real per-service helper via `migrate-gen`. Once `migrate-gen` ships
21
21
  // the real helper, `CREATE OR REPLACE` replaces this stub in place.
22
+ import { sessionFenceBootstrapSql, TENANT_FENCE_GATE_GRANTS_SQL, TENANT_FENCE_GATE_SQL, } from "./tenant-fence-gate.js";
22
23
  /**
23
24
  * Canonical SQL string exported as a module-level constant per the R1
24
25
  * compliance requirement. Identical byte-equal payload across TS / Python
@@ -47,10 +48,29 @@ LANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('app.user_id', true), ''
47
48
  CREATE OR REPLACE FUNCTION auth.get_tenant_id() RETURNS uuid
48
49
  LANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('app.tenant_id', true), '')::uuid $$;
49
50
 
50
- -- Stub variant. Replaced in-place by migrate-gen's 0001_user_tenant_ids.sql
51
- -- once the service has employees_mirror + customers_mirror tables.
52
- CREATE OR REPLACE FUNCTION auth.user_tenant_ids(p_user_id uuid) RETURNS SETOF uuid
53
- LANGUAGE sql STABLE AS $$ SELECT NULL::uuid WHERE false $$;
51
+ -- Stub variant, installed ONLY IF ABSENT. Replaced in-place by migrate-gen's
52
+ -- 0001_user_tenant_ids.sql once the service has employees_mirror +
53
+ -- customers_mirror tables.
54
+ --
55
+ -- IT IS A GUARDED CREATE, NOT A \`CREATE OR REPLACE\`, AND THAT IS LOAD-BEARING.
56
+ -- As an unguarded REPLACE this statement silently overwrote a service's REAL
57
+ -- membership helper with the dead stub every time the auth schema was
58
+ -- re-applied — turning a correctly-fenced service into a deny-all one with no
59
+ -- error, which is the same fail-silent shape this release exists to remove.
60
+ -- It also made \`INSTALL_SESSION_RESOLVER_SQL\` unable to classify: running
61
+ -- immediately after this block, it would read the stub this block had just
62
+ -- stamped and conclude EVERY service needed the degraded body.
63
+ --
64
+ -- A bootstrap default must never win against the real thing it is standing in
65
+ -- for.
66
+ DO $ensure_user_tenant_ids$
67
+ BEGIN
68
+ IF to_regprocedure('auth.user_tenant_ids(uuid)') IS NULL THEN
69
+ EXECUTE $utid$CREATE FUNCTION auth.user_tenant_ids(p_user_id uuid) RETURNS SETOF uuid
70
+ LANGUAGE sql STABLE AS $utid_body$ SELECT NULL::uuid WHERE false $utid_body$;$utid$;
71
+ END IF;
72
+ END
73
+ $ensure_user_tenant_ids$;
54
74
  `;
55
75
  /**
56
76
  * `auth.session_tenant_ids()` — THE authenticated-pool resolver.
@@ -219,6 +239,92 @@ $session$;
219
239
  * same reason: the roles must exist before the GRANT runs. */
220
240
  export const SESSION_TENANT_IDS_GRANTS_SQL = `GRANT EXECUTE ON FUNCTION auth.session_tenant_ids() TO nodii_services, authenticated;
221
241
  `;
242
+ /**
243
+ * THE DELIVERY UNIT. One idempotent, re-runnable, SELF-CLASSIFYING statement
244
+ * that installs whichever resolver body this database can actually support.
245
+ *
246
+ * WHY IT SELF-CLASSIFIES RATHER THAN TAKING A FLAG.
247
+ *
248
+ * The strict and degraded bodies are not interchangeable, and picking the wrong
249
+ * one is silent in BOTH directions:
250
+ *
251
+ * - strict on a service whose `auth.user_tenant_ids` is the bootstrap stub
252
+ * (`SELECT NULL::uuid WHERE false`) intersects to the EMPTY SET, so every
253
+ * authenticated policy denies every row. No error. Measured live on
254
+ * `nodii-dev-postgres`: crm carries 507 authenticated policies, and a strict
255
+ * install there takes ALL 507 dark, not just the 85 already dead.
256
+ * - degraded on a service that HAS a real membership mirror silently drops
257
+ * the principal check, which is the defence-in-depth this release exists to
258
+ * add.
259
+ *
260
+ * A caller-supplied boolean makes that choice a thing somebody has to know. The
261
+ * library shipped exactly such a list in prose — and it was WRONG BY SIX
262
+ * SERVICES on the first pass and had to be re-derived from the repos. A list
263
+ * that was wrong once, maintained by hand, across fifteen repos, on a
264
+ * fail-silent switch, is not a control. It is a countdown.
265
+ *
266
+ * So the choice is made from what the database ACTUALLY CONTAINS, at the moment
267
+ * of install, by the same structural test `isStubMembershipHelper` applies in
268
+ * TypeScript: a membership helper that is absent, or whose body can never
269
+ * return a row, cannot support the intersect. Nobody has to know anything, and
270
+ * there is no list to get wrong.
271
+ *
272
+ * D415 (LOCKED) is the governing decision: a single-tenant-session service
273
+ * whose `user_tenant_ids` is absent, stubbed or uncalled MAY fence on the
274
+ * session tenant — isolation-equivalent, forfeiting no cross-tenant protection.
275
+ * The predicate at every call site is IDENTICAL for both bodies, which is what
276
+ * makes this substitutable at all.
277
+ *
278
+ * IT IS ALSO D415's SUNSET, AUTOMATED. D415's exit path is "land a membership
279
+ * mirror → repoint the helper → swap in the strict body". Because this block
280
+ * re-reads the catalog on EVERY run and is `CREATE OR REPLACE`, the swap
281
+ * happens by itself on the next boot after the mirror lands. Nobody files a
282
+ * follow-up ticket, because there is nothing to follow up.
283
+ *
284
+ * THE ONE-WAY RATCHET. The condition is "absent OR stub", so a real helper
285
+ * ALWAYS yields strict. This block can upgrade degraded → strict; it can never
286
+ * downgrade strict → degraded. That direction is the one that matters: it means
287
+ * this delivery path cannot overwrite a correctly-strict service, and cannot
288
+ * overwrite crm's deliberately-degraded body with a strict one either, because
289
+ * crm's helper is still the stub and will keep classifying as such until it
290
+ * genuinely isn't.
291
+ *
292
+ * Byte-identical across the TS / Go / Python ports (asserted by the parity
293
+ * test). The nested dollar-quote tags are all distinct — `$fence_install$`
294
+ * outer, `$fence_strict$` / `$fence_degraded$` for the EXECUTE literals,
295
+ * `$session$` inside the bodies — so the statement survives every dollar-quote
296
+ * -aware splitter in the fleet, including the ones in @nodii/saga and
297
+ * @nodii/dsar that split the mirrors' DDL on `;\n`.
298
+ */
299
+ export const INSTALL_SESSION_RESOLVER_SQL = `CREATE SCHEMA IF NOT EXISTS auth;
300
+ DO $fence_install$
301
+ DECLARE
302
+ v_src text;
303
+ v_is_stub boolean;
304
+ BEGIN
305
+ SELECT p.prosrc INTO v_src
306
+ FROM pg_proc p
307
+ JOIN pg_namespace n ON n.oid = p.pronamespace
308
+ WHERE n.nspname = 'auth' AND p.proname = 'user_tenant_ids'
309
+ LIMIT 1;
310
+
311
+ -- Structural, not an exact-string compare: the bootstrap stub is
312
+ -- \`SELECT NULL::uuid WHERE false\` — an unsatisfiable WHERE and no FROM. A
313
+ -- reformatted or re-indented copy of the same dead body still classifies as a
314
+ -- stub. Mirrors isStubMembershipHelper() in tenant-fence.ts exactly; the two
315
+ -- are asserted equivalent in the test-suite so a fix to one cannot leave the
316
+ -- other behind.
317
+ v_is_stub := v_src IS NULL
318
+ OR (v_src ~* 'where\\s+false' AND v_src !~* 'from\\s+\\w');
319
+
320
+ IF v_is_stub THEN
321
+ EXECUTE $fence_degraded$${SESSION_TENANT_IDS_DEGRADED_SQL.trimEnd()}$fence_degraded$;
322
+ ELSE
323
+ EXECUTE $fence_strict$${SESSION_TENANT_IDS_SQL.trimEnd()}$fence_strict$;
324
+ END IF;
325
+ END
326
+ $fence_install$;
327
+ `;
222
328
  /**
223
329
  * Grants for the 7 helper functions. Emitted separately because they
224
330
  * reference roles that must exist before the GRANT runs — applied last
@@ -257,12 +363,56 @@ export async function applyAuthSchemaMigrations(client, opts = {}) {
257
363
  // CREATE POLICY time, so the migration itself fails with `function
258
364
  // auth.session_tenant_ids() does not exist`. Installing both together makes
259
365
  // that unreachable.
366
+ //
367
+ // WHICH BODY IS NOT A DECISION THIS FUNCTION MAKES. It is derived from the
368
+ // membership helper actually present in THIS database — see
369
+ // INSTALL_SESSION_RESOLVER_SQL. `degradedSessionResolver` remains as an
370
+ // explicit override for the one case derivation cannot cover: a service that
371
+ // has a real-looking helper but knows it is not yet populated. Passing it is
372
+ // a deliberate, argued act; the default is derivation, so a caller who knows
373
+ // nothing gets the right body.
260
374
  await exec.query(opts.degradedSessionResolver
261
375
  ? SESSION_TENANT_IDS_DEGRADED_SQL
262
- : SESSION_TENANT_IDS_SQL);
376
+ : INSTALL_SESSION_RESOLVER_SQL);
377
+ // The gate ships with the resolver, always. See sessionFenceBootstrapSql.
378
+ await exec.query(TENANT_FENCE_GATE_SQL);
263
379
  if (includeGrants) {
264
380
  await exec.query(AUTH_SCHEMA_GRANTS_SQL);
265
381
  await exec.query(SESSION_TENANT_IDS_GRANTS_SQL);
382
+ await exec.query(TENANT_FENCE_GATE_GRANTS_SQL);
383
+ }
384
+ }
385
+ /**
386
+ * THE WHOLE DELIVERY UNIT AS ONE STRING — resolver + gate, self-classifying,
387
+ * idempotent, re-runnable, and safe to apply on every boot.
388
+ *
389
+ * This is what the three mirrors inline byte-equal, what the CLI applies, and
390
+ * what a service with no other delivery path should call directly from its boot
391
+ * sequence, BEFORE any code that opens an authenticated transaction:
392
+ *
393
+ * import { applySessionFenceBootstrap } from "@nodii/db-rls";
394
+ * await applySessionFenceBootstrap(ownerSql);
395
+ *
396
+ * Grants are separate (as everywhere else in this package) because the runtime
397
+ * roles must exist before a GRANT naming them can run, and on a fresh database
398
+ * they do not yet.
399
+ */
400
+ export const SESSION_FENCE_BOOTSTRAP_SQL = sessionFenceBootstrapSql(INSTALL_SESSION_RESOLVER_SQL);
401
+ /**
402
+ * Apply {@link SESSION_FENCE_BOOTSTRAP_SQL} against an owner-privileged client.
403
+ *
404
+ * Idempotent and safe on every boot: the resolver and gate are
405
+ * `CREATE OR REPLACE`, the schema is `IF NOT EXISTS`, and the body selection
406
+ * re-derives from the catalog each run — so a service that lands a membership
407
+ * mirror upgrades itself from the degraded body to the strict one on its next
408
+ * restart, with no migration and no policy change (D415's exit path).
409
+ */
410
+ export async function applySessionFenceBootstrap(client, opts = {}) {
411
+ const exec = client;
412
+ await exec.query(SESSION_FENCE_BOOTSTRAP_SQL);
413
+ if (opts.includeGrants ?? false) {
414
+ await exec.query(SESSION_TENANT_IDS_GRANTS_SQL);
415
+ await exec.query(TENANT_FENCE_GATE_GRANTS_SQL);
266
416
  }
267
417
  }
268
418
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"auth-schema.js","sourceRoot":"","sources":["../src/auth-schema.ts"],"names":[],"mappings":"AAAA,wEAAwE;AACxE,EAAE;AACF,oEAAoE;AACpE,qEAAqE;AACrE,2BAA2B;AAC3B,EAAE;AACF,mEAAmE;AACnE,qBAAqB;AACrB,EAAE;AACF,4EAA4E;AAC5E,8EAA8E;AAC9E,qEAAqE;AACrE,4FAA4F;AAC5F,8DAA8D;AAC9D,8DAA8D;AAC9D,0EAA0E;AAC1E,EAAE;AACF,yEAAyE;AACzE,uEAAuE;AACvE,0EAA0E;AAC1E,oEAAoE;AAIpE;;;;GAIG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2B9B,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG;;;;;;;;;;;;;;;;;;;;;;;CAuBrC,CAAC;AAEF;;;;;GAKG;AACH,MAAM,CAAC,MAAM,kCAAkC,GAC7C,sCAAsC,CAAC;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,MAAM,CAAC,MAAM,+BAA+B,GAAG;;;;;;;OAOxC,kCAAkC;;;;;;;;;;;;;;;;;CAiBxC,CAAC;AAEF;+DAC+D;AAC/D,MAAM,CAAC,MAAM,6BAA6B,GAAG;CAC5C,CAAC;AAEF;;;;GAIG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG;;;;;;;;CAQrC,CAAC;AAYF;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC7C,MAAmB,EACnB,OAAuE,EAAE;IAEzE,MAAM,aAAa,GAAG,IAAI,CAAC,aAAa,IAAI,IAAI,CAAC;IACjD,wEAAwE;IACxE,2CAA2C;IAC3C,MAAM,IAAI,GAAG,MAAqB,CAAC;IACnC,MAAM,IAAI,CAAC,KAAK,CAAC,eAAe,CAAC,CAAC;IAClC,0EAA0E;IAC1E,+EAA+E;IAC/E,yEAAyE;IACzE,mEAAmE;IACnE,4EAA4E;IAC5E,oBAAoB;IACpB,MAAM,IAAI,CAAC,KAAK,CACd,IAAI,CAAC,uBAAuB;QAC1B,CAAC,CAAC,+BAA+B;QACjC,CAAC,CAAC,sBAAsB,CAC3B,CAAC;IACF,IAAI,aAAa,EAAE,CAAC;QAClB,MAAM,IAAI,CAAC,KAAK,CAAC,sBAAsB,CAAC,CAAC;QACzC,MAAM,IAAI,CAAC,KAAK,CAAC,6BAA6B,CAAC,CAAC;IAClD,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAC/B,OAAoC,EAAE;IAEtC,MAAM,aAAa,GAAG,IAAI,CAAC,aAAa,IAAI,IAAI,CAAC;IACjD,OAAO,aAAa;QAClB,CAAC,CAAC,GAAG,eAAe,KAAK,sBAAsB,EAAE;QACjD,CAAC,CAAC,eAAe,CAAC;AACtB,CAAC"}
1
+ {"version":3,"file":"auth-schema.js","sourceRoot":"","sources":["../src/auth-schema.ts"],"names":[],"mappings":"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"}
package/dist/context.d.ts CHANGED
@@ -1,11 +1,16 @@
1
1
  import { type OwnerContextOpts } from "./migrate-guard.js";
2
2
  import { type AutoDispatchOpts, type PgTx, type PlatformOpsContextOpts, type SystemContextOpts, type UserContextOpts, type UserMembershipContextOpts } from "./types.js";
3
+ /** Test-only. Not exported through the public barrel. */
4
+ export declare function _resetFenceGateForTests(): void;
3
5
  /**
4
6
  * withUserContext — opens a tx on the `authenticated` pool, sets
5
7
  * `app.user_id` + `app.tenant_id` (+ optional `app.source_ip`) via
6
8
  * `set_config(..., true)` tx-scoped GUCs, yields the tx, commits/rolls
7
9
  * back per the PgPool contract.
8
10
  *
11
+ * On the FIRST call in a process it also runs the tenant-fence gate (see
12
+ * {@link assertFenceOnce}) and refuses to yield the tx if the fence is broken.
13
+ *
9
14
  * Throws `DbRlsNotInitialized` if `initDbRls` hasn't run.
10
15
  * Wraps any error thrown by `fn` in `TransactionRolledBack`.
11
16
  */
@@ -1 +1 @@
1
- {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AASA,OAAO,EAEL,KAAK,gBAAgB,EACtB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,KAAK,gBAAgB,EAGrB,KAAK,IAAI,EACT,KAAK,sBAAsB,EAC3B,KAAK,iBAAiB,EAEtB,KAAK,eAAe,EACpB,KAAK,yBAAyB,EAC/B,MAAM,YAAY,CAAC;AAEpB;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CAAC,CAAC,EACrC,IAAI,EAAE,eAAe,EACrB,EAAE,EAAE,CAAC,EAAE,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,GAC3B,OAAO,CAAC,CAAC,CAAC,CAyBZ;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAsB,yBAAyB,CAAC,CAAC,EAC/C,KAAK,EAAE,yBAAyB,EAChC,GAAG,EAAE,CAAC,EAAE,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,GAC5B,OAAO,CAAC,CAAC,CAAC,CAEZ;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CAAC,CAAC,EACvC,IAAI,EAAE,iBAAiB,EACvB,EAAE,EAAE,CAAC,EAAE,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,GAC3B,OAAO,CAAC,CAAC,CAAC,CAUZ;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,gBAAgB,CAAC,CAAC,EACtC,QAAQ,EAAE,gBAAgB,GAAG,CAAC,CAAC,EAAE,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,EACvD,OAAO,CAAC,EAAE,CAAC,EAAE,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,GACjC,OAAO,CAAC,CAAC,CAAC,CAiBZ;AAiBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAsB,sBAAsB,CAAC,CAAC,EAC5C,IAAI,EAAE,sBAAsB,EAC5B,EAAE,EAAE,CAAC,EAAE,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,GAC3B,OAAO,CAAC,CAAC,CAAC,CAYZ;AAMD,4DAA4D;AAC5D,wBAAgB,8CAA8C,IAAI,IAAI,CAErE;AAED;;;;;;;;;GASG;AACH,wBAAsB,eAAe,CAAC,CAAC,EACrC,IAAI,EAAE,sBAAsB,EAC5B,EAAE,EAAE,CAAC,EAAE,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,GAC3B,OAAO,CAAC,CAAC,CAAC,CAWZ;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,YAAY,CAAC,CAAC,EAClC,IAAI,EAAE,gBAAgB,EACtB,EAAE,EAAE,CAAC,EAAE,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,GAC3B,OAAO,CAAC,CAAC,CAAC,CAeZ"}
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AASA,OAAO,EAEL,KAAK,gBAAgB,EACtB,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EACL,KAAK,gBAAgB,EAGrB,KAAK,IAAI,EACT,KAAK,sBAAsB,EAC3B,KAAK,iBAAiB,EAEtB,KAAK,eAAe,EACpB,KAAK,yBAAyB,EAC/B,MAAM,YAAY,CAAC;AAepB,yDAAyD;AACzD,wBAAgB,uBAAuB,IAAI,IAAI,CAE9C;AAyCD;;;;;;;;;;;GAWG;AACH,wBAAsB,eAAe,CAAC,CAAC,EACrC,IAAI,EAAE,eAAe,EACrB,EAAE,EAAE,CAAC,EAAE,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,GAC3B,OAAO,CAAC,CAAC,CAAC,CAmCZ;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAsB,yBAAyB,CAAC,CAAC,EAC/C,KAAK,EAAE,yBAAyB,EAChC,GAAG,EAAE,CAAC,EAAE,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,GAC5B,OAAO,CAAC,CAAC,CAAC,CAEZ;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CAAC,CAAC,EACvC,IAAI,EAAE,iBAAiB,EACvB,EAAE,EAAE,CAAC,EAAE,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,GAC3B,OAAO,CAAC,CAAC,CAAC,CAUZ;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,gBAAgB,CAAC,CAAC,EACtC,QAAQ,EAAE,gBAAgB,GAAG,CAAC,CAAC,EAAE,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,EACvD,OAAO,CAAC,EAAE,CAAC,EAAE,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,GACjC,OAAO,CAAC,CAAC,CAAC,CAiBZ;AAiBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAsB,sBAAsB,CAAC,CAAC,EAC5C,IAAI,EAAE,sBAAsB,EAC5B,EAAE,EAAE,CAAC,EAAE,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,GAC3B,OAAO,CAAC,CAAC,CAAC,CAYZ;AAMD,4DAA4D;AAC5D,wBAAgB,8CAA8C,IAAI,IAAI,CAErE;AAED;;;;;;;;;GASG;AACH,wBAAsB,eAAe,CAAC,CAAC,EACrC,IAAI,EAAE,sBAAsB,EAC5B,EAAE,EAAE,CAAC,EAAE,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,GAC3B,OAAO,CAAC,CAAC,CAAC,CAWZ;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,YAAY,CAAC,CAAC,EAClC,IAAI,EAAE,gBAAgB,EACtB,EAAE,EAAE,CAAC,EAAE,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,GAC3B,OAAO,CAAC,CAAC,CAAC,CAeZ"}
package/dist/context.js CHANGED
@@ -7,13 +7,71 @@
7
7
  import { requirePool } from "./init.js";
8
8
  import { recordOwnerContextInvocation } from "./metrics.js";
9
9
  import { assertMigrateAllowed, } from "./migrate-guard.js";
10
+ import { TENANT_FENCE_GATE_CALL_SQL } from "./tenant-fence-gate.js";
10
11
  import { CrossTenantSessionRefused, MissingTenantContext, TransactionRolledBack, } from "./types.js";
12
+ /**
13
+ * Process-local latch for the first-use fence gate. `null` until the gate has
14
+ * run; thereafter the settled promise, so the check costs one round-trip per
15
+ * PROCESS rather than one per transaction.
16
+ *
17
+ * A REJECTED promise is retained deliberately. A broken fence is not a
18
+ * transient condition — it is a property of the schema — so re-checking on
19
+ * every subsequent request would turn one clear failure into a storm of
20
+ * identical ones while adding a catalog scan to each. The service stays down
21
+ * and says the same thing every time, which is the intended behaviour.
22
+ */
23
+ let fenceGate = null;
24
+ /** Test-only. Not exported through the public barrel. */
25
+ export function _resetFenceGateForTests() {
26
+ fenceGate = null;
27
+ }
28
+ /**
29
+ * THE GATE. Runs `auth.assert_tenant_fence()` once per process, on the first
30
+ * authenticated-pool checkout.
31
+ *
32
+ * WHY HERE, AND NOT IN A DEPLOY SCRIPT.
33
+ *
34
+ * The obvious home is a `db:verify` step in each service's deploy. It does not
35
+ * work, and this is measured rather than argued: SIX of the ten services this
36
+ * release has to reach — billing, kyc, notification, task-tracking, tenant and
37
+ * geo — have NO database step in `deploy-dev.yml` at all. Their `db:setup`
38
+ * runs in `ci.yml` against a throwaway Postgres. A gate wired there is green
39
+ * forever on the services that need it most, and reported as a pass.
40
+ *
41
+ * WHY NOT `initDbRls`. Boot order forbids it. billing runs `applySagaMigrations`
42
+ * and `applyDsarMigrations` at boot, AFTER its pools are constructed — a check
43
+ * at `initDbRls` time would fire against a schema that is about to be fixed
44
+ * two statements later and fail every deploy.
45
+ *
46
+ * First-authenticated-use is the earliest point that is both after all boot
47
+ * migrations and before any tenant row is served, and it is reached by
48
+ * construction: there is no way to open an authenticated transaction without
49
+ * passing through here. That is the "cannot forget to opt in" property. It is
50
+ * not a flag, a config key, or a script somebody adds to a pipeline — it is the
51
+ * function you must call to get the connection.
52
+ *
53
+ * WHY IT IS NOT THE `verifyRls` SHAPE. `verifyRls` takes `requiredTables` from
54
+ * the caller, and the author who forgets `ENABLE ROW LEVEL SECURITY` forgets
55
+ * the list entry in the same migration — so it sits wired and green while
56
+ * unprotected tables accumulate behind it. `auth.assert_tenant_fence()` asks
57
+ * the catalog which tables carry a tenant column. Nobody maintains a list,
58
+ * so nobody can omit the entry that mattered.
59
+ */
60
+ async function assertFenceOnce(tx) {
61
+ if (fenceGate === null) {
62
+ fenceGate = tx.exec(TENANT_FENCE_GATE_CALL_SQL);
63
+ }
64
+ return fenceGate;
65
+ }
11
66
  /**
12
67
  * withUserContext — opens a tx on the `authenticated` pool, sets
13
68
  * `app.user_id` + `app.tenant_id` (+ optional `app.source_ip`) via
14
69
  * `set_config(..., true)` tx-scoped GUCs, yields the tx, commits/rolls
15
70
  * back per the PgPool contract.
16
71
  *
72
+ * On the FIRST call in a process it also runs the tenant-fence gate (see
73
+ * {@link assertFenceOnce}) and refuses to yield the tx if the fence is broken.
74
+ *
17
75
  * Throws `DbRlsNotInitialized` if `initDbRls` hasn't run.
18
76
  * Wraps any error thrown by `fn` in `TransactionRolledBack`.
19
77
  */
@@ -38,6 +96,16 @@ export async function withUserContext(opts, fn) {
38
96
  if (opts.sourceIp !== undefined) {
39
97
  await tx.setConfig("app.source_ip", opts.sourceIp);
40
98
  }
99
+ // BEFORE `fn`, deliberately. The point of the gate is that no tenant row is
100
+ // served through a broken fence — running it afterwards would mean the
101
+ // first request reads cross-tenant data and THEN reports the problem.
102
+ //
103
+ // NOT wrapped in TransactionRolledBack: that wrapper exists to mark "the
104
+ // caller's own code threw". This is the library refusing to hand over the
105
+ // transaction, and it must surface with its own message and SQLSTATE
106
+ // intact so the on-call engineer sees the offender list rather than a
107
+ // generic rollback.
108
+ await assertFenceOnce(tx);
41
109
  try {
42
110
  return await fn(tx);
43
111
  }
@@ -1 +1 @@
1
- {"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,21 @@
1
+ export interface FenceCliResult {
2
+ /** 0 = clean, 1 = fence defect, 2 = usage / connection error. */
3
+ code: number;
4
+ message: string;
5
+ }
6
+ /**
7
+ * `install-fence` — apply the delivery unit (resolver + gate).
8
+ *
9
+ * Idempotent and re-runnable. Must be run with OWNER credentials: it creates
10
+ * functions in the `auth` schema. Safe to run on every deploy; safe to run
11
+ * twice; and re-running is how a service that has since landed a membership
12
+ * mirror upgrades itself from the degraded body to the strict one.
13
+ */
14
+ export declare function runInstallFenceCli(args: readonly string[]): Promise<FenceCliResult>;
15
+ /**
16
+ * `verify-fence` — assert the fence from the CATALOG, using the TypeScript
17
+ * verifier (which reports EVERY offender at once rather than raising on the
18
+ * first, and covers the unique-index check the SQL gate leaves to deploy time).
19
+ */
20
+ export declare function runVerifyFenceCli(args: readonly string[]): Promise<FenceCliResult>;
21
+ //# sourceMappingURL=fence-cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fence-cli.d.ts","sourceRoot":"","sources":["../src/fence-cli.ts"],"names":[],"mappings":"AA+BA,MAAM,WAAW,cAAc;IAC7B,iEAAiE;IACjE,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAkDD;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,SAAS,MAAM,EAAE,GACtB,OAAO,CAAC,cAAc,CAAC,CAsCzB;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,SAAS,MAAM,EAAE,GACtB,OAAO,CAAC,cAAc,CAAC,CAkCzB"}
@@ -0,0 +1,191 @@
1
+ // `nodii-db-rls install-fence` and `nodii-db-rls verify-fence`.
2
+ //
3
+ // The eager, pipeline-facing half of the gate. `withUserContext` runs
4
+ // `auth.assert_tenant_fence()` on first authenticated use, which is the
5
+ // unforgettable check — but "unforgettable" is not the same as "early", and a
6
+ // deploy that can fail at 09:00 rather than on the first customer request at
7
+ // 09:04 should.
8
+ //
9
+ // THIS IS NOT OFFERED AS THE PRIMARY MECHANISM, and the reason is measured
10
+ // rather than stylistic. Of the ten services that call neither
11
+ // `applyAuthSchemaMigrations` nor `authSchemaHelpers`, SIX — billing, kyc,
12
+ // notification, task-tracking, tenant and geo — have NO database step in
13
+ // `deploy-dev.yml`. Wiring the fence exclusively into a deploy script would
14
+ // leave those six permanently unchecked while reporting green, which is the
15
+ // precise failure mode this release exists to end. So the CLI is the
16
+ // belt-and-braces for pipelines that DO have a database step, and the runtime
17
+ // gate is the floor for everyone.
18
+ //
19
+ // `install-fence` exists because the delivery unit has to be applicable to a
20
+ // service that is on none of the library paths — one command, owner
21
+ // credentials, no code change:
22
+ //
23
+ // bunx @nodii/db-rls install-fence --db "$DATABASE_URL"
24
+ import { SESSION_FENCE_BOOTSTRAP_SQL, SESSION_TENANT_IDS_GRANTS_SQL, } from "./auth-schema.js";
25
+ import { TENANT_FENCE_GATE_GRANTS_SQL } from "./tenant-fence-gate.js";
26
+ import { TenantFenceError, verifyTenantFence } from "./tenant-fence.js";
27
+ function splitList(value) {
28
+ if (!value)
29
+ return [];
30
+ return value
31
+ .split(",")
32
+ .map((s) => s.trim())
33
+ .filter((s) => s.length > 0);
34
+ }
35
+ function parse(args) {
36
+ const out = {
37
+ allowDegraded: true,
38
+ skipGrants: false,
39
+ help: false,
40
+ };
41
+ for (let i = 0; i < args.length; i++) {
42
+ const a = args[i];
43
+ if (a === "--db" && i + 1 < args.length)
44
+ out.dbUrl = args[++i];
45
+ else if (a === "--schema" && i + 1 < args.length)
46
+ out.schema = args[++i];
47
+ else if (a === "--exempt-policies" && i + 1 < args.length)
48
+ out.exemptPolicies = args[++i];
49
+ else if (a === "--exempt-tables" && i + 1 < args.length)
50
+ out.exemptTables = args[++i];
51
+ // Default is to ACCEPT the degraded resolver: it is the D415-sanctioned
52
+ // terminal state for a service with no membership mirror, and a gate that
53
+ // is red on a legitimate configuration gets switched off. `--strict-only`
54
+ // is for a service that HAS a mirror and wants to prove it never silently
55
+ // regresses to fence-only.
56
+ else if (a === "--strict-only")
57
+ out.allowDegraded = false;
58
+ else if (a === "--skip-grants")
59
+ out.skipGrants = true;
60
+ else if (a === "--help" || a === "-h")
61
+ out.help = true;
62
+ }
63
+ return out;
64
+ }
65
+ function connectionString(parsed) {
66
+ return parsed.dbUrl ?? process.env.DATABASE_URL ?? process.env.DB_RLS_PG_URL;
67
+ }
68
+ /**
69
+ * `install-fence` — apply the delivery unit (resolver + gate).
70
+ *
71
+ * Idempotent and re-runnable. Must be run with OWNER credentials: it creates
72
+ * functions in the `auth` schema. Safe to run on every deploy; safe to run
73
+ * twice; and re-running is how a service that has since landed a membership
74
+ * mirror upgrades itself from the degraded body to the strict one.
75
+ */
76
+ export async function runInstallFenceCli(args) {
77
+ const parsed = parse(args);
78
+ if (parsed.help)
79
+ return { code: 0, message: installUsage() };
80
+ const conn = connectionString(parsed);
81
+ if (!conn) {
82
+ return {
83
+ code: 2,
84
+ message: "install-fence: no connection string — pass --db <url> or set DATABASE_URL / DB_RLS_PG_URL.",
85
+ };
86
+ }
87
+ const { Pool } = await import("pg");
88
+ const pool = new Pool({ connectionString: conn, max: 1 });
89
+ try {
90
+ await pool.query(SESSION_FENCE_BOOTSTRAP_SQL);
91
+ if (!parsed.skipGrants) {
92
+ await pool.query(SESSION_TENANT_IDS_GRANTS_SQL);
93
+ await pool.query(TENANT_FENCE_GATE_GRANTS_SQL);
94
+ }
95
+ const { rows } = await pool.query(`SELECT prosrc FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
96
+ WHERE n.nspname = 'auth' AND p.proname = 'session_tenant_ids'`);
97
+ const body = rows[0]?.prosrc ?? "";
98
+ const variant = body.includes("DB_RLS_DEGRADED_NO_MEMBERSHIP_MIRROR")
99
+ ? "DEGRADED (fence-only, D415) — auth.user_tenant_ids is absent or still the bootstrap stub"
100
+ : "STRICT — session tenant INTERSECT server-resolved membership";
101
+ return {
102
+ code: 0,
103
+ message: `install-fence: OK. auth.session_tenant_ids() installed as ${variant}\ninstall-fence: auth.assert_tenant_fence() installed.`,
104
+ };
105
+ }
106
+ catch (err) {
107
+ return { code: 2, message: `install-fence: ${err.message}` };
108
+ }
109
+ finally {
110
+ await pool.end().catch(() => { });
111
+ }
112
+ }
113
+ /**
114
+ * `verify-fence` — assert the fence from the CATALOG, using the TypeScript
115
+ * verifier (which reports EVERY offender at once rather than raising on the
116
+ * first, and covers the unique-index check the SQL gate leaves to deploy time).
117
+ */
118
+ export async function runVerifyFenceCli(args) {
119
+ const parsed = parse(args);
120
+ if (parsed.help)
121
+ return { code: 0, message: verifyUsage() };
122
+ const conn = connectionString(parsed);
123
+ if (!conn) {
124
+ return {
125
+ code: 2,
126
+ message: "verify-fence: no connection string — pass --db <url> or set DATABASE_URL / DB_RLS_PG_URL.",
127
+ };
128
+ }
129
+ const { Pool } = await import("pg");
130
+ const pool = new Pool({ connectionString: conn, max: 1 });
131
+ try {
132
+ await verifyTenantFence(pool, {
133
+ schema: parsed.schema,
134
+ exemptPolicies: splitList(parsed.exemptPolicies),
135
+ exemptTables: splitList(parsed.exemptTables),
136
+ allowDegradedResolver: parsed.allowDegraded,
137
+ });
138
+ return {
139
+ code: 0,
140
+ message: "verify-fence: OK — every authenticated policy resolves through auth.session_tenant_ids(), every tenant-scoped relation is protected.",
141
+ };
142
+ }
143
+ catch (err) {
144
+ if (err instanceof TenantFenceError)
145
+ return { code: 1, message: err.message };
146
+ return { code: 2, message: `verify-fence: ${err.message}` };
147
+ }
148
+ finally {
149
+ await pool.end().catch(() => { });
150
+ }
151
+ }
152
+ function installUsage() {
153
+ return `usage: install-fence [--db <url>] [--skip-grants]
154
+
155
+ Applies the tenant-fence delivery unit with OWNER credentials:
156
+ - auth.session_tenant_ids() — the authenticated-pool resolver. The STRICT
157
+ body (session tenant INTERSECT membership) is installed where a real
158
+ auth.user_tenant_ids exists; the DEGRADED fence-only body (D415) where it
159
+ is absent or still the bootstrap stub. Derived from the catalog on every
160
+ run — there is no per-service list to get wrong, and re-running after a
161
+ membership mirror lands upgrades degraded to strict automatically.
162
+ - auth.assert_tenant_fence() — the runtime gate @nodii/db-rls calls on the
163
+ first authenticated-pool checkout.
164
+
165
+ Idempotent. Safe on every deploy. Run BEFORE any migration that creates a
166
+ policy referencing the resolver: Postgres validates the reference at CREATE
167
+ POLICY time, so an absent resolver fails the migration with 42883.
168
+
169
+ Connection string: --db <url>, else DATABASE_URL, else DB_RLS_PG_URL.`;
170
+ }
171
+ function verifyUsage() {
172
+ return `usage: verify-fence [--db <url>] [--schema <name>] [--strict-only]
173
+ [--exempt-policies <a.b,c>] [--exempt-tables <x,y>]
174
+
175
+ Asserts from the CATALOG that every permissive policy applying to the
176
+ 'authenticated' role resolves through auth.session_tenant_ids(), that every
177
+ relation carrying a tenant column can enforce RLS, and that the resolver is
178
+ installed, loud, and SECURITY DEFINER. Exits 1 (loud) listing every offender.
179
+
180
+ Tables are DISCOVERED from the catalog, never supplied by the caller: the
181
+ author who forgets ENABLE ROW LEVEL SECURITY forgets the list entry in the
182
+ same migration, which is why a caller-supplied list is blind to exactly the
183
+ tables at risk.
184
+
185
+ --strict-only additionally rejects the DEGRADED fence-only resolver. The
186
+ default accepts it: it is the D415-sanctioned terminal state for a service
187
+ with no membership mirror.
188
+
189
+ Connection string: --db <url>, else DATABASE_URL, else DB_RLS_PG_URL.`;
190
+ }
191
+ //# sourceMappingURL=fence-cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fence-cli.js","sourceRoot":"","sources":["../src/fence-cli.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -11,8 +11,9 @@ export type { OwnerContextOpts } from "./migrate-guard.js";
11
11
  export { assertMigrateAllowed } from "./migrate-guard.js";
12
12
  export { RlsCoverageError, verifyRls } from "./verify-rls.js";
13
13
  export type { AnyRlsQuerier, ReferenceTablePredicate, RlsCoverageOffender, RlsQuerier, RlsQuerierPostgresJs, VerifyRlsOpts, } from "./verify-rls.js";
14
- export { AUTH_SCHEMA_SQL, AUTH_SCHEMA_GRANTS_SQL, applyAuthSchemaMigrations, authSchemaFullSql, SESSION_TENANT_IDS_DEGRADED_MARKER, SESSION_TENANT_IDS_DEGRADED_SQL, SESSION_TENANT_IDS_GRANTS_SQL, SESSION_TENANT_IDS_SQL, } from "./auth-schema.js";
14
+ export { AUTH_SCHEMA_SQL, AUTH_SCHEMA_GRANTS_SQL, applyAuthSchemaMigrations, authSchemaFullSql, SESSION_TENANT_IDS_DEGRADED_MARKER, SESSION_TENANT_IDS_DEGRADED_SQL, SESSION_TENANT_IDS_GRANTS_SQL, SESSION_TENANT_IDS_SQL, applySessionFenceBootstrap, INSTALL_SESSION_RESOLVER_SQL, SESSION_FENCE_BOOTSTRAP_SQL, } from "./auth-schema.js";
15
15
  export type { SqlExecutor } from "./auth-schema.js";
16
+ export { TENANT_FENCE_GATE_CALL_SQL, TENANT_FENCE_GATE_GRANTS_SQL, TENANT_FENCE_GATE_SQL, sessionFenceBootstrapSql, } from "./tenant-fence-gate.js";
16
17
  export { withRls, generateTableGrants, authenticatedPredicate, SESSION_TENANT_IDS_CALL, SESSION_TENANT_IDS_SUBQUERY, withPlatformOpsTenantSql, withPlatformOpsGlobalSql, platformOpsTenantPredicateSql, platformOpsGlobalPredicateSql, platformOpsVerbClauseSql, sqlLiteral, PLATFORM_OPS_GRANTS_REPLICA_DEFAULT, referenceTableGrantsSql, findTenantColumn, ReferenceTableTenantColumnError, REFERENCE_TABLE_SKIP_RLS_MARKER, } from "./rls-policy-gen.js";
17
18
  export type { RlsVerb, WithRlsOpts, AuthPoolMode, PlatformOpsVerb, PlatformOpsPerVerbPermissions, WithPlatformOpsSqlOpts, ReferenceTableReadRole, ReferenceTableSqlOpts, } from "./rls-policy-gen.js";
18
19
  export { migrateGen, runMigrateGenCli, userTenantIdsRealSql, userTenantIdsStubSql, platformOpsPoliciesSql, referenceTablesSql, } from "./migrate-gen.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -72,7 +72,18 @@ export { assertMigrateAllowed } from "./migrate-guard.js";
72
72
  // no `pg` installed) could not satisfy; that is why this had zero adoption
73
73
  // while five services hand-rolled the same pg_class query in their tests.
74
74
  export { RlsCoverageError, verifyRls } from "./verify-rls.js";
75
- export { AUTH_SCHEMA_SQL, AUTH_SCHEMA_GRANTS_SQL, applyAuthSchemaMigrations, authSchemaFullSql, SESSION_TENANT_IDS_DEGRADED_MARKER, SESSION_TENANT_IDS_DEGRADED_SQL, SESSION_TENANT_IDS_GRANTS_SQL, SESSION_TENANT_IDS_SQL, } from "./auth-schema.js";
75
+ export { AUTH_SCHEMA_SQL, AUTH_SCHEMA_GRANTS_SQL, applyAuthSchemaMigrations, authSchemaFullSql, SESSION_TENANT_IDS_DEGRADED_MARKER, SESSION_TENANT_IDS_DEGRADED_SQL, SESSION_TENANT_IDS_GRANTS_SQL, SESSION_TENANT_IDS_SQL,
76
+ // THE DELIVERY PATH. `INSTALL_SESSION_RESOLVER_SQL` picks the strict or
77
+ // degraded resolver body from what the database actually contains;
78
+ // `SESSION_FENCE_BOOTSTRAP_SQL` is that plus the runtime gate, and
79
+ // `applySessionFenceBootstrap` applies it. A service with no other delivery
80
+ // path calls the last of these from its boot sequence, before anything opens
81
+ // an authenticated transaction.
82
+ applySessionFenceBootstrap, INSTALL_SESSION_RESOLVER_SQL, SESSION_FENCE_BOOTSTRAP_SQL, } from "./auth-schema.js";
83
+ // The RUNTIME gate. `withUserContext` calls `auth.assert_tenant_fence()` once
84
+ // per process on the first authenticated checkout — these are exported for the
85
+ // CLI, for a bespoke `db:verify`, and so the wiring is greppable.
86
+ export { TENANT_FENCE_GATE_CALL_SQL, TENANT_FENCE_GATE_GRANTS_SQL, TENANT_FENCE_GATE_SQL, sessionFenceBootstrapSql, } from "./tenant-fence-gate.js";
76
87
  export { withRls, generateTableGrants,
77
88
  // 08-rls § 6 — the authenticated-pool predicate (single source of truth,
78
89
  // pool-mode aware). Exported so consumers can compose it directly.
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/db-rls",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Three-pool RLS dispatch + GUC-set + entry-point helpers + RLS policy DDL generator for the Nodii microservice stack. Polyglot per D159 (TS + Python + Go). Spec: planning hub docKey=db-rls.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -62,7 +62,7 @@
62
62
  }
63
63
  },
64
64
  "devDependencies": {
65
- "@nodii/telemetry": "0.26.0",
65
+ "@nodii/telemetry": "0.27.0",
66
66
  "@types/bun": "^1.3.13",
67
67
  "@types/pg": "^8.11.10",
68
68
  "drizzle-orm": "^0.45.2",