@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.
@@ -0,0 +1,262 @@
1
+ // Request-scoped VERIFIED-claims context — the tenant binding for the
2
+ // authenticated pool.
3
+ //
4
+ // WHY THIS EXISTS
5
+ // ---------------
6
+ // Until this module, `withUserContext({ userId, tenantId }, fn)` took the
7
+ // tenant as a PARAMETER. RLS then trusted `app.tenant_id` completely: the
8
+ // authenticated-pool predicate resolves the session tenant and never proves
9
+ // the principal actually holds it (membership was proven exactly once, in
10
+ // nodii-auth at login, and nowhere near the database). So any handler that
11
+ // passed a REQUEST-DERIVED tenant — `req.body.tenantId`, a path param, a
12
+ // header — instead of the verified JWT claim silently defeated row-level
13
+ // security, and nothing in the stack caught it. 271 call sites across 11
14
+ // services were one line away from serving another tenant's rows.
15
+ //
16
+ // The fix is structural, not advisory: the tenant is no longer expressible
17
+ // as an argument. It is read from a request-scoped store that carries the
18
+ // VERIFIED claim set, established ONCE per request by auth middleware. A
19
+ // handler cannot pass a tenant, cannot rebind the store to a different
20
+ // tenant, and cannot mutate a bound claim set. There is no override option
21
+ // and no default — an unbound context throws.
22
+ //
23
+ // SCOPE — the AUTHENTICATED path, plus ONE fence on the system path.
24
+ // `withSystemContext({ tenantId }, fn)` (workers, cron, S2S, webhooks: a
25
+ // legitimately resolved tenant and no user claim) keeps its explicit
26
+ // parameter — with claims UNBOUND it is completely unchanged. But when it
27
+ // is called INSIDE a claims-bound request it now refuses unless the caller
28
+ // passes an explicit `{ reason }` (see
29
+ // {@link assertSystemContextEscapeAllowed}): the services-pool predicate is
30
+ // the bare `tenant_id = auth.get_tenant_id()` with no membership proof, so
31
+ // an authenticated request that falls through to it can aim the session at
32
+ // ANY tenant — the seam where the ₹250,000 cross-tenant refund reproduced.
33
+ // `withPlatformOpsContext` (Nodii staff, RBAC-gated by a different
34
+ // predicate against `platform_ops_grants_replica`) is not touched.
35
+ //
36
+ // SUBPATH-SPLIT SAFETY (@nodii/grpc-auth `user-context-als.ts` class).
37
+ // @nodii/db-rls ships five entry points (`.`, `/drizzle`,
38
+ // `/drizzle/platform-ops-grants`, `/postgres-js`, `/node-postgres`). Under
39
+ // plain `tsc` they share one `dist/claims-context.js`, but a bundler that
40
+ // splits per entry point (esbuild for Lambda) would DUPLICATE a
41
+ // module-level `new AsyncLocalStorage()`: middleware would bind on one
42
+ // copy while the secure-client factory read another. The single instance is
43
+ // therefore held on a non-configurable `globalThis` slot so every bundle
44
+ // copy in a realm shares it. Note the failure mode here is loud rather than
45
+ // silent — an unbound read throws `NoVerifiedTenantClaims` — but "loud in
46
+ // production" is not the bar; we want it to be impossible.
47
+ import { AsyncLocalStorage } from "node:async_hooks";
48
+ import { InvalidVerifiedTenantClaims, NoVerifiedTenantClaims, SystemContextUnderClaimsRefused, TenantArgumentRefused, VerifiedTenantClaimsAlreadyBound, } from "./types.js";
49
+ const SLOT = "__nodii_db_rls_verified_tenant_claims_als__";
50
+ function resolveStore() {
51
+ const realm = globalThis;
52
+ const existing = realm[SLOT];
53
+ if (existing) {
54
+ return existing;
55
+ }
56
+ const als = new AsyncLocalStorage();
57
+ // Non-writable + non-configurable: the store cannot be swapped out from
58
+ // under the fence at runtime (a swapped store is a tenant override with
59
+ // extra steps).
60
+ Object.defineProperty(realm, SLOT, {
61
+ value: als,
62
+ enumerable: false,
63
+ writable: false,
64
+ configurable: false,
65
+ });
66
+ return als;
67
+ }
68
+ const store = resolveStore();
69
+ function describe(value) {
70
+ if (value === null)
71
+ return "null";
72
+ if (typeof value === "string")
73
+ return `a string (${JSON.stringify(value)})`;
74
+ if (typeof value === "object") {
75
+ const keys = Object.keys(value);
76
+ return `an object with keys [${keys.join(", ")}]`;
77
+ }
78
+ return `a ${typeof value}`;
79
+ }
80
+ /**
81
+ * Every claim field is validated EXPLICITLY against `typeof` + blankness.
82
+ * There is deliberately no `value || fallback` / `value ?? fallback` here:
83
+ * a configured-but-falsy value quietly becoming a default is a failure mode
84
+ * this fleet has shipped more than once, and on this path the "default"
85
+ * would be a tenant.
86
+ */
87
+ function requireNonBlankString(field, value) {
88
+ if (typeof value !== "string") {
89
+ throw new InvalidVerifiedTenantClaims(field, `expected a non-blank string, received ${describe(value)}`);
90
+ }
91
+ if (value.trim() === "") {
92
+ throw new InvalidVerifiedTenantClaims(field, "received a blank string — an empty tenant binds an empty GUC and the RLS predicate then resolves nothing, which reads as 'no rows' rather than 'refused'");
93
+ }
94
+ return value;
95
+ }
96
+ /**
97
+ * Copy the caller's claim fields into a fresh FROZEN object.
98
+ *
99
+ * The copy is load-bearing, not hygiene. Without it a handler could hold a
100
+ * reference to the object the middleware bound and mutate `tenantId` after
101
+ * the fact — the store would hand the mutated tenant to the next
102
+ * `withUserContext`, which is precisely the forgery this module exists to
103
+ * make unrepresentable. Extra properties are dropped for the same reason.
104
+ */
105
+ function sealClaims(claims) {
106
+ if (claims === null || typeof claims !== "object") {
107
+ throw new InvalidVerifiedTenantClaims("claims", `expected an object with { userId, tenantId }, received ${describe(claims)}`);
108
+ }
109
+ const userId = requireNonBlankString("userId", claims.userId);
110
+ const tenantId = requireNonBlankString("tenantId", claims.tenantId);
111
+ const sealed = {
112
+ userId,
113
+ tenantId,
114
+ };
115
+ // `sourceIp` is forensic-only, so it stays optional — but if it is
116
+ // supplied it must be usable. Binding an empty `app.source_ip` GUC would
117
+ // put a blank string in the audit trail and read as "we recorded it".
118
+ if (claims.sourceIp !== undefined) {
119
+ sealed.sourceIp = requireNonBlankString("sourceIp", claims.sourceIp);
120
+ }
121
+ return Object.freeze(sealed);
122
+ }
123
+ /** Read a tenant off an UNVALIDATED object, for an error message only. */
124
+ function peekTenant(claims) {
125
+ if (claims !== null && typeof claims === "object") {
126
+ const candidate = claims.tenantId;
127
+ if (typeof candidate === "string" && candidate.trim() !== "") {
128
+ return candidate;
129
+ }
130
+ }
131
+ return "<unreadable>";
132
+ }
133
+ /**
134
+ * Establish the request-scoped verified-claims context. Call this ONCE per
135
+ * request, from auth middleware, IMMEDIATELY after the JWT has been
136
+ * verified — and pass the values off the VERIFIED claim set, never off the
137
+ * request body / path / headers.
138
+ *
139
+ * Everything downstream (`withUserContext`, `withUserContextDb`,
140
+ * `withUserContextDbPostgresJs`, `autoDispatch`) reads the tenant from
141
+ * here. Nothing downstream can supply one.
142
+ *
143
+ * ```ts
144
+ * app.use(async (c, next) => {
145
+ * const claims = await verifyJwt(c.req.header("authorization"));
146
+ * return runWithVerifiedTenantClaims(
147
+ * { userId: claims.sub, tenantId: claims.tenant_id },
148
+ * () => next(),
149
+ * );
150
+ * });
151
+ * ```
152
+ *
153
+ * Returns whatever `fn` returns (including a promise), so it composes with
154
+ * Hono / Express / gRPC interceptor shapes unchanged.
155
+ *
156
+ * Throws {@link VerifiedTenantClaimsAlreadyBound} if claims are already
157
+ * bound in the current async context. Re-binding is refused
158
+ * UNCONDITIONALLY — including to identical claims. A nested bind is the
159
+ * one shape that could re-open the hole (bind in middleware, re-bind to a
160
+ * request-derived tenant in the handler), so it is not a thing that
161
+ * happens quietly. If you have hit this legitimately, the answer is to
162
+ * move the single bind to the outermost auth boundary, not to relax it.
163
+ *
164
+ * Throws {@link InvalidVerifiedTenantClaims} if `userId` / `tenantId` is
165
+ * missing, not a string, or blank.
166
+ */
167
+ export function runWithVerifiedTenantClaims(claims, fn) {
168
+ const alreadyBound = store.getStore();
169
+ if (alreadyBound) {
170
+ throw new VerifiedTenantClaimsAlreadyBound(alreadyBound.tenantId, peekTenant(claims));
171
+ }
172
+ if (typeof fn !== "function") {
173
+ throw new TypeError("runWithVerifiedTenantClaims: the second argument must be the callback to run inside the bound context.");
174
+ }
175
+ return store.run(sealClaims(claims), fn);
176
+ }
177
+ /**
178
+ * The verified claims bound to the current request, or `null` outside any
179
+ * bound context.
180
+ *
181
+ * This is the DIAGNOSTIC read (health probes, "am I on the authenticated
182
+ * path?" branches). Anything that is about to touch the database must use
183
+ * {@link requireVerifiedTenantClaims} so an unbound context fails closed
184
+ * instead of degrading into a nullish branch.
185
+ */
186
+ export function getVerifiedTenantClaimsOrNull() {
187
+ return store.getStore() ?? null;
188
+ }
189
+ /** Whether verified claims are bound in the current async context. */
190
+ export function hasVerifiedTenantClaims() {
191
+ return store.getStore() !== undefined;
192
+ }
193
+ /**
194
+ * The verified claims bound to the current request, or THROW.
195
+ *
196
+ * Fail closed and loud: there is no default tenant, no fallback to a
197
+ * passed value, and no empty result. A silently-empty result is how the
198
+ * crm 85-policy blackout stayed invisible for weeks.
199
+ */
200
+ export function requireVerifiedTenantClaims(helper) {
201
+ const claims = store.getStore();
202
+ if (!claims) {
203
+ throw new NoVerifiedTenantClaims(helper);
204
+ }
205
+ return claims;
206
+ }
207
+ /**
208
+ * Refuse the retired `helper(opts, fn)` call shape.
209
+ *
210
+ * TypeScript already rejects it — the helpers take the callback as their
211
+ * only argument, so all 271 call sites are compile errors until each one is
212
+ * examined. This is the runtime backstop for consumers that do not
213
+ * typecheck against this package (plain JS, a stale `dist/`, a `any`-typed
214
+ * wrapper): without it, the opts object would be invoked as the callback
215
+ * and the caller would get `opts is not a function`, which reads like a bug
216
+ * in db-rls rather than a migration they have not done.
217
+ */
218
+ export function assertNoTenantArgument(helper, fn) {
219
+ if (typeof fn !== "function") {
220
+ throw new TenantArgumentRefused(helper, describe(fn));
221
+ }
222
+ }
223
+ /**
224
+ * THE STEP-1 FENCE on the system leg. Called by `withSystemContext`,
225
+ * `withSystemContextDb` and `withSystemContextDbPostgresJs` before they
226
+ * open a services-pool transaction.
227
+ *
228
+ * Three outcomes:
229
+ *
230
+ * - **No claims bound** (boot, cron, worker, S2S, webhook) → returns
231
+ * `null`. The system helpers behave EXACTLY as before — this is the
232
+ * compat property that keeps the fence from breaking every worker.
233
+ * - **Claims bound + `opts.reason` is a non-blank string** → returns
234
+ * `{ boundTenantId, reason }` so the caller can annotate telemetry
235
+ * (`db_rls.cross_pool_nesting_total{outer=authenticated,inner=services,
236
+ * reason}`). The escape is sanctioned, named, and observable.
237
+ * - **Claims bound + no reason** → THROWS
238
+ * {@link SystemContextUnderClaimsRefused}, BEFORE any pool is touched.
239
+ *
240
+ * WHY. The services-pool predicate is the bare
241
+ * `tenant_id = auth.get_tenant_id()` — it trusts the GUC completely and
242
+ * proves no membership. An authenticated request that falls through to
243
+ * `withSystemContext` therefore re-opens the exact hole the verified-claims
244
+ * binding closes on the authenticated pool: the handler supplies the
245
+ * tenant, and nothing checks the principal holds it. That fall-through is
246
+ * where the ₹250,000 cross-tenant refund reproduces. Requiring an explicit
247
+ * `{ reason }` turns a silent fall-through into a deliberate, reviewable,
248
+ * telemetry-visible act — and everything else into a throw.
249
+ */
250
+ export function assertSystemContextEscapeAllowed(helper, opts) {
251
+ const claims = store.getStore();
252
+ if (!claims)
253
+ return null;
254
+ const reason = opts?.reason;
255
+ // Explicit typeof + blankness — a blank reason is not a reason, and there
256
+ // is deliberately no `reason || default` on this path.
257
+ if (typeof reason !== "string" || reason.trim() === "") {
258
+ throw new SystemContextUnderClaimsRefused(helper, claims.tenantId, typeof opts?.tenantId === "string" ? opts.tenantId : "<unreadable>");
259
+ }
260
+ return { boundTenantId: claims.tenantId, reason };
261
+ }
262
+ //# sourceMappingURL=claims-context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claims-context.js","sourceRoot":"","sources":["../src/claims-context.ts"],"names":[],"mappings":"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"}
package/dist/context.d.ts CHANGED
@@ -1,15 +1,31 @@
1
1
  import { type OwnerContextOpts } from "./migrate-guard.js";
