@nodii/db-rls 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/bin/migrate-gen.mjs +11 -0
  2. package/dist/auth-schema.d.ts +207 -1
  3. package/dist/auth-schema.d.ts.map +1 -1
  4. package/dist/auth-schema.js +331 -4
  5. package/dist/auth-schema.js.map +1 -1
  6. package/dist/context.d.ts +31 -8
  7. package/dist/context.d.ts.map +1 -1
  8. package/dist/context.js +103 -24
  9. package/dist/context.js.map +1 -1
  10. package/dist/drizzle-client.d.ts +17 -5
  11. package/dist/drizzle-client.d.ts.map +1 -1
  12. package/dist/drizzle-client.js +19 -18
  13. package/dist/drizzle-client.js.map +1 -1
  14. package/dist/fence-cli.d.ts +21 -0
  15. package/dist/fence-cli.d.ts.map +1 -0
  16. package/dist/fence-cli.js +191 -0
  17. package/dist/fence-cli.js.map +1 -0
  18. package/dist/index.d.ts +7 -4
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +31 -7
  21. package/dist/index.js.map +1 -1
  22. package/dist/postgres-js-client.d.ts +17 -10
  23. package/dist/postgres-js-client.d.ts.map +1 -1
  24. package/dist/postgres-js-client.js +19 -21
  25. package/dist/postgres-js-client.js.map +1 -1
  26. package/dist/rls-policy-gen.d.ts +43 -27
  27. package/dist/rls-policy-gen.d.ts.map +1 -1
  28. package/dist/rls-policy-gen.js +40 -15
  29. package/dist/rls-policy-gen.js.map +1 -1
  30. package/dist/rls-policy.d.ts +11 -1
  31. package/dist/rls-policy.d.ts.map +1 -1
  32. package/dist/rls-policy.js +31 -4
  33. package/dist/rls-policy.js.map +1 -1
  34. package/dist/tenant-fence-gate.d.ts +54 -0
  35. package/dist/tenant-fence-gate.d.ts.map +1 -0
  36. package/dist/tenant-fence-gate.js +291 -0
  37. package/dist/tenant-fence-gate.js.map +1 -0
  38. package/dist/tenant-fence.d.ts +165 -0
  39. package/dist/tenant-fence.d.ts.map +1 -0
  40. package/dist/tenant-fence.js +542 -0
  41. package/dist/tenant-fence.js.map +1 -0
  42. package/dist/types.d.ts +18 -0
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/types.js +22 -0
  45. package/dist/types.js.map +1 -1
  46. package/dist/verify-rls-cli.d.ts.map +1 -1
  47. package/dist/verify-rls-cli.js +86 -10
  48. package/dist/verify-rls-cli.js.map +1 -1
  49. package/dist/verify-rls.d.ts +26 -5
  50. package/dist/verify-rls.d.ts.map +1 -1
  51. package/dist/verify-rls.js +73 -5
  52. package/dist/verify-rls.js.map +1 -1
  53. package/package.json +2 -2
