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