2
- import { type AutoDispatchOpts, type PgTx, type PlatformOpsContextOpts, type SystemContextOpts, type UserContextOpts, type UserMembershipContextOpts } from "./types.js";
2
+ import { type AutoDispatchOpts, type PgTx, type PlatformOpsContextOpts, type SystemContextOpts, 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
+ * BREAKING (unforgeable tenant binding): the tenant is NO LONGER an
12
+ * argument. It is read from the request's verified claims, bound once by
13
+ * auth middleware via `runWithVerifiedTenantClaims`. The previous shape,
14
+ * `withUserContext({ userId, tenantId }, fn)`, let a handler pass a
15
+ * request-derived tenant instead of the verified claim and silently defeat
16
+ * RLS — the database layer accepted `app.tenant_id` without ever proving
17
+ * the principal held it. The callback is now the only argument, so every
18
+ * call site is a compile error until it has been examined.
19
+ *
20
+ * On the FIRST call in a process it also runs the tenant-fence gate (see
21
+ * {@link assertFenceOnce}) and refuses to yield the tx if the fence is broken.
22
+ *
23
+ * Throws `NoVerifiedTenantClaims` if no claims are bound — never falls back
24
+ * to a default or to a passed value.
9
25
  * Throws `DbRlsNotInitialized` if `initDbRls` hasn't run.
10
26
  * Wraps any error thrown by `fn` in `TransactionRolledBack`.
11
27
  */
12
- export declare function withUserContext<T>(opts: UserContextOpts, fn: (tx: PgTx) => Promise<T>): Promise<T>;
28
+ export declare function withUserContext<T>(fn: (tx: PgTx) => Promise<T>): Promise<T>;
13
29
  /**
14
30
  * @deprecated REMOVED — always throws {@link CrossTenantSessionRefused}.
15
31
  *
@@ -25,8 +41,11 @@ export declare function withUserContext<T>(opts: UserContextOpts, fn: (tx: PgTx)
25
41
  * task, got HTTP 200, and released a 250,000 INR refund. Rewriting predicates
26
42
  * while leaving this callable would have changed nothing for any caller of it.
27
43
  *
28
- * Migrate to `withUserContext({ userId, tenantId }, fn)` — the same pool, with
29
- * the tenant the request actually resolved to. If a caller genuinely needs to
44
+ * Migrate to `withUserContext(fn)` — the same pool, with the tenant the
45
+ * request's VERIFIED claims resolved to. That tenant is bound once by auth
46
+ * middleware (`runWithVerifiedTenantClaims`) and is no longer an argument at
47
+ * all, so the "pass the tenant the request resolved to" step this docblock
48
+ * used to ask for cannot be got wrong. If a caller genuinely needs to
30
49
  * know which tenants a user belongs to (a tenant switcher, a login
31
50
  * tenant-picker), query `auth.user_tenant_ids` directly from a services-pool
32
51
  * context; that function is deliberately left intact and still answers exactly
@@ -42,6 +61,22 @@ export declare function withUserMembershipContext<T>(_opts: UserMembershipContex
42
61
  * withSystemContext — opens a tx on the `services` (nodii_services) pool,
43
62
  * sets `app.tenant_id` via tx-scoped `set_config`, yields the tx, commits
44
63
  * / rolls back per the PgPool contract.
64
+ *
65
+ * THE STEP-1 FENCE (panel finding). The services-pool predicate is the bare
66
+ * `tenant_id = auth.get_tenant_id()` — no membership proof — so an
67
+ * authenticated request that falls through to this helper can aim the
68
+ * session at ANY tenant. That fall-through is the un-fenced seam where the
69
+ * ₹250,000 cross-tenant refund reproduced. Therefore:
70
+ *
71
+ * - **Claims UNBOUND** (boot, cron, workers, S2S, webhooks): unchanged.
72
+ * This is the compat property that keeps the fence from breaking every
73
+ * worker in the fleet.
74
+ * - **Claims BOUND** (`hasVerifiedTenantClaims()` is true): REFUSED with
75
+ * {@link SystemContextUnderClaimsRefused} — a throw, before any pool is
76
+ * acquired — unless the caller passes an explicit non-blank
77
+ * `{ reason }`. A sanctioned escape is emitted to telemetry as
78
+ * `db_rls.cross_pool_nesting_total{outer=authenticated,inner=services,reason}`
79
+ * so every crossing is named, counted, and audit-greppable.
45
80
  */
46
81
  export declare function withSystemContext<T>(opts: SystemContextOpts, fn: (tx: PgTx) => Promise<T>): Promise<T>;
47
82
  /**
@@ -103,17 +138,28 @@ export declare function _resetWithPlatformOpsDeprecationWarnedForTests(): void;
103
138
  */
104
139
  export declare function withPlatformOps<T>(opts: PlatformOpsContextOpts, fn: (tx: PgTx) => Promise<T>): Promise<T>;
105
140
  /**
106
- * autoDispatch — chooses the right pool based on what's bound.
141
+ * autoDispatch — chooses the pool from HOW IT IS CALLED, not from a bag of
142
+ * optional fields.
143
+ *
144
+ * - `autoDispatch(fn)` → authenticated pool, tenant from the request's
145
+ * verified claims. Throws `NoVerifiedTenantClaims` if none are bound.
146
+ * - `autoDispatch({ systemTenantId }, fn)` → services pool, explicit
147
+ * tenant. The system leg (workers, cron, S2S, webhooks) legitimately
148
+ * resolves its own tenant and keeps its parameter.
107
149
  *
108
- * - `authContext` present → dispatch through withUserContext
109
- * (uses authContext.userId + authContext.tenantId).
110
- * - only `tenantId` present → dispatch through withSystemContext.
111
- * - neither → throw `MissingTenantContext`.
150
+ * This is the ALS-bound dispatch the original docblock deferred
151
+ * (`// TODO db-rls-v0.1.x: ALS-bound auto-dispatch`). The retired shape
152
+ * was `autoDispatch({ authContext?, tenantId? }, fn)` — `authContext` was
153
+ * assembled by the handler, so autoDispatch was a second way to hand the
154
+ * database a request-derived tenant. It is gone.
112
155
  *
113
- * This mirrors `withRequestContext` from the spec § 5.4 with one
114
- * simplification: the auth context is passed explicitly rather than
115
- * being read from ALS. Real auth-sdk integration lands in v0.1.x —
116
- * `// TODO db-rls-v0.1.x: ALS-bound auto-dispatch`.
156
+ * The system leg dispatches through {@link withSystemContext}, so it
157
+ * inherits the step-1 fence: INSIDE a claims-bound request an explicit
158
+ * `systemTenantId` needs a non-blank `reason` too, or it throws
159
+ * `SystemContextUnderClaimsRefused`. There is deliberately no silent
160
+ * precedence rule — the caller either names the system leg (and, under
161
+ * claims, the reason for it) or takes the authenticated leg.
117
162
  */
163
+ export declare function autoDispatch<T>(fn: (tx: PgTx) => Promise<T>): Promise<T>;
118
164
  export declare function autoDispatch<T>(opts: AutoDispatchOpts, fn: (tx: PgTx) => Promise<T>): Promise<T>;
119
165
  //# sourceMappingURL=context.d.ts.map
@@ -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":"AAiBA,OAAO,EAEL,KAAK,gBAAgB,EACtB,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EACL,KAAK,gBAAgB,EAGrB,KAAK,IAAI,EACT,KAAK,sBAAsB,EAC3B,KAAK,iBAAiB,EAEtB,KAAK,yBAAyB,EAC/B,MAAM,YAAY,CAAC;AAepB,yDAAyD;AACzD,wBAAgB,uBAAuB,IAAI,IAAI,CAE9C;AAyCD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAsB,eAAe,CAAC,CAAC,EACrC,EAAE,EAAE,CAAC,EAAE,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,GAC3B,OAAO,CAAC,CAAC,CAAC,CAmCZ;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;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;;;;;;;;;;;;;;;;;;;;GAoBG;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,CAiBZ;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;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAsB,YAAY,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;AAChF,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,CAAC"}
package/dist/context.js CHANGED
@@ -4,27 +4,95 @@
4
4
  // the canonical GUCs via `set_config(..., true)` (tx-scoped), yields, and
5
5
  // commits / rollbacks per the underlying PgPool's withTransaction
6
6
  // contract.
7
+ import { assertNoTenantArgument, assertSystemContextEscapeAllowed, requireVerifiedTenantClaims, } from "./claims-context.js";
7
8
  import { requirePool } from "./init.js";
8
- import { recordOwnerContextInvocation } from "./metrics.js";
9
+ import { recordCrossPoolNesting, recordOwnerContextInvocation, } from "./metrics.js";
9
10
  import { assertMigrateAllowed, } from "./migrate-guard.js";
11
+ import { TENANT_FENCE_GATE_CALL_SQL } from "./tenant-fence-gate.js";
10
12
  import { CrossTenantSessionRefused, MissingTenantContext, TransactionRolledBack, } from "./types.js";
13
+ /**
14
+ * Process-local latch for the first-use fence gate. `null` until the gate has
15
+ * run; thereafter the settled promise, so the check costs one round-trip per
16
+ * PROCESS rather than one per transaction.
17
+ *
18
+ * A REJECTED promise is retained deliberately. A broken fence is not a
19
+ * transient condition — it is a property of the schema — so re-checking on
20
+ * every subsequent request would turn one clear failure into a storm of
21
+ * identical ones while adding a catalog scan to each. The service stays down
22
+ * and says the same thing every time, which is the intended behaviour.
23
+ */
24
+ let fenceGate = null;
25
+ /** Test-only. Not exported through the public barrel. */
26
+ export function _resetFenceGateForTests() {
27
+ fenceGate = null;
28
+ }
29
+ /**
30
+ * THE GATE. Runs `auth.assert_tenant_fence()` once per process, on the first
31
+ * authenticated-pool checkout.
32
+ *
33
+ * WHY HERE, AND NOT IN A DEPLOY SCRIPT.
34
+ *
35
+ * The obvious home is a `db:verify` step in each service's deploy. It does not
36
+ * work, and this is measured rather than argued: SIX of the ten services this
37
+ * release has to reach — billing, kyc, notification, task-tracking, tenant and
38
+ * geo — have NO database step in `deploy-dev.yml` at all. Their `db:setup`
39
+ * runs in `ci.yml` against a throwaway Postgres. A gate wired there is green
40
+ * forever on the services that need it most, and reported as a pass.
41
+ *
42
+ * WHY NOT `initDbRls`. Boot order forbids it. billing runs `applySagaMigrations`
43
+ * and `applyDsarMigrations` at boot, AFTER its pools are constructed — a check
44
+ * at `initDbRls` time would fire against a schema that is about to be fixed
45
+ * two statements later and fail every deploy.
46
+ *
47
+ * First-authenticated-use is the earliest point that is both after all boot
48
+ * migrations and before any tenant row is served, and it is reached by
49
+ * construction: there is no way to open an authenticated transaction without
50
+ * passing through here. That is the "cannot forget to opt in" property. It is
51
+ * not a flag, a config key, or a script somebody adds to a pipeline — it is the
52
+ * function you must call to get the connection.
53
+ *
54
+ * WHY IT IS NOT THE `verifyRls` SHAPE. `verifyRls` takes `requiredTables` from
55
+ * the caller, and the author who forgets `ENABLE ROW LEVEL SECURITY` forgets
56
+ * the list entry in the same migration — so it sits wired and green while
57
+ * unprotected tables accumulate behind it. `auth.assert_tenant_fence()` asks
58
+ * the catalog which tables carry a tenant column. Nobody maintains a list,
59
+ * so nobody can omit the entry that mattered.
60
+ */
61
+ async function assertFenceOnce(tx) {
62
+ if (fenceGate === null) {
63
+ fenceGate = tx.exec(TENANT_FENCE_GATE_CALL_SQL);
64
+ }
65
+ return fenceGate;
66
+ }
11
67
  /**
12
68
  * withUserContext — opens a tx on the `authenticated` pool, sets
13
69
  * `app.user_id` + `app.tenant_id` (+ optional `app.source_ip`) via
14
70
  * `set_config(..., true)` tx-scoped GUCs, yields the tx, commits/rolls
15
71
  * back per the PgPool contract.
16
72
  *
73
+ * BREAKING (unforgeable tenant binding): the tenant is NO LONGER an
74
+ * argument. It is read from the request's verified claims, bound once by
75
+ * auth middleware via `runWithVerifiedTenantClaims`. The previous shape,
76
+ * `withUserContext({ userId, tenantId }, fn)`, let a handler pass a
77
+ * request-derived tenant instead of the verified claim and silently defeat
78
+ * RLS — the database layer accepted `app.tenant_id` without ever proving
79
+ * the principal held it. The callback is now the only argument, so every
80
+ * call site is a compile error until it has been examined.
81
+ *
82
+ * On the FIRST call in a process it also runs the tenant-fence gate (see
83
+ * {@link assertFenceOnce}) and refuses to yield the tx if the fence is broken.
84
+ *
85
+ * Throws `NoVerifiedTenantClaims` if no claims are bound — never falls back
86
+ * to a default or to a passed value.
17
87
  * Throws `DbRlsNotInitialized` if `initDbRls` hasn't run.
18
88
  * Wraps any error thrown by `fn` in `TransactionRolledBack`.
19
89
  */
20
- export async function withUserContext(opts, fn) {
21
- // Reject a blank principal or tenant HERE rather than binding an empty GUC
22
- // and letting `auth.session_tenant_ids()` raise 42501 on the first query.
23
- // Both fail closed, but this one names the caller instead of a policy.
24
- if (!opts?.userId)
25
- throw new MissingTenantContext();
26
- if (!opts?.tenantId)
27
- throw new MissingTenantContext();
90
+ export async function withUserContext(fn) {
91
+ assertNoTenantArgument("withUserContext", fn);
92
+ // Blank principal / tenant were rejected at BIND time by
93
+ // runWithVerifiedTenantClaims, so a bound claim set is always usable
94
+ // here — no re-validation, and nothing to fall back to.
95
+ const opts = requireVerifiedTenantClaims("withUserContext");
28
96
  const pool = requirePool("authenticated");
29
97
  return pool.withTransaction(async (tx) => {
30
98
  // Canonical GUC namespace is `app.*` (08-rls §2/§6): the generated
@@ -38,6 +106,16 @@ export async function withUserContext(opts, fn) {
38
106
  if (opts.sourceIp !== undefined) {
39
107
  await tx.setConfig("app.source_ip", opts.sourceIp);
40
108
  }
109
+ // BEFORE `fn`, deliberately. The point of the gate is that no tenant row is
110
+ // served through a broken fence — running it afterwards would mean the
111
+ // first request reads cross-tenant data and THEN reports the problem.
112
+ //
113
+ // NOT wrapped in TransactionRolledBack: that wrapper exists to mark "the
114
+ // caller's own code threw". This is the library refusing to hand over the
115
+ // transaction, and it must surface with its own message and SQLSTATE
116
+ // intact so the on-call engineer sees the offender list rather than a
117
+ // generic rollback.
118
+ await assertFenceOnce(tx);
41
119
  try {
42
120
  return await fn(tx);
43
121
  }
@@ -61,8 +139,11 @@ export async function withUserContext(opts, fn) {
61
139
  * task, got HTTP 200, and released a 250,000 INR refund. Rewriting predicates
62
140
  * while leaving this callable would have changed nothing for any caller of it.
63
141
  *
64
- * Migrate to `withUserContext({ userId, tenantId }, fn)` — the same pool, with
65
- * the tenant the request actually resolved to. If a caller genuinely needs to
142
+ * Migrate to `withUserContext(fn)` — the same pool, with the tenant the
143
+ * request's VERIFIED claims resolved to. That tenant is bound once by auth
144
+ * middleware (`runWithVerifiedTenantClaims`) and is no longer an argument at
145
+ * all, so the "pass the tenant the request resolved to" step this docblock
146
+ * used to ask for cannot be got wrong. If a caller genuinely needs to
66
147
  * know which tenants a user belongs to (a tenant switcher, a login
67
148
  * tenant-picker), query `auth.user_tenant_ids` directly from a services-pool
68
149
  * context; that function is deliberately left intact and still answers exactly
@@ -80,8 +161,28 @@ export async function withUserMembershipContext(_opts, _fn) {
80
161
  * withSystemContext — opens a tx on the `services` (nodii_services) pool,
81
162
  * sets `app.tenant_id` via tx-scoped `set_config`, yields the tx, commits
82
163
  * / rolls back per the PgPool contract.
164
+ *
165
+ * THE STEP-1 FENCE (panel finding). The services-pool predicate is the bare
166
+ * `tenant_id = auth.get_tenant_id()` — no membership proof — so an
167
+ * authenticated request that falls through to this helper can aim the
168
+ * session at ANY tenant. That fall-through is the un-fenced seam where the
169
+ * ₹250,000 cross-tenant refund reproduced. Therefore:
170
+ *
171
+ * - **Claims UNBOUND** (boot, cron, workers, S2S, webhooks): unchanged.
172
+ * This is the compat property that keeps the fence from breaking every
173
+ * worker in the fleet.
174
+ * - **Claims BOUND** (`hasVerifiedTenantClaims()` is true): REFUSED with
175
+ * {@link SystemContextUnderClaimsRefused} — a throw, before any pool is
176
+ * acquired — unless the caller passes an explicit non-blank
177
+ * `{ reason }`. A sanctioned escape is emitted to telemetry as
178
+ * `db_rls.cross_pool_nesting_total{outer=authenticated,inner=services,reason}`
179
+ * so every crossing is named, counted, and audit-greppable.
83
180
  */
84
181
  export async function withSystemContext(opts, fn) {
182
+ const sanctioned = assertSystemContextEscapeAllowed("withSystemContext", opts);
183
+ if (sanctioned) {
184
+ recordCrossPoolNesting("authenticated", "services", sanctioned.reason);
185
+ }
85
186
  const pool = requirePool("services");
86
187
  return pool.withTransaction(async (tx) => {
87
188
  await tx.setConfig("app.tenant_id", opts.tenantId);
@@ -206,30 +307,19 @@ export async function withPlatformOps(opts, fn) {
206
307
  }
207
308
  return withPlatformOpsContext(opts, fn);
208
309
  }
209
- /**
210
- * autoDispatch — chooses the right pool based on what's bound.
211
- *
212
- * - `authContext` present → dispatch through withUserContext
213
- * (uses authContext.userId + authContext.tenantId).
214
- * - only `tenantId` present → dispatch through withSystemContext.
215
- * - neither → throw `MissingTenantContext`.
216
- *
217
- * This mirrors `withRequestContext` from the spec § 5.4 with one
218
- * simplification: the auth context is passed explicitly rather than
219
- * being read from ALS. Real auth-sdk integration lands in v0.1.x —
220
- * `// TODO db-rls-v0.1.x: ALS-bound auto-dispatch`.
221
- */
222
- export async function autoDispatch(opts, fn) {
223
- if (opts.authContext) {
224
- return withUserContext({
225
- userId: opts.authContext.userId,
226
- tenantId: opts.authContext.tenantId,
227
- sourceIp: opts.authContext.sourceIp,
228
- }, fn);
310
+ export async function autoDispatch(optsOrFn, maybeFn) {
311
+ if (typeof optsOrFn === "function") {
312
+ return withUserContext(optsOrFn);
313
+ }
314
+ if (typeof maybeFn !== "function") {
315
+ throw new TypeError("autoDispatch: when opts are passed, the second argument must be the callback.");
229
316
  }
230
- if (opts.tenantId !== undefined) {
231
- return withSystemContext({ tenantId: opts.tenantId }, fn);
317
+ // Explicit, non-blank, no fallback: `systemTenantId` is required by the
318
+ // type, and a blank one is refused rather than defaulted.
319
+ if (typeof optsOrFn?.systemTenantId !== "string" ||
320
+ optsOrFn.systemTenantId.trim() === "") {
321
+ throw new MissingTenantContext("autoDispatch({ systemTenantId }, fn) needs a non-blank systemTenantId for the services-pool leg. For the authenticated leg call autoDispatch(fn) with no opts — the tenant comes from the request's verified claims.");
232
322
  }
233
- throw new MissingTenantContext();
323
+ return withSystemContext({ tenantId: optsOrFn.systemTenantId, reason: optsOrFn.reason }, maybeFn);
234
324
  }
235
325
  //# sourceMappingURL=context.js.map
@@ -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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"}