@@ -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,184 @@ 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
+ /**
9
+ * `auth.session_tenant_ids()` — THE authenticated-pool resolver.
10
+ *
11
+ * Returns AT MOST ONE tenant: the one bound to `app.tenant_id`, and only if the
12
+ * principal in `app.user_id` genuinely holds it per `auth.user_tenant_ids`.
13
+ * Edge-signed session tenant **INTERSECT** server-resolved membership.
14
+ *
15
+ * WHY THIS EXISTS. The `authenticated` predicate used to be
16
+ * `tenant_id IN (SELECT auth.user_tenant_ids(auth.get_user_id()))` — the UNION
17
+ * of every tenant the user belongs to, with no tenant fence at all. A session
18
+ * opened for a user with memberships in tenants A and B could read AND write
19
+ * both. Reproduced end-to-end: a legitimate tenant-A session transitioned a
20
+ * tenant-B approval task, got HTTP 200, and released a 250,000 INR refund.
21
+ *
22
+ * WHY SUBSTITUTION RATHER THAN APPENDING A FENCE. The obvious repair is to
23
+ * append `AND tenant_id = auth.get_tenant_id()` at the top level of each
24
+ * policy. That breaks every policy whose shape is not a bare tenant equality,
25
+ * and the fleet is full of them:
26
+ *
27
+ * - tables with NO `tenant_id` column that reach it through a nested
28
+ * `EXISTS` or a sub-`SELECT` (`tenant_role_permissions`, `fx_rate_cache`)
29
+ * - platform-default escape hatches — `tenant_id IS NULL OR tenant_id IN (…)`
30
+ * (hr's statutory masters, `pricing_snapshots`)
31
+ * - additive ORs on a differently-named column — `is_public_listing = TRUE OR
32
+ * developer_tenant_id IN (…)` (`oauth_apps` marketplace listings)
33
+ * - policies fencing on a column that is not `tenant_id` at all (`tenants.id`,
34
+ * `legal_documents.scope`, `impersonation_sessions.target_tenant_id`)
35
+ * - TEXT-vs-uuid casts that need fixing in opposite directions per table
36
+ *
37
+ * Swapping the resolver INSIDE the subquery leaves the column, the cast and the
38
+ * surrounding OR untouched, so all of those keep working while the set the
39
+ * subquery yields narrows from "every tenant you belong to" to "the one you are
40
+ * logged into, if you really belong to it". One substitution, zero shape
41
+ * changes, and NO `AS RESTRICTIVE` (which would AND against the additive
42
+ * permissive master policies and kill every platform-default row).
43
+ *
44
+ * `user_tenant_ids` is deliberately LEFT INTACT. It still answers the honest
45
+ * question "which tenants does this user belong to", which a tenant-switcher or
46
+ * a login tenant-picker legitimately needs. Overloading it with session scope
47
+ * would have silently broken those callers with no error and no test coverage.
48
+ *
49
+ * SECURITY DEFINER is required, not decorative:
50
+ * - `authenticated` has no privilege on the membership mirror, so an INVOKER
51
+ * function raises `permission denied for table user_memberships`;
52
+ * - if the mirror is itself RLS-policied, an INVOKER function re-enters its
53
+ * own policy and dies with `stack depth limit exceeded` (54001).
54
+ *
55
+ * FAIL-CLOSED SPLIT, deliberate:
56
+ * - GUCs unset → RAISE 42501. Misconfiguration is LOUD. An unbound session
57
+ * silently yielding an empty grid reads to a human as "there is no data
58
+ * here", which is how a broken session survives a week of QA.
59
+ * - bound, but no membership → EMPTY SET, no error. A legitimate non-member
60
+ * seeing nothing is not an error condition, and platform-default rows
61
+ * reached through a sibling `OR` must still resolve.
62
+ */
63
+ export declare const SESSION_TENANT_IDS_SQL = "CREATE OR REPLACE FUNCTION auth.session_tenant_ids() RETURNS SETOF uuid\nLANGUAGE plpgsql STABLE SECURITY DEFINER\nSET search_path = pg_catalog, public, pg_temp\nAS $session$\nDECLARE\n v_user uuid;\n v_tenant uuid;\nBEGIN\n v_user := nullif(current_setting('app.user_id', true), '')::uuid;\n v_tenant := nullif(current_setting('app.tenant_id', true), '')::uuid;\n IF v_user IS NULL THEN\n RAISE EXCEPTION 'db_rls_session_user_missing: app.user_id is not bound on this connection'\n USING ERRCODE = '42501',\n HINT = 'The authenticated pool is the user-session pool; a session with no principal cannot be membership-checked. Open it with withUserContext({ userId, tenantId }, fn) from @nodii/db-rls.';\n END IF;\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'db_rls_session_tenant_missing: app.tenant_id is not bound on this connection'\n USING ERRCODE = '42501',\n HINT = 'Every authenticated-pool session must be EXPLICITLY tenant-scoped. Open it with withUserContext({ userId, tenantId }, fn) from @nodii/db-rls.';\n END IF;\n RETURN QUERY SELECT v_tenant WHERE v_tenant IN (SELECT auth.user_tenant_ids(v_user));\nEND\n$session$;\n";
64
+ /**
65
+ * Marker embedded in the DEGRADED body so `pg_proc.prosrc` introspection can
66
+ * tell the two variants apart. {@link verifyTenantFence} reports any database
67
+ * running the degraded resolver — the point is that a service is never on it by
68
+ * accident.
69
+ */
70
+ export declare const SESSION_TENANT_IDS_DEGRADED_MARKER = "DB_RLS_DEGRADED_NO_MEMBERSHIP_MIRROR";
71
+ /**
72
+ * DEGRADED variant of {@link SESSION_TENANT_IDS_SQL} — fence ONLY, no
73
+ * membership intersect.
74
+ *
75
+ * FOR SERVICES THAT SHIP NO MEMBERSHIP MIRROR, AND NOTHING ELSE. Such a service
76
+ * has `auth.user_tenant_ids` as the bootstrap stub (`SELECT NULL::uuid WHERE
77
+ * false`), so the strict resolver intersects to the empty set and the entire
78
+ * authenticated pool becomes silently deny-all — the P0 shape of drift
79
+ * 199feabc, where crm's 430 policies were dead and nobody noticed.
80
+ *
81
+ * THE LIST BELOW IS DERIVED FROM THE REPOS, NOT FROM MEMORY. An earlier
82
+ * revision of this comment asserted "nodii-crm-service alone", which was wrong
83
+ * by six services and wrong about crm's urgency. Several repos ship a phase-004
84
+ * or phase-005 `0002_create_rls_helpers.sql` whose real `user_tenant_ids` is
85
+ * FULLY COMMENTED OUT, so the phase-002 stub is still the winner:
86
+ *
87
+ * STUB + a LIVE authenticated pool → deny-all TODAY, needs this variant:
88
+ * - nodii-billing-service 77 call sites; saga + dsar migrations run at
89
+ * BOOT (`src/lib/bootstrap.ts`), so its
90
+ * `saga_outbox` / `lifecycle_requests` /
91
+ * `data_residency_holds` are library-owned. The
92
+ * money path. Highest risk in the fleet.
93
+ * - nodii-finance-service 14 call sites. Already self-mitigates via
94
+ * `010-lib-auth-pool-mode`, which rewrites the
95
+ * lib policies to `= auth.get_tenant_id()`.
96
+ * - nodii-notification-service 3 call sites. Its own policies use
97
+ * `= auth.get_tenant_id()` and keep working, so
98
+ * only the library-owned tables go dark — which
99
+ * is exactly the shape nobody notices.
100
+ *
101
+ * STUB + a pool that is built but has ZERO handler call sites → not broken
102
+ * today, breaks the moment a handler uses it:
103
+ * - nodii-crm-service, nodii-kyc-service, nodii-ticketing-service,
104
+ * nodii-product-service
105
+ *
106
+ * Does NOT need it: nodii-geo-service (no authenticated pool at all —
107
+ * 2-pool), nodii-edge (no `user_tenant_ids`, RLS mode `single`),
108
+ * nodii-customer-service and nodii-vendor-service (already GUC-bodied),
109
+ * nodii-auth / nodii-hr-service / nodii-task-tracking / nodii-tenant-service
110
+ * (REAL membership helpers).
111
+ *
112
+ * WHAT YOU GIVE UP. The database stops verifying the principal. Isolation rests
113
+ * entirely on the application binding `app.tenant_id` correctly — RLS degrades
114
+ * from an independent second layer into "the app said so". A forged or simply
115
+ * mistaken tenant binding becomes access. That is a real reduction in
116
+ * defence-in-depth and it is why this is a separate, named, reported constant
117
+ * rather than a boolean flag on the strict one.
118
+ *
119
+ * THE EXIT. Land a membership mirror, point `auth.user_tenant_ids` at it, then
120
+ * apply {@link SESSION_TENANT_IDS_SQL} over this. No policy changes — the
121
+ * predicate at every call site is identical for both variants.
122
+ */
123
+ export declare const SESSION_TENANT_IDS_DEGRADED_SQL = "CREATE OR REPLACE FUNCTION auth.session_tenant_ids() RETURNS SETOF uuid\nLANGUAGE plpgsql STABLE SECURITY DEFINER\nSET search_path = pg_catalog, public, pg_temp\nAS $session$\nDECLARE\n v_tenant uuid;\nBEGIN\n -- DB_RLS_DEGRADED_NO_MEMBERSHIP_MIRROR: no membership mirror in this service,\n -- so the session tenant is accepted WITHOUT proving the principal holds it.\n -- Replace with the strict resolver as soon as a mirror exists.\n IF nullif(current_setting('app.user_id', true), '') IS NULL THEN\n RAISE EXCEPTION 'db_rls_session_user_missing: app.user_id is not bound on this connection'\n USING ERRCODE = '42501',\n HINT = 'Open the session with withUserContext({ userId, tenantId }, fn) from @nodii/db-rls.';\n END IF;\n v_tenant := nullif(current_setting('app.tenant_id', true), '')::uuid;\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'db_rls_session_tenant_missing: app.tenant_id is not bound on this connection'\n USING ERRCODE = '42501',\n HINT = 'Every authenticated-pool session must be EXPLICITLY tenant-scoped. Open it with withUserContext({ userId, tenantId }, fn) from @nodii/db-rls.';\n END IF;\n RETURN QUERY SELECT v_tenant;\nEND\n$session$;\n";
124
+ /** Grant for the resolver. Separate from {@link AUTH_SCHEMA_GRANTS_SQL} for the
125
+ * same reason: the roles must exist before the GRANT runs. */
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;
8
185
  /**
9
186
  * Grants for the 7 helper functions. Emitted separately because they
10
187
  * reference roles that must exist before the GRANT runs — applied last
@@ -33,6 +210,35 @@ type AnyExecutor = Pool | PoolClient | SqlExecutor;
33
210
  */
