@nodii/db-rls 0.12.0 → 0.14.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/README.md CHANGED
@@ -1,5 +1,83 @@
1
1
  # @nodii/db-rls
2
2
 
3
- Placeholder package at v0.0.1. Implementation lands at v0.1.0 per the locked feature_doc.
3
+ Three-pool RLS dispatch + GUC binding + policy DDL generation for the Nodii
4
+ microservice stack.
4
5
 
5
6
  **Spec**: `https://planning.dev.nucleus-cloud.in/api/v1/feature-docs?serviceId=nodii-libs&docKey=db-rls`
7
+
8
+ ## Unforgeable tenant binding
9
+
10
+ On the **authenticated** path the tenant is not a parameter. It is read from a
11
+ request-scoped verified-claims context that auth middleware binds once:
12
+
13
+ ```ts
14
+ // ONCE, in auth middleware, immediately after JWT verification.
15
+ app.use(async (c, next) => {
16
+ const claims = await verifyJwt(c.req.header("authorization"));
17
+ return runWithVerifiedTenantClaims(
18
+ { userId: claims.sub, tenantId: claims.tenant_id },
19
+ () => next(),
20
+ );
21
+ });
22
+
23
+ // Everywhere else — no tenant, and nowhere to put one.
24
+ const rows = await withUserContextDbPostgresJs(async (db) =>
25
+ db.select().from(invoices),
26
+ );
27
+ ```
28
+
29
+ `withUserContext` / `withUserContextDb` / `withUserContextDbPostgresJs` take the
30
+ callback as their **only** argument. `autoDispatch(fn)` is the same path;
31
+ `autoDispatch({ systemTenantId }, fn)` is the system leg.
32
+
33
+ Why: the previous shape, `withUserContext({ userId, tenantId }, fn)`, let a
34
+ handler pass a request-derived tenant instead of the verified claim, and RLS
35
+ accepted `app.tenant_id` without ever proving the principal held it. Membership
36
+ was checked once at login in `nodii-auth`, and nowhere near the database.
37
+
38
+ **Workers, cron, S2S and webhooks are unaffected** — they have a legitimately
39
+ resolved tenant and no user claim, and keep their explicit parameter:
40
+ `withSystemContext({ tenantId }, fn)`. Migrations use `withOwnerContext`
41
+ (`NODII_ENV=migrate`-gated); Nodii staff cross-tenant access uses
42
+ `withPlatformOpsContext` (RBAC-gated, a different predicate).
43
+
44
+ There is **no escape hatch** — no option to override the tenant. Binding is
45
+ refused if claims are already bound (a handler cannot rebind to a different
46
+ tenant), bound claims are copied and frozen (they cannot be mutated after the
47
+ fact), and a blank / missing / non-string `userId` or `tenantId` is refused at
48
+ bind time rather than binding an empty GUC that reads as "no rows". With no
49
+ claims bound the authenticated helpers throw `NoVerifiedTenantClaims` before
50
+ acquiring a pool — never a default, never a fallback to a passed value.
51
+
52
+ ## The system pool is fenced against authenticated fall-through
53
+
54
+ The services-pool predicate is the bare `tenant_id = auth.get_tenant_id()` —
55
+ no membership proof. So an authenticated request that falls through to
56
+ `withSystemContext({ tenantId: req.body.tenantId }, fn)` re-opens exactly the
57
+ forgery the claims binding closes: the handler supplies the tenant, and
58
+ nothing checks the principal holds it. That fall-through is the seam where the
59
+ ₹250,000 cross-tenant refund reproduced.
60
+
61
+ Therefore, **inside a claims-bound request** (`hasVerifiedTenantClaims()` is
62
+ true), `withSystemContext` / `withSystemContextDb` /
63
+ `withSystemContextDbPostgresJs` (and `autoDispatch`'s system leg) **throw
64
+ `SystemContextUnderClaimsRefused`** — before any pool is acquired — unless the
65
+ caller passes an explicit, non-blank `{ reason }`:
66
+
67
+ ```ts
68
+ // Deliberate system work inside a request — named, counted, greppable.
69
+ await withSystemContext(
70
+ { tenantId, reason: "outbox-emit: invoice.settled fan-out" },
71
+ async (tx) => { ... },
72
+ );
73
+ ```
74
+
75
+ A sanctioned escape is emitted to telemetry as
76
+ `db_rls.cross_pool_nesting_total{outer="authenticated", inner="services",
77
+ reason}`, so every crossing is observable. **Outside any claims-bound context
78
+ (boot, cron, workers, S2S, webhooks) nothing changes** — no reason is needed
79
+ and the helpers behave exactly as before. That compat property is what keeps
80
+ this fence from breaking every worker in the fleet.
81
+
82
+ Full migration steps, including the `createSecureClient` before/after, are in
83
+ `.changeset/db-rls-unforgeable-tenant-binding.md`.
@@ -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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"}
@@ -0,0 +1,100 @@
1
+ import { type SystemContextOpts, type VerifiedTenantClaims } from "./types.js";
2
+ /**
3
+ * Establish the request-scoped verified-claims context. Call this ONCE per
4
+ * request, from auth middleware, IMMEDIATELY after the JWT has been
5
+ * verified — and pass the values off the VERIFIED claim set, never off the
6
+ * request body / path / headers.
7
+ *
8
+ * Everything downstream (`withUserContext`, `withUserContextDb`,
9
+ * `withUserContextDbPostgresJs`, `autoDispatch`) reads the tenant from
10
+ * here. Nothing downstream can supply one.
11
+ *
12
+ * ```ts
13
+ * app.use(async (c, next) => {
14
+ * const claims = await verifyJwt(c.req.header("authorization"));
15
+ * return runWithVerifiedTenantClaims(
16
+ * { userId: claims.sub, tenantId: claims.tenant_id },
17
+ * () => next(),
18
+ * );
19
+ * });
20
+ * ```
21
+ *
22
+ * Returns whatever `fn` returns (including a promise), so it composes with
23
+ * Hono / Express / gRPC interceptor shapes unchanged.
24
+ *
25
+ * Throws {@link VerifiedTenantClaimsAlreadyBound} if claims are already
26
+ * bound in the current async context. Re-binding is refused
27
+ * UNCONDITIONALLY — including to identical claims. A nested bind is the
28
+ * one shape that could re-open the hole (bind in middleware, re-bind to a
29
+ * request-derived tenant in the handler), so it is not a thing that
30
+ * happens quietly. If you have hit this legitimately, the answer is to
31
+ * move the single bind to the outermost auth boundary, not to relax it.
32
+ *
33
+ * Throws {@link InvalidVerifiedTenantClaims} if `userId` / `tenantId` is
34
+ * missing, not a string, or blank.
35
+ */
36
+ export declare function runWithVerifiedTenantClaims<T>(claims: VerifiedTenantClaims, fn: () => T): T;
37
+ /**
38
+ * The verified claims bound to the current request, or `null` outside any
39
+ * bound context.
40
+ *
41
+ * This is the DIAGNOSTIC read (health probes, "am I on the authenticated
42
+ * path?" branches). Anything that is about to touch the database must use
43
+ * {@link requireVerifiedTenantClaims} so an unbound context fails closed
44
+ * instead of degrading into a nullish branch.
45
+ */
46
+ export declare function getVerifiedTenantClaimsOrNull(): VerifiedTenantClaims | null;
47
+ /** Whether verified claims are bound in the current async context. */
48
+ export declare function hasVerifiedTenantClaims(): boolean;
49
+ /**
50
+ * The verified claims bound to the current request, or THROW.
51
+ *
52
+ * Fail closed and loud: there is no default tenant, no fallback to a
53
+ * passed value, and no empty result. A silently-empty result is how the
54
+ * crm 85-policy blackout stayed invisible for weeks.
55
+ */
56
+ export declare function requireVerifiedTenantClaims(helper?: string): VerifiedTenantClaims;
57
+ /**
58
+ * Refuse the retired `helper(opts, fn)` call shape.
59
+ *
60
+ * TypeScript already rejects it — the helpers take the callback as their
61
+ * only argument, so all 271 call sites are compile errors until each one is
62
+ * examined. This is the runtime backstop for consumers that do not
63
+ * typecheck against this package (plain JS, a stale `dist/`, a `any`-typed
64
+ * wrapper): without it, the opts object would be invoked as the callback
65
+ * and the caller would get `opts is not a function`, which reads like a bug
66
+ * in db-rls rather than a migration they have not done.
67
+ */
68
+ export declare function assertNoTenantArgument(helper: string, fn: unknown): void;
69
+ /**
70
+ * THE STEP-1 FENCE on the system leg. Called by `withSystemContext`,
71
+ * `withSystemContextDb` and `withSystemContextDbPostgresJs` before they
72
+ * open a services-pool transaction.
73
+ *
74
+ * Three outcomes:
75
+ *
76
+ * - **No claims bound** (boot, cron, worker, S2S, webhook) → returns
77
+ * `null`. The system helpers behave EXACTLY as before — this is the
78
+ * compat property that keeps the fence from breaking every worker.
79
+ * - **Claims bound + `opts.reason` is a non-blank string** → returns
80
+ * `{ boundTenantId, reason }` so the caller can annotate telemetry
81
+ * (`db_rls.cross_pool_nesting_total{outer=authenticated,inner=services,
82
+ * reason}`). The escape is sanctioned, named, and observable.
83
+ * - **Claims bound + no reason** → THROWS
84
+ * {@link SystemContextUnderClaimsRefused}, BEFORE any pool is touched.
85
+ *
86
+ * WHY. The services-pool predicate is the bare
87
+ * `tenant_id = auth.get_tenant_id()` — it trusts the GUC completely and
88
+ * proves no membership. An authenticated request that falls through to
89
+ * `withSystemContext` therefore re-opens the exact hole the verified-claims
90
+ * binding closes on the authenticated pool: the handler supplies the
91
+ * tenant, and nothing checks the principal holds it. That fall-through is
92
+ * where the ₹250,000 cross-tenant refund reproduces. Requiring an explicit
93
+ * `{ reason }` turns a silent fall-through into a deliberate, reviewable,
94
+ * telemetry-visible act — and everything else into a throw.
95
+ */
96
+ export declare function assertSystemContextEscapeAllowed(helper: string, opts: SystemContextOpts): {
97
+ boundTenantId: string;
98
+ reason: string;
99
+ } | null;
100
+ //# sourceMappingURL=claims-context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claims-context.d.ts","sourceRoot":"","sources":["../src/claims-context.ts"],"names":[],"mappings":"AAiDA,OAAO,EAIL,KAAK,iBAAiB,EAEtB,KAAK,oBAAoB,EAE1B,MAAM,YAAY,CAAC;AAuGpB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,2BAA2B,CAAC,CAAC,EAC3C,MAAM,EAAE,oBAAoB,EAC5B,EAAE,EAAE,MAAM,CAAC,GACV,CAAC,CAcH;AAED;;;;;;;;GAQG;AACH,wBAAgB,6BAA6B,IAAI,oBAAoB,GAAG,IAAI,CAE3E;AAED,sEAAsE;AACtE,wBAAgB,uBAAuB,IAAI,OAAO,CAEjD;AAED;;;;;;GAMG;AACH,wBAAgB,2BAA2B,CACzC,MAAM,CAAC,EAAE,MAAM,GACd,oBAAoB,CAMtB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,OAAO,GAAG,IAAI,CAIxE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,gCAAgC,CAC9C,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,iBAAiB,GACtB;IAAE,aAAa,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAclD"}