@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 +79 -1
- package/bin/migrate-gen.mjs +11 -0
- package/dist/auth-schema.d.ts +87 -1
- package/dist/auth-schema.d.ts.map +1 -1
- package/dist/auth-schema.js +155 -5
- package/dist/auth-schema.js.map +1 -1
- package/dist/claims-context.d.ts +100 -0
- package/dist/claims-context.d.ts.map +1 -0
- package/dist/claims-context.js +262 -0
- package/dist/claims-context.js.map +1 -0
- package/dist/context.d.ts +59 -13
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +124 -34
- package/dist/context.js.map +1 -1
- package/dist/drizzle-client.d.ts +21 -3
- package/dist/drizzle-client.d.ts.map +1 -1
- package/dist/drizzle-client.js +28 -7
- package/dist/drizzle-client.js.map +1 -1
- package/dist/fence-cli.d.ts +21 -0
- package/dist/fence-cli.d.ts.map +1 -0
- package/dist/fence-cli.js +191 -0
- package/dist/fence-cli.js.map +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -2
- package/dist/index.js.map +1 -1
- package/dist/postgres-js-client.d.ts +26 -4
- package/dist/postgres-js-client.d.ts.map +1 -1
- package/dist/postgres-js-client.js +36 -8
- package/dist/postgres-js-client.js.map +1 -1
- package/dist/tenant-fence-gate.d.ts +54 -0
- package/dist/tenant-fence-gate.d.ts.map +1 -0
- package/dist/tenant-fence-gate.js +291 -0
- package/dist/tenant-fence-gate.js.map +1 -0
- package/dist/types.d.ts +161 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +126 -3
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
|
@@ -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
|
|
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>(
|
|
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(
|
|
29
|
-
*
|
|
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
|
|
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
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
package/dist/context.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"
|
|
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(
|
|
21
|
-
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
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(
|
|
65
|
-
*
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
323
|
+
return withSystemContext({ tenantId: optsOrFn.systemTenantId, reason: optsOrFn.reason }, maybeFn);
|
|
234
324
|
}
|
|
235
325
|
//# sourceMappingURL=context.js.map
|
package/dist/context.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA,gDAAgD;AAChD,EAAE;AACF,uEAAuE;AACvE,0EAA0E;AAC1E,kEAAkE;AAClE,YAAY;AAEZ,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,
|
|
1
|
+
{"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"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"}
|