34
211
  export declare function applyAuthSchemaMigrations(client: AnyExecutor, opts?: {
35
212
  includeGrants?: boolean;
213
+ degradedSessionResolver?: boolean;
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;
36
242
  }): Promise<void>;
37
243
  /**
38
244
  * Composite full DDL — the schema + helpers + grants, joined.
@@ -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;;;;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,CAAA;CAAO,GACrC,OAAO,CAAC,IAAI,CAAC,CASf;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,282 @@ 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$;
74
+ `;
75
+ /**
76
+ * `auth.session_tenant_ids()` — THE authenticated-pool resolver.
77
+ *
78
+ * Returns AT MOST ONE tenant: the one bound to `app.tenant_id`, and only if the
79
+ * principal in `app.user_id` genuinely holds it per `auth.user_tenant_ids`.
80
+ * Edge-signed session tenant **INTERSECT** server-resolved membership.
81
+ *
82
+ * WHY THIS EXISTS. The `authenticated` predicate used to be
83
+ * `tenant_id IN (SELECT auth.user_tenant_ids(auth.get_user_id()))` — the UNION
84
+ * of every tenant the user belongs to, with no tenant fence at all. A session
85
+ * opened for a user with memberships in tenants A and B could read AND write
86
+ * both. Reproduced end-to-end: a legitimate tenant-A session transitioned a
87
+ * tenant-B approval task, got HTTP 200, and released a 250,000 INR refund.
88
+ *
89
+ * WHY SUBSTITUTION RATHER THAN APPENDING A FENCE. The obvious repair is to
90
+ * append `AND tenant_id = auth.get_tenant_id()` at the top level of each
91
+ * policy. That breaks every policy whose shape is not a bare tenant equality,
92
+ * and the fleet is full of them:
93
+ *
94
+ * - tables with NO `tenant_id` column that reach it through a nested
95
+ * `EXISTS` or a sub-`SELECT` (`tenant_role_permissions`, `fx_rate_cache`)
96
+ * - platform-default escape hatches — `tenant_id IS NULL OR tenant_id IN (…)`
97
+ * (hr's statutory masters, `pricing_snapshots`)
98
+ * - additive ORs on a differently-named column — `is_public_listing = TRUE OR
99
+ * developer_tenant_id IN (…)` (`oauth_apps` marketplace listings)
100
+ * - policies fencing on a column that is not `tenant_id` at all (`tenants.id`,
101
+ * `legal_documents.scope`, `impersonation_sessions.target_tenant_id`)
102
+ * - TEXT-vs-uuid casts that need fixing in opposite directions per table
103
+ *
104
+ * Swapping the resolver INSIDE the subquery leaves the column, the cast and the
105
+ * surrounding OR untouched, so all of those keep working while the set the
106
+ * subquery yields narrows from "every tenant you belong to" to "the one you are
107
+ * logged into, if you really belong to it". One substitution, zero shape
108
+ * changes, and NO `AS RESTRICTIVE` (which would AND against the additive
109
+ * permissive master policies and kill every platform-default row).
110
+ *
111
+ * `user_tenant_ids` is deliberately LEFT INTACT. It still answers the honest
112
+ * question "which tenants does this user belong to", which a tenant-switcher or
113
+ * a login tenant-picker legitimately needs. Overloading it with session scope
114
+ * would have silently broken those callers with no error and no test coverage.
115
+ *
116
+ * SECURITY DEFINER is required, not decorative:
117
+ * - `authenticated` has no privilege on the membership mirror, so an INVOKER
118
+ * function raises `permission denied for table user_memberships`;
119
+ * - if the mirror is itself RLS-policied, an INVOKER function re-enters its
120
+ * own policy and dies with `stack depth limit exceeded` (54001).
121
+ *
122
+ * FAIL-CLOSED SPLIT, deliberate:
123
+ * - GUCs unset → RAISE 42501. Misconfiguration is LOUD. An unbound session
124
+ * silently yielding an empty grid reads to a human as "there is no data
125
+ * here", which is how a broken session survives a week of QA.
126
+ * - bound, but no membership → EMPTY SET, no error. A legitimate non-member
127
+ * seeing nothing is not an error condition, and platform-default rows
128
+ * reached through a sibling `OR` must still resolve.
129
+ */
130
+ export const SESSION_TENANT_IDS_SQL = `CREATE OR REPLACE FUNCTION auth.session_tenant_ids() RETURNS SETOF uuid
131
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
132
+ SET search_path = pg_catalog, public, pg_temp
133
+ AS $session$
134
+ DECLARE
135
+ v_user uuid;
136
+ v_tenant uuid;
137
+ BEGIN
138
+ v_user := nullif(current_setting('app.user_id', true), '')::uuid;
139
+ v_tenant := nullif(current_setting('app.tenant_id', true), '')::uuid;
140
+ IF v_user IS NULL THEN
141
+ RAISE EXCEPTION 'db_rls_session_user_missing: app.user_id is not bound on this connection'
142
+ USING ERRCODE = '42501',
143
+ HINT = 'The authenticated pool is the user-session pool; a session with no principal cannot be membership-checked. Open it with withUserContext({ userId, tenantId }, fn) from @nodii/db-rls.';
144
+ END IF;
145
+ IF v_tenant IS NULL THEN
146
+ RAISE EXCEPTION 'db_rls_session_tenant_missing: app.tenant_id is not bound on this connection'
147
+ USING ERRCODE = '42501',
148
+ HINT = 'Every authenticated-pool session must be EXPLICITLY tenant-scoped. Open it with withUserContext({ userId, tenantId }, fn) from @nodii/db-rls.';
149
+ END IF;
150
+ RETURN QUERY SELECT v_tenant WHERE v_tenant IN (SELECT auth.user_tenant_ids(v_user));
151
+ END
152
+ $session$;
153
+ `;
154
+ /**
155
+ * Marker embedded in the DEGRADED body so `pg_proc.prosrc` introspection can
156
+ * tell the two variants apart. {@link verifyTenantFence} reports any database
157
+ * running the degraded resolver — the point is that a service is never on it by
158
+ * accident.
159
+ */
160
+ export const SESSION_TENANT_IDS_DEGRADED_MARKER = "DB_RLS_DEGRADED_NO_MEMBERSHIP_MIRROR";
161
+ /**
162
+ * DEGRADED variant of {@link SESSION_TENANT_IDS_SQL} — fence ONLY, no
163
+ * membership intersect.
164
+ *
165
+ * FOR SERVICES THAT SHIP NO MEMBERSHIP MIRROR, AND NOTHING ELSE. Such a service
166
+ * has `auth.user_tenant_ids` as the bootstrap stub (`SELECT NULL::uuid WHERE
167
+ * false`), so the strict resolver intersects to the empty set and the entire
168
+ * authenticated pool becomes silently deny-all — the P0 shape of drift
169
+ * 199feabc, where crm's 430 policies were dead and nobody noticed.
170
+ *
171
+ * THE LIST BELOW IS DERIVED FROM THE REPOS, NOT FROM MEMORY. An earlier
172
+ * revision of this comment asserted "nodii-crm-service alone", which was wrong
173
+ * by six services and wrong about crm's urgency. Several repos ship a phase-004
174
+ * or phase-005 `0002_create_rls_helpers.sql` whose real `user_tenant_ids` is
175
+ * FULLY COMMENTED OUT, so the phase-002 stub is still the winner:
176
+ *
177
+ * STUB + a LIVE authenticated pool → deny-all TODAY, needs this variant:
178
+ * - nodii-billing-service 77 call sites; saga + dsar migrations run at
179
+ * BOOT (`src/lib/bootstrap.ts`), so its
180
+ * `saga_outbox` / `lifecycle_requests` /
181
+ * `data_residency_holds` are library-owned. The
182
+ * money path. Highest risk in the fleet.
183
+ * - nodii-finance-service 14 call sites. Already self-mitigates via
184
+ * `010-lib-auth-pool-mode`, which rewrites the
185
+ * lib policies to `= auth.get_tenant_id()`.
186
+ * - nodii-notification-service 3 call sites. Its own policies use
187
+ * `= auth.get_tenant_id()` and keep working, so
188
+ * only the library-owned tables go dark — which
189
+ * is exactly the shape nobody notices.
190
+ *
191
+ * STUB + a pool that is built but has ZERO handler call sites → not broken
192
+ * today, breaks the moment a handler uses it:
193
+ * - nodii-crm-service, nodii-kyc-service, nodii-ticketing-service,
194
+ * nodii-product-service
195
+ *
196
+ * Does NOT need it: nodii-geo-service (no authenticated pool at all —
197
+ * 2-pool), nodii-edge (no `user_tenant_ids`, RLS mode `single`),
198
+ * nodii-customer-service and nodii-vendor-service (already GUC-bodied),
199
+ * nodii-auth / nodii-hr-service / nodii-task-tracking / nodii-tenant-service
200
+ * (REAL membership helpers).
201
+ *
202
+ * WHAT YOU GIVE UP. The database stops verifying the principal. Isolation rests
203
+ * entirely on the application binding `app.tenant_id` correctly — RLS degrades
204
+ * from an independent second layer into "the app said so". A forged or simply
205
+ * mistaken tenant binding becomes access. That is a real reduction in
206
+ * defence-in-depth and it is why this is a separate, named, reported constant
207
+ * rather than a boolean flag on the strict one.
208
+ *
209
+ * THE EXIT. Land a membership mirror, point `auth.user_tenant_ids` at it, then
210
+ * apply {@link SESSION_TENANT_IDS_SQL} over this. No policy changes — the
211
+ * predicate at every call site is identical for both variants.
212
+ */
213
+ export const SESSION_TENANT_IDS_DEGRADED_SQL = `CREATE OR REPLACE FUNCTION auth.session_tenant_ids() RETURNS SETOF uuid
214
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
215
+ SET search_path = pg_catalog, public, pg_temp
216
+ AS $session$
217
+ DECLARE
218
+ v_tenant uuid;
219
+ BEGIN
220
+ -- ${SESSION_TENANT_IDS_DEGRADED_MARKER}: no membership mirror in this service,
221
+ -- so the session tenant is accepted WITHOUT proving the principal holds it.
222
+ -- Replace with the strict resolver as soon as a mirror exists.
223
+ IF nullif(current_setting('app.user_id', true), '') IS NULL THEN
224
+ RAISE EXCEPTION 'db_rls_session_user_missing: app.user_id is not bound on this connection'
225
+ USING ERRCODE = '42501',
226
+ HINT = 'Open the session with withUserContext({ userId, tenantId }, fn) from @nodii/db-rls.';
227
+ END IF;
228
+ v_tenant := nullif(current_setting('app.tenant_id', true), '')::uuid;
229
+ IF v_tenant IS NULL THEN
230
+ RAISE EXCEPTION 'db_rls_session_tenant_missing: app.tenant_id is not bound on this connection'
231
+ USING ERRCODE = '42501',
232
+ HINT = 'Every authenticated-pool session must be EXPLICITLY tenant-scoped. Open it with withUserContext({ userId, tenantId }, fn) from @nodii/db-rls.';
233
+ END IF;
234
+ RETURN QUERY SELECT v_tenant;
235
+ END
236
+ $session$;
237
+ `;
238
+ /** Grant for the resolver. Separate from {@link AUTH_SCHEMA_GRANTS_SQL} for the
239
+ * same reason: the roles must exist before the GRANT runs. */
240
+ export const SESSION_TENANT_IDS_GRANTS_SQL = `GRANT EXECUTE ON FUNCTION auth.session_tenant_ids() TO nodii_services, authenticated;
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$;
54
327
  `;
