@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
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`.
|
package/bin/migrate-gen.mjs
CHANGED
|
@@ -7,12 +7,15 @@
|
|
|
7
7
|
// Subcommands:
|
|
8
8
|
// migrate-gen <serviceName> [--out <dir>] [--stub]
|
|
9
9
|
// verify-rls --tables <a,b,c> [--ref <x,y>] [--schema <name>] [--db <url>]
|
|
10
|
+
// install-fence [--db <url>] [--skip-grants]
|
|
11
|
+
// verify-fence [--db <url>] [--schema <name>] [--strict-only]
|
|
10
12
|
//
|
|
11
13
|
// (Filename kept as migrate-gen.mjs for the package.json `bin` mapping;
|
|
12
14
|
// it is the single `nodii-db-rls` entrypoint, not migrate-gen only.)
|
|
13
15
|
|
|
14
16
|
import { runMigrateGenCli } from "../dist/migrate-gen.js";
|
|
15
17
|
import { runVerifyRlsCli } from "../dist/verify-rls-cli.js";
|
|
18
|
+
import { runInstallFenceCli, runVerifyFenceCli } from "../dist/fence-cli.js";
|
|
16
19
|
|
|
17
20
|
const argv = process.argv.slice(2);
|
|
18
21
|
const sub = argv[0];
|
|
@@ -22,6 +25,14 @@ if (sub === "verify-rls") {
|
|
|
22
25
|
// Coverage failures (1) + usage (2) go to stderr; success (0) to stdout.
|
|
23
26
|
(code === 0 ? console.log : console.error)(message);
|
|
24
27
|
process.exit(code);
|
|
28
|
+
} else if (sub === "install-fence") {
|
|
29
|
+
const { code, message } = await runInstallFenceCli(argv.slice(1));
|
|
30
|
+
(code === 0 ? console.log : console.error)(message);
|
|
31
|
+
process.exit(code);
|
|
32
|
+
} else if (sub === "verify-fence") {
|
|
33
|
+
const { code, message } = await runVerifyFenceCli(argv.slice(1));
|
|
34
|
+
(code === 0 ? console.log : console.error)(message);
|
|
35
|
+
process.exit(code);
|
|
25
36
|
} else {
|
|
26
37
|
// migrate-gen (default + back-compat: runMigrateGenCli accepts the
|
|
27
38
|
// leading "migrate-gen" token and prints usage on a missing serviceName).
|
package/dist/auth-schema.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { Pool, PoolClient } from "pg";
|
|
|
4
4
|
* compliance requirement. Identical byte-equal payload across TS / Python
|
|
5
5
|
* / Go ports (asserted by the parity-fence CI).
|
|
6
6
|
*/
|
|
7
|
-
export declare const AUTH_SCHEMA_SQL = "CREATE SCHEMA IF NOT EXISTS auth;\n\nCREATE OR REPLACE FUNCTION auth.set_user_id(p_user_id uuid) RETURNS void\nLANGUAGE sql AS $$ SELECT set_config('app.user_id', p_user_id::text, true) $$;\n\nCREATE OR REPLACE FUNCTION auth.set_tenant_id(p_tenant_id uuid) RETURNS void\nLANGUAGE sql AS $$ SELECT set_config('app.tenant_id', p_tenant_id::text, true) $$;\n\nCREATE OR REPLACE FUNCTION auth.init(p_user_id uuid, p_tenant_id uuid) RETURNS void\nLANGUAGE sql AS $$\n SELECT set_config('app.user_id', p_user_id::text, true);\n SELECT set_config('app.tenant_id', p_tenant_id::text, true);\n$$;\n\nCREATE OR REPLACE FUNCTION auth.init_tenant(p_tenant_id uuid) RETURNS void\nLANGUAGE sql AS $$ SELECT set_config('app.tenant_id', p_tenant_id::text, true) $$;\n\nCREATE OR REPLACE FUNCTION auth.get_user_id() RETURNS uuid\nLANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('app.user_id', true), '')::uuid $$;\n\nCREATE OR REPLACE FUNCTION auth.get_tenant_id() RETURNS uuid\nLANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('app.tenant_id', true), '')::uuid $$;\n\n-- Stub variant. Replaced in-place by migrate-gen's 0001_user_tenant_ids.sql
|
|
7
|
+
export declare const AUTH_SCHEMA_SQL = "CREATE SCHEMA IF NOT EXISTS auth;\n\nCREATE OR REPLACE FUNCTION auth.set_user_id(p_user_id uuid) RETURNS void\nLANGUAGE sql AS $$ SELECT set_config('app.user_id', p_user_id::text, true) $$;\n\nCREATE OR REPLACE FUNCTION auth.set_tenant_id(p_tenant_id uuid) RETURNS void\nLANGUAGE sql AS $$ SELECT set_config('app.tenant_id', p_tenant_id::text, true) $$;\n\nCREATE OR REPLACE FUNCTION auth.init(p_user_id uuid, p_tenant_id uuid) RETURNS void\nLANGUAGE sql AS $$\n SELECT set_config('app.user_id', p_user_id::text, true);\n SELECT set_config('app.tenant_id', p_tenant_id::text, true);\n$$;\n\nCREATE OR REPLACE FUNCTION auth.init_tenant(p_tenant_id uuid) RETURNS void\nLANGUAGE sql AS $$ SELECT set_config('app.tenant_id', p_tenant_id::text, true) $$;\n\nCREATE OR REPLACE FUNCTION auth.get_user_id() RETURNS uuid\nLANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('app.user_id', true), '')::uuid $$;\n\nCREATE OR REPLACE FUNCTION auth.get_tenant_id() RETURNS uuid\nLANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('app.tenant_id', true), '')::uuid $$;\n\n-- Stub variant, installed ONLY IF ABSENT. Replaced in-place by migrate-gen's\n-- 0001_user_tenant_ids.sql once the service has employees_mirror +\n-- customers_mirror tables.\n--\n-- IT IS A GUARDED CREATE, NOT A `CREATE OR REPLACE`, AND THAT IS LOAD-BEARING.\n-- As an unguarded REPLACE this statement silently overwrote a service's REAL\n-- membership helper with the dead stub every time the auth schema was\n-- re-applied \u2014 turning a correctly-fenced service into a deny-all one with no\n-- error, which is the same fail-silent shape this release exists to remove.\n-- It also made `INSTALL_SESSION_RESOLVER_SQL` unable to classify: running\n-- immediately after this block, it would read the stub this block had just\n-- stamped and conclude EVERY service needed the degraded body.\n--\n-- A bootstrap default must never win against the real thing it is standing in\n-- for.\nDO $ensure_user_tenant_ids$\nBEGIN\n IF to_regprocedure('auth.user_tenant_ids(uuid)') IS NULL THEN\n EXECUTE $utid$CREATE FUNCTION auth.user_tenant_ids(p_user_id uuid) RETURNS SETOF uuid\nLANGUAGE sql STABLE AS $utid_body$ SELECT NULL::uuid WHERE false $utid_body$;$utid$;\n END IF;\nEND\n$ensure_user_tenant_ids$;\n";
|
|
8
8
|
/**
|
|
9
9
|
* `auth.session_tenant_ids()` — THE authenticated-pool resolver.
|
|
10
10
|
*
|
|
@@ -124,6 +124,64 @@ export declare const SESSION_TENANT_IDS_DEGRADED_SQL = "CREATE OR REPLACE FUNCTI
|
|
|
124
124
|
/** Grant for the resolver. Separate from {@link AUTH_SCHEMA_GRANTS_SQL} for the
|
|
125
125
|
* same reason: the roles must exist before the GRANT runs. */
|
|
126
126
|
export declare const SESSION_TENANT_IDS_GRANTS_SQL = "GRANT EXECUTE ON FUNCTION auth.session_tenant_ids() TO nodii_services, authenticated;\n";
|
|
127
|
+
/**
|
|
128
|
+
* THE DELIVERY UNIT. One idempotent, re-runnable, SELF-CLASSIFYING statement
|
|
129
|
+
* that installs whichever resolver body this database can actually support.
|
|
130
|
+
*
|
|
131
|
+
* WHY IT SELF-CLASSIFIES RATHER THAN TAKING A FLAG.
|
|
132
|
+
*
|
|
133
|
+
* The strict and degraded bodies are not interchangeable, and picking the wrong
|
|
134
|
+
* one is silent in BOTH directions:
|
|
135
|
+
*
|
|
136
|
+
* - strict on a service whose `auth.user_tenant_ids` is the bootstrap stub
|
|
137
|
+
* (`SELECT NULL::uuid WHERE false`) intersects to the EMPTY SET, so every
|
|
138
|
+
* authenticated policy denies every row. No error. Measured live on
|
|
139
|
+
* `nodii-dev-postgres`: crm carries 507 authenticated policies, and a strict
|
|
140
|
+
* install there takes ALL 507 dark, not just the 85 already dead.
|
|
141
|
+
* - degraded on a service that HAS a real membership mirror silently drops
|
|
142
|
+
* the principal check, which is the defence-in-depth this release exists to
|
|
143
|
+
* add.
|
|
144
|
+
*
|
|
145
|
+
* A caller-supplied boolean makes that choice a thing somebody has to know. The
|
|
146
|
+
* library shipped exactly such a list in prose — and it was WRONG BY SIX
|
|
147
|
+
* SERVICES on the first pass and had to be re-derived from the repos. A list
|
|
148
|
+
* that was wrong once, maintained by hand, across fifteen repos, on a
|
|
149
|
+
* fail-silent switch, is not a control. It is a countdown.
|
|
150
|
+
*
|
|
151
|
+
* So the choice is made from what the database ACTUALLY CONTAINS, at the moment
|
|
152
|
+
* of install, by the same structural test `isStubMembershipHelper` applies in
|
|
153
|
+
* TypeScript: a membership helper that is absent, or whose body can never
|
|
154
|
+
* return a row, cannot support the intersect. Nobody has to know anything, and
|
|
155
|
+
* there is no list to get wrong.
|
|
156
|
+
*
|
|
157
|
+
* D415 (LOCKED) is the governing decision: a single-tenant-session service
|
|
158
|
+
* whose `user_tenant_ids` is absent, stubbed or uncalled MAY fence on the
|
|
159
|
+
* session tenant — isolation-equivalent, forfeiting no cross-tenant protection.
|
|
160
|
+
* The predicate at every call site is IDENTICAL for both bodies, which is what
|
|
161
|
+
* makes this substitutable at all.
|
|
162
|
+
*
|
|
163
|
+
* IT IS ALSO D415's SUNSET, AUTOMATED. D415's exit path is "land a membership
|
|
164
|
+
* mirror → repoint the helper → swap in the strict body". Because this block
|
|
165
|
+
* re-reads the catalog on EVERY run and is `CREATE OR REPLACE`, the swap
|
|
166
|
+
* happens by itself on the next boot after the mirror lands. Nobody files a
|
|
167
|
+
* follow-up ticket, because there is nothing to follow up.
|
|
168
|
+
*
|
|
169
|
+
* THE ONE-WAY RATCHET. The condition is "absent OR stub", so a real helper
|
|
170
|
+
* ALWAYS yields strict. This block can upgrade degraded → strict; it can never
|
|
171
|
+
* downgrade strict → degraded. That direction is the one that matters: it means
|
|
172
|
+
* this delivery path cannot overwrite a correctly-strict service, and cannot
|
|
173
|
+
* overwrite crm's deliberately-degraded body with a strict one either, because
|
|
174
|
+
* crm's helper is still the stub and will keep classifying as such until it
|
|
175
|
+
* genuinely isn't.
|
|
176
|
+
*
|
|
177
|
+
* Byte-identical across the TS / Go / Python ports (asserted by the parity
|
|
178
|
+
* test). The nested dollar-quote tags are all distinct — `$fence_install$`
|
|
179
|
+
* outer, `$fence_strict$` / `$fence_degraded$` for the EXECUTE literals,
|
|
180
|
+
* `$session$` inside the bodies — so the statement survives every dollar-quote
|
|
181
|
+
* -aware splitter in the fleet, including the ones in @nodii/saga and
|
|
182
|
+
* @nodii/dsar that split the mirrors' DDL on `;\n`.
|
|
183
|
+
*/
|
|
184
|
+
export declare const INSTALL_SESSION_RESOLVER_SQL: string;
|
|
127
185
|
/**
|
|
128
186
|
* Grants for the 7 helper functions. Emitted separately because they
|
|
129
187
|
* reference roles that must exist before the GRANT runs — applied last
|
|
@@ -154,6 +212,34 @@ export declare function applyAuthSchemaMigrations(client: AnyExecutor, opts?: {
|
|
|
154
212
|
includeGrants?: boolean;
|
|
155
213
|
degradedSessionResolver?: boolean;
|
|
156
214
|
}): Promise<void>;
|
|
215
|
+
/**
|
|
216
|
+
* THE WHOLE DELIVERY UNIT AS ONE STRING — resolver + gate, self-classifying,
|
|
217
|
+
* idempotent, re-runnable, and safe to apply on every boot.
|
|
218
|
+
*
|
|
219
|
+
* This is what the three mirrors inline byte-equal, what the CLI applies, and
|
|
220
|
+
* what a service with no other delivery path should call directly from its boot
|
|
221
|
+
* sequence, BEFORE any code that opens an authenticated transaction:
|
|
222
|
+
*
|
|
223
|
+
* import { applySessionFenceBootstrap } from "@nodii/db-rls";
|
|
224
|
+
* await applySessionFenceBootstrap(ownerSql);
|
|
225
|
+
*
|
|
226
|
+
* Grants are separate (as everywhere else in this package) because the runtime
|
|
227
|
+
* roles must exist before a GRANT naming them can run, and on a fresh database
|
|
228
|
+
* they do not yet.
|
|
229
|
+
*/
|
|
230
|
+
export declare const SESSION_FENCE_BOOTSTRAP_SQL: string;
|
|
231
|
+
/**
|
|
232
|
+
* Apply {@link SESSION_FENCE_BOOTSTRAP_SQL} against an owner-privileged client.
|
|
233
|
+
*
|
|
234
|
+
* Idempotent and safe on every boot: the resolver and gate are
|
|
235
|
+
* `CREATE OR REPLACE`, the schema is `IF NOT EXISTS`, and the body selection
|
|
236
|
+
* re-derives from the catalog each run — so a service that lands a membership
|
|
237
|
+
* mirror upgrades itself from the degraded body to the strict one on its next
|
|
238
|
+
* restart, with no migration and no policy change (D415's exit path).
|
|
239
|
+
*/
|
|
240
|
+
export declare function applySessionFenceBootstrap(client: AnyExecutor, opts?: {
|
|
241
|
+
includeGrants?: boolean;
|
|
242
|
+
}): Promise<void>;
|
|
157
243
|
/**
|
|
158
244
|
* Composite full DDL — the schema + helpers + grants, joined.
|
|
159
245
|
* Provided for migrate-gen + parity-fence byte-equality.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-schema.d.ts","sourceRoot":"","sources":["../src/auth-schema.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"auth-schema.d.ts","sourceRoot":"","sources":["../src/auth-schema.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,IAAI,CAAC;AAO3C;;;;GAIG;AACH,eAAO,MAAM,eAAe,0uEA8C3B,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AACH,eAAO,MAAM,sBAAsB,iqCAuBlC,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,kCAAkC,yCACP,CAAC;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,eAAO,MAAM,+BAA+B,isCAwB3C,CAAC;AAEF;+DAC+D;AAC/D,eAAO,MAAM,6BAA6B,4FACzC,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AACH,eAAO,MAAM,4BAA4B,QA4BxC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,8oBAQlC,CAAC;AAEF;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACtC;AAED,KAAK,WAAW,GAAG,IAAI,GAAG,UAAU,GAAG,WAAW,CAAC;AAEnD;;;;;;;;;;;GAWG;AACH,wBAAsB,yBAAyB,CAC7C,MAAM,EAAE,WAAW,EACnB,IAAI,GAAE;IAAE,aAAa,CAAC,EAAE,OAAO,CAAC;IAAC,uBAAuB,CAAC,EAAE,OAAO,CAAA;CAAO,GACxE,OAAO,CAAC,IAAI,CAAC,CAgCf;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,2BAA2B,EAAE,MAEzC,CAAC;AAEF;;;;;;;;GAQG;AACH,wBAAsB,0BAA0B,CAC9C,MAAM,EAAE,WAAW,EACnB,IAAI,GAAE;IAAE,aAAa,CAAC,EAAE,OAAO,CAAA;CAAO,GACrC,OAAO,CAAC,IAAI,CAAC,CAOf;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,GAAE;IAAE,aAAa,CAAC,EAAE,OAAO,CAAA;CAAO,GACrC,MAAM,CAKR"}
|
package/dist/auth-schema.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// fails closed if a service applies the helpers but hasn't yet emitted
|
|
20
20
|
// the real per-service helper via `migrate-gen`. Once `migrate-gen` ships
|
|
21
21
|
// the real helper, `CREATE OR REPLACE` replaces this stub in place.
|
|
22
|
+
import { sessionFenceBootstrapSql, TENANT_FENCE_GATE_GRANTS_SQL, TENANT_FENCE_GATE_SQL, } from "./tenant-fence-gate.js";
|
|
22
23
|
/**
|
|
23
24
|
* Canonical SQL string exported as a module-level constant per the R1
|
|
24
25
|
* compliance requirement. Identical byte-equal payload across TS / Python
|
|
@@ -47,10 +48,29 @@ LANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('app.user_id', true), ''
|
|
|
47
48
|
CREATE OR REPLACE FUNCTION auth.get_tenant_id() RETURNS uuid
|
|
48
49
|
LANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('app.tenant_id', true), '')::uuid $$;
|
|
49
50
|
|
|
50
|
-
-- Stub variant. Replaced in-place by migrate-gen's
|
|
51
|
-
-- once the service has employees_mirror +
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
-- Stub variant, installed ONLY IF ABSENT. Replaced in-place by migrate-gen's
|
|
52
|
+
-- 0001_user_tenant_ids.sql once the service has employees_mirror +
|
|
53
|
+
-- customers_mirror tables.
|
|
54
|
+
--
|
|
55
|
+
-- IT IS A GUARDED CREATE, NOT A \`CREATE OR REPLACE\`, AND THAT IS LOAD-BEARING.
|
|
56
|
+
-- As an unguarded REPLACE this statement silently overwrote a service's REAL
|
|
57
|
+
-- membership helper with the dead stub every time the auth schema was
|
|
58
|
+
-- re-applied — turning a correctly-fenced service into a deny-all one with no
|
|
59
|
+
-- error, which is the same fail-silent shape this release exists to remove.
|
|
60
|
+
-- It also made \`INSTALL_SESSION_RESOLVER_SQL\` unable to classify: running
|
|
61
|
+
-- immediately after this block, it would read the stub this block had just
|
|
62
|
+
-- stamped and conclude EVERY service needed the degraded body.
|
|
63
|
+
--
|
|
64
|
+
-- A bootstrap default must never win against the real thing it is standing in
|
|
65
|
+
-- for.
|
|
66
|
+
DO $ensure_user_tenant_ids$
|
|
67
|
+
BEGIN
|
|
68
|
+
IF to_regprocedure('auth.user_tenant_ids(uuid)') IS NULL THEN
|
|
69
|
+
EXECUTE $utid$CREATE FUNCTION auth.user_tenant_ids(p_user_id uuid) RETURNS SETOF uuid
|
|
70
|
+
LANGUAGE sql STABLE AS $utid_body$ SELECT NULL::uuid WHERE false $utid_body$;$utid$;
|
|
71
|
+
END IF;
|
|
72
|
+
END
|
|
73
|
+
$ensure_user_tenant_ids$;
|
|
54
74
|
`;
|
|
55
75
|
/**
|
|
56
76
|
* `auth.session_tenant_ids()` — THE authenticated-pool resolver.
|
|
@@ -219,6 +239,92 @@ $session$;
|
|
|
219
239
|
* same reason: the roles must exist before the GRANT runs. */
|
|
220
240
|
export const SESSION_TENANT_IDS_GRANTS_SQL = `GRANT EXECUTE ON FUNCTION auth.session_tenant_ids() TO nodii_services, authenticated;
|
|
221
241
|
`;
|
|
242
|
+
/**
|
|
243
|
+
* THE DELIVERY UNIT. One idempotent, re-runnable, SELF-CLASSIFYING statement
|
|
244
|
+
* that installs whichever resolver body this database can actually support.
|
|
245
|
+
*
|
|
246
|
+
* WHY IT SELF-CLASSIFIES RATHER THAN TAKING A FLAG.
|
|
247
|
+
*
|
|
248
|
+
* The strict and degraded bodies are not interchangeable, and picking the wrong
|
|
249
|
+
* one is silent in BOTH directions:
|
|
250
|
+
*
|
|
251
|
+
* - strict on a service whose `auth.user_tenant_ids` is the bootstrap stub
|
|
252
|
+
* (`SELECT NULL::uuid WHERE false`) intersects to the EMPTY SET, so every
|
|
253
|
+
* authenticated policy denies every row. No error. Measured live on
|
|
254
|
+
* `nodii-dev-postgres`: crm carries 507 authenticated policies, and a strict
|
|
255
|
+
* install there takes ALL 507 dark, not just the 85 already dead.
|
|
256
|
+
* - degraded on a service that HAS a real membership mirror silently drops
|
|
257
|
+
* the principal check, which is the defence-in-depth this release exists to
|
|
258
|
+
* add.
|
|
259
|
+
*
|
|
260
|
+
* A caller-supplied boolean makes that choice a thing somebody has to know. The
|
|
261
|
+
* library shipped exactly such a list in prose — and it was WRONG BY SIX
|
|
262
|
+
* SERVICES on the first pass and had to be re-derived from the repos. A list
|
|
263
|
+
* that was wrong once, maintained by hand, across fifteen repos, on a
|
|
264
|
+
* fail-silent switch, is not a control. It is a countdown.
|
|
265
|
+
*
|
|
266
|
+
* So the choice is made from what the database ACTUALLY CONTAINS, at the moment
|
|
267
|
+
* of install, by the same structural test `isStubMembershipHelper` applies in
|
|
268
|
+
* TypeScript: a membership helper that is absent, or whose body can never
|
|
269
|
+
* return a row, cannot support the intersect. Nobody has to know anything, and
|
|
270
|
+
* there is no list to get wrong.
|
|
271
|
+
*
|
|
272
|
+
* D415 (LOCKED) is the governing decision: a single-tenant-session service
|
|
273
|
+
* whose `user_tenant_ids` is absent, stubbed or uncalled MAY fence on the
|
|
274
|
+
* session tenant — isolation-equivalent, forfeiting no cross-tenant protection.
|
|
275
|
+
* The predicate at every call site is IDENTICAL for both bodies, which is what
|
|
276
|
+
* makes this substitutable at all.
|
|
277
|
+
*
|
|
278
|
+
* IT IS ALSO D415's SUNSET, AUTOMATED. D415's exit path is "land a membership
|
|
279
|
+
* mirror → repoint the helper → swap in the strict body". Because this block
|
|
280
|
+
* re-reads the catalog on EVERY run and is `CREATE OR REPLACE`, the swap
|
|
281
|
+
* happens by itself on the next boot after the mirror lands. Nobody files a
|
|
282
|
+
* follow-up ticket, because there is nothing to follow up.
|
|
283
|
+
*
|
|
284
|
+
* THE ONE-WAY RATCHET. The condition is "absent OR stub", so a real helper
|
|
285
|
+
* ALWAYS yields strict. This block can upgrade degraded → strict; it can never
|
|
286
|
+
* downgrade strict → degraded. That direction is the one that matters: it means
|
|
287
|
+
* this delivery path cannot overwrite a correctly-strict service, and cannot
|
|
288
|
+
* overwrite crm's deliberately-degraded body with a strict one either, because
|
|
289
|
+
* crm's helper is still the stub and will keep classifying as such until it
|
|
290
|
+
* genuinely isn't.
|
|
291
|
+
*
|
|
292
|
+
* Byte-identical across the TS / Go / Python ports (asserted by the parity
|
|
293
|
+
* test). The nested dollar-quote tags are all distinct — `$fence_install$`
|
|
294
|
+
* outer, `$fence_strict$` / `$fence_degraded$` for the EXECUTE literals,
|
|
295
|
+
* `$session$` inside the bodies — so the statement survives every dollar-quote
|
|
296
|
+
* -aware splitter in the fleet, including the ones in @nodii/saga and
|
|
297
|
+
* @nodii/dsar that split the mirrors' DDL on `;\n`.
|
|
298
|
+
*/
|
|
299
|
+
export const INSTALL_SESSION_RESOLVER_SQL = `CREATE SCHEMA IF NOT EXISTS auth;
|
|
300
|
+
DO $fence_install$
|
|
301
|
+
DECLARE
|
|
302
|
+
v_src text;
|
|
303
|
+
v_is_stub boolean;
|
|
304
|
+
BEGIN
|
|
305
|
+
SELECT p.prosrc INTO v_src
|
|
306
|
+
FROM pg_proc p
|
|
307
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
308
|
+
WHERE n.nspname = 'auth' AND p.proname = 'user_tenant_ids'
|
|
309
|
+
LIMIT 1;
|
|
310
|
+
|
|
311
|
+
-- Structural, not an exact-string compare: the bootstrap stub is
|
|
312
|
+
-- \`SELECT NULL::uuid WHERE false\` — an unsatisfiable WHERE and no FROM. A
|
|
313
|
+
-- reformatted or re-indented copy of the same dead body still classifies as a
|
|
314
|
+
-- stub. Mirrors isStubMembershipHelper() in tenant-fence.ts exactly; the two
|
|
315
|
+
-- are asserted equivalent in the test-suite so a fix to one cannot leave the
|
|
316
|
+
-- other behind.
|
|
317
|
+
v_is_stub := v_src IS NULL
|
|
318
|
+
OR (v_src ~* 'where\\s+false' AND v_src !~* 'from\\s+\\w');
|
|
319
|
+
|
|
320
|
+
IF v_is_stub THEN
|
|
321
|
+
EXECUTE $fence_degraded$${SESSION_TENANT_IDS_DEGRADED_SQL.trimEnd()}$fence_degraded$;
|
|
322
|
+
ELSE
|
|
323
|
+
EXECUTE $fence_strict$${SESSION_TENANT_IDS_SQL.trimEnd()}$fence_strict$;
|
|
324
|
+
END IF;
|
|
325
|
+
END
|
|
326
|
+
$fence_install$;
|
|
327
|
+
`;
|
|
222
328
|
/**
|
|
223
329
|
* Grants for the 7 helper functions. Emitted separately because they
|
|
224
330
|
* reference roles that must exist before the GRANT runs — applied last
|
|
@@ -257,12 +363,56 @@ export async function applyAuthSchemaMigrations(client, opts = {}) {
|
|
|
257
363
|
// CREATE POLICY time, so the migration itself fails with `function
|
|
258
364
|
// auth.session_tenant_ids() does not exist`. Installing both together makes
|
|
259
365
|
// that unreachable.
|
|
366
|
+
//
|
|
367
|
+
// WHICH BODY IS NOT A DECISION THIS FUNCTION MAKES. It is derived from the
|
|
368
|
+
// membership helper actually present in THIS database — see
|
|
369
|
+
// INSTALL_SESSION_RESOLVER_SQL. `degradedSessionResolver` remains as an
|
|
370
|
+
// explicit override for the one case derivation cannot cover: a service that
|
|
371
|
+
// has a real-looking helper but knows it is not yet populated. Passing it is
|
|
372
|
+
// a deliberate, argued act; the default is derivation, so a caller who knows
|
|
373
|
+
// nothing gets the right body.
|
|
260
374
|
await exec.query(opts.degradedSessionResolver
|
|
261
375
|
? SESSION_TENANT_IDS_DEGRADED_SQL
|
|
262
|
-
:
|
|
376
|
+
: INSTALL_SESSION_RESOLVER_SQL);
|
|
377
|
+
// The gate ships with the resolver, always. See sessionFenceBootstrapSql.
|
|
378
|
+
await exec.query(TENANT_FENCE_GATE_SQL);
|
|
263
379
|
if (includeGrants) {
|
|
264
380
|
await exec.query(AUTH_SCHEMA_GRANTS_SQL);
|
|
265
381
|
await exec.query(SESSION_TENANT_IDS_GRANTS_SQL);
|
|
382
|
+
await exec.query(TENANT_FENCE_GATE_GRANTS_SQL);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* THE WHOLE DELIVERY UNIT AS ONE STRING — resolver + gate, self-classifying,
|
|
387
|
+
* idempotent, re-runnable, and safe to apply on every boot.
|
|
388
|
+
*
|
|
389
|
+
* This is what the three mirrors inline byte-equal, what the CLI applies, and
|
|
390
|
+
* what a service with no other delivery path should call directly from its boot
|
|
391
|
+
* sequence, BEFORE any code that opens an authenticated transaction:
|
|
392
|
+
*
|
|
393
|
+
* import { applySessionFenceBootstrap } from "@nodii/db-rls";
|
|
394
|
+
* await applySessionFenceBootstrap(ownerSql);
|
|
395
|
+
*
|
|
396
|
+
* Grants are separate (as everywhere else in this package) because the runtime
|
|
397
|
+
* roles must exist before a GRANT naming them can run, and on a fresh database
|
|
398
|
+
* they do not yet.
|
|
399
|
+
*/
|
|
400
|
+
export const SESSION_FENCE_BOOTSTRAP_SQL = sessionFenceBootstrapSql(INSTALL_SESSION_RESOLVER_SQL);
|
|
401
|
+
/**
|
|
402
|
+
* Apply {@link SESSION_FENCE_BOOTSTRAP_SQL} against an owner-privileged client.
|
|
403
|
+
*
|
|
404
|
+
* Idempotent and safe on every boot: the resolver and gate are
|
|
405
|
+
* `CREATE OR REPLACE`, the schema is `IF NOT EXISTS`, and the body selection
|
|
406
|
+
* re-derives from the catalog each run — so a service that lands a membership
|
|
407
|
+
* mirror upgrades itself from the degraded body to the strict one on its next
|
|
408
|
+
* restart, with no migration and no policy change (D415's exit path).
|
|
409
|
+
*/
|
|
410
|
+
export async function applySessionFenceBootstrap(client, opts = {}) {
|
|
411
|
+
const exec = client;
|
|
412
|
+
await exec.query(SESSION_FENCE_BOOTSTRAP_SQL);
|
|
413
|
+
if (opts.includeGrants ?? false) {
|
|
414
|
+
await exec.query(SESSION_TENANT_IDS_GRANTS_SQL);
|
|
415
|
+
await exec.query(TENANT_FENCE_GATE_GRANTS_SQL);
|
|
266
416
|
}
|
|
267
417
|
}
|
|
268
418
|
/**
|
package/dist/auth-schema.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-schema.js","sourceRoot":"","sources":["../src/auth-schema.ts"],"names":[],"mappings":"AAAA,wEAAwE;AACxE,EAAE;AACF,oEAAoE;AACpE,qEAAqE;AACrE,2BAA2B;AAC3B,EAAE;AACF,mEAAmE;AACnE,qBAAqB;AACrB,EAAE;AACF,4EAA4E;AAC5E,8EAA8E;AAC9E,qEAAqE;AACrE,4FAA4F;AAC5F,8DAA8D;AAC9D,8DAA8D;AAC9D,0EAA0E;AAC1E,EAAE;AACF,yEAAyE;AACzE,uEAAuE;AACvE,0EAA0E;AAC1E,oEAAoE;
|
|
1
|
+
{"version":3,"file":"auth-schema.js","sourceRoot":"","sources":["../src/auth-schema.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { type SystemContextOpts, type VerifiedTenantClaims } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Establish the request-scoped verified-claims context. Call this ONCE per
|
|
4
|
+
* request, from auth middleware, IMMEDIATELY after the JWT has been
|
|
5
|
+
* verified — and pass the values off the VERIFIED claim set, never off the
|
|
6
|
+
* request body / path / headers.
|
|
7
|
+
*
|
|
8
|
+
* Everything downstream (`withUserContext`, `withUserContextDb`,
|
|
9
|
+
* `withUserContextDbPostgresJs`, `autoDispatch`) reads the tenant from
|
|
10
|
+
* here. Nothing downstream can supply one.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* app.use(async (c, next) => {
|
|
14
|
+
* const claims = await verifyJwt(c.req.header("authorization"));
|
|
15
|
+
* return runWithVerifiedTenantClaims(
|
|
16
|
+
* { userId: claims.sub, tenantId: claims.tenant_id },
|
|
17
|
+
* () => next(),
|
|
18
|
+
* );
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Returns whatever `fn` returns (including a promise), so it composes with
|
|
23
|
+
* Hono / Express / gRPC interceptor shapes unchanged.
|
|
24
|
+
*
|
|
25
|
+
* Throws {@link VerifiedTenantClaimsAlreadyBound} if claims are already
|
|
26
|
+
* bound in the current async context. Re-binding is refused
|
|
27
|
+
* UNCONDITIONALLY — including to identical claims. A nested bind is the
|
|
28
|
+
* one shape that could re-open the hole (bind in middleware, re-bind to a
|
|
29
|
+
* request-derived tenant in the handler), so it is not a thing that
|
|
30
|
+
* happens quietly. If you have hit this legitimately, the answer is to
|
|
31
|
+
* move the single bind to the outermost auth boundary, not to relax it.
|
|
32
|
+
*
|
|
33
|
+
* Throws {@link InvalidVerifiedTenantClaims} if `userId` / `tenantId` is
|
|
34
|
+
* missing, not a string, or blank.
|
|
35
|
+
*/
|
|
36
|
+
export declare function runWithVerifiedTenantClaims<T>(claims: VerifiedTenantClaims, fn: () => T): T;
|
|
37
|
+
/**
|
|
38
|
+
* The verified claims bound to the current request, or `null` outside any
|
|
39
|
+
* bound context.
|
|
40
|
+
*
|
|
41
|
+
* This is the DIAGNOSTIC read (health probes, "am I on the authenticated
|
|
42
|
+
* path?" branches). Anything that is about to touch the database must use
|
|
43
|
+
* {@link requireVerifiedTenantClaims} so an unbound context fails closed
|
|
44
|
+
* instead of degrading into a nullish branch.
|
|
45
|
+
*/
|
|
46
|
+
export declare function getVerifiedTenantClaimsOrNull(): VerifiedTenantClaims | null;
|
|
47
|
+
/** Whether verified claims are bound in the current async context. */
|
|
48
|
+
export declare function hasVerifiedTenantClaims(): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* The verified claims bound to the current request, or THROW.
|
|
51
|
+
*
|
|
52
|
+
* Fail closed and loud: there is no default tenant, no fallback to a
|
|
53
|
+
* passed value, and no empty result. A silently-empty result is how the
|
|
54
|
+
* crm 85-policy blackout stayed invisible for weeks.
|
|
55
|
+
*/
|
|
56
|
+
export declare function requireVerifiedTenantClaims(helper?: string): VerifiedTenantClaims;
|
|
57
|
+
/**
|
|
58
|
+
* Refuse the retired `helper(opts, fn)` call shape.
|
|
59
|
+
*
|
|
60
|
+
* TypeScript already rejects it — the helpers take the callback as their
|
|
61
|
+
* only argument, so all 271 call sites are compile errors until each one is
|
|
62
|
+
* examined. This is the runtime backstop for consumers that do not
|
|
63
|
+
* typecheck against this package (plain JS, a stale `dist/`, a `any`-typed
|
|
64
|
+
* wrapper): without it, the opts object would be invoked as the callback
|
|
65
|
+
* and the caller would get `opts is not a function`, which reads like a bug
|
|
66
|
+
* in db-rls rather than a migration they have not done.
|
|
67
|
+
*/
|
|
68
|
+
export declare function assertNoTenantArgument(helper: string, fn: unknown): void;
|
|
69
|
+
/**
|
|
70
|
+
* THE STEP-1 FENCE on the system leg. Called by `withSystemContext`,
|
|
71
|
+
* `withSystemContextDb` and `withSystemContextDbPostgresJs` before they
|
|
72
|
+
* open a services-pool transaction.
|
|
73
|
+
*
|
|
74
|
+
* Three outcomes:
|
|
75
|
+
*
|
|
76
|
+
* - **No claims bound** (boot, cron, worker, S2S, webhook) → returns
|
|
77
|
+
* `null`. The system helpers behave EXACTLY as before — this is the
|
|
78
|
+
* compat property that keeps the fence from breaking every worker.
|
|
79
|
+
* - **Claims bound + `opts.reason` is a non-blank string** → returns
|
|
80
|
+
* `{ boundTenantId, reason }` so the caller can annotate telemetry
|
|
81
|
+
* (`db_rls.cross_pool_nesting_total{outer=authenticated,inner=services,
|
|
82
|
+
* reason}`). The escape is sanctioned, named, and observable.
|
|
83
|
+
* - **Claims bound + no reason** → THROWS
|
|
84
|
+
* {@link SystemContextUnderClaimsRefused}, BEFORE any pool is touched.
|
|
85
|
+
*
|
|
86
|
+
* WHY. The services-pool predicate is the bare
|
|
87
|
+
* `tenant_id = auth.get_tenant_id()` — it trusts the GUC completely and
|
|
88
|
+
* proves no membership. An authenticated request that falls through to
|
|
89
|
+
* `withSystemContext` therefore re-opens the exact hole the verified-claims
|
|
90
|
+
* binding closes on the authenticated pool: the handler supplies the
|
|
91
|
+
* tenant, and nothing checks the principal holds it. That fall-through is
|
|
92
|
+
* where the ₹250,000 cross-tenant refund reproduces. Requiring an explicit
|
|
93
|
+
* `{ reason }` turns a silent fall-through into a deliberate, reviewable,
|
|
94
|
+
* telemetry-visible act — and everything else into a throw.
|
|
95
|
+
*/
|
|
96
|
+
export declare function assertSystemContextEscapeAllowed(helper: string, opts: SystemContextOpts): {
|
|
97
|
+
boundTenantId: string;
|
|
98
|
+
reason: string;
|
|
99
|
+
} | null;
|
|
100
|
+
//# sourceMappingURL=claims-context.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"claims-context.d.ts","sourceRoot":"","sources":["../src/claims-context.ts"],"names":[],"mappings":"AAiDA,OAAO,EAIL,KAAK,iBAAiB,EAEtB,KAAK,oBAAoB,EAE1B,MAAM,YAAY,CAAC;AAuGpB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,2BAA2B,CAAC,CAAC,EAC3C,MAAM,EAAE,oBAAoB,EAC5B,EAAE,EAAE,MAAM,CAAC,GACV,CAAC,CAcH;AAED;;;;;;;;GAQG;AACH,wBAAgB,6BAA6B,IAAI,oBAAoB,GAAG,IAAI,CAE3E;AAED,sEAAsE;AACtE,wBAAgB,uBAAuB,IAAI,OAAO,CAEjD;AAED;;;;;;GAMG;AACH,wBAAgB,2BAA2B,CACzC,MAAM,CAAC,EAAE,MAAM,GACd,oBAAoB,CAMtB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,OAAO,GAAG,IAAI,CAIxE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,gCAAgC,CAC9C,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,iBAAiB,GACtB;IAAE,aAAa,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAclD"}
|