55
328
  /**
56
329
  * Grants for the 7 helper functions. Emitted separately because they
@@ -84,8 +357,62 @@ export async function applyAuthSchemaMigrations(client, opts = {}) {
84
357
  // SqlExecutor`; cast to the minimal shape.
85
358
  const exec = client;
86
359
  await exec.query(AUTH_SCHEMA_SQL);
360
+ // The resolver is applied unconditionally alongside the seven. A database
361
+ // where the policies reference `auth.session_tenant_ids()` but the function is
362
+ // absent does not merely misbehave — Postgres validates the reference at
363
+ // CREATE POLICY time, so the migration itself fails with `function
364
+ // auth.session_tenant_ids() does not exist`. Installing both together makes
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.
374
+ await exec.query(opts.degradedSessionResolver
375
+ ? SESSION_TENANT_IDS_DEGRADED_SQL
376
+ : INSTALL_SESSION_RESOLVER_SQL);
377
+ // The gate ships with the resolver, always. See sessionFenceBootstrapSql.
378
+ await exec.query(TENANT_FENCE_GATE_SQL);
87
379
  if (includeGrants) {
88
380
  await exec.query(AUTH_SCHEMA_GRANTS_SQL);
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);
89
416
  }
90
417
  }
91
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;;;;GAIG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG;;;;;;;;CAQrC,CAAC;AAYF;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC7C,MAAmB,EACnB,OAAoC,EAAE;IAEtC,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,IAAI,aAAa,EAAE,CAAC;QAClB,MAAM,IAAI,CAAC,KAAK,CAAC,sBAAsB,CAAC,CAAC;IAC3C,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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"}