@nodii/db-rls 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/migrate-gen.mjs +11 -0
- package/dist/auth-schema.d.ts +207 -1
- package/dist/auth-schema.d.ts.map +1 -1
- package/dist/auth-schema.js +331 -4
- package/dist/auth-schema.js.map +1 -1
- package/dist/context.d.ts +31 -8
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +103 -24
- package/dist/context.js.map +1 -1
- package/dist/drizzle-client.d.ts +17 -5
- package/dist/drizzle-client.d.ts.map +1 -1
- package/dist/drizzle-client.js +19 -18
- 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 +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -7
- package/dist/index.js.map +1 -1
- package/dist/postgres-js-client.d.ts +17 -10
- package/dist/postgres-js-client.d.ts.map +1 -1
- package/dist/postgres-js-client.js +19 -21
- package/dist/postgres-js-client.js.map +1 -1
- package/dist/rls-policy-gen.d.ts +43 -27
- package/dist/rls-policy-gen.d.ts.map +1 -1
- package/dist/rls-policy-gen.js +40 -15
- package/dist/rls-policy-gen.js.map +1 -1
- package/dist/rls-policy.d.ts +11 -1
- package/dist/rls-policy.d.ts.map +1 -1
- package/dist/rls-policy.js +31 -4
- package/dist/rls-policy.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/tenant-fence.d.ts +165 -0
- package/dist/tenant-fence.d.ts.map +1 -0
- package/dist/tenant-fence.js +542 -0
- package/dist/tenant-fence.js.map +1 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +22 -0
- package/dist/types.js.map +1 -1
- package/dist/verify-rls-cli.d.ts.map +1 -1
- package/dist/verify-rls-cli.js +86 -10
- package/dist/verify-rls-cli.js.map +1 -1
- package/dist/verify-rls.d.ts +26 -5
- package/dist/verify-rls.d.ts.map +1 -1
- package/dist/verify-rls.js +73 -5
- package/dist/verify-rls.js.map +1 -1
- package/package.json +2 -2
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,184 @@ import type { Pool, PoolClient } from "pg";
|
|
|
4
4
|
* compliance requirement. Identical byte-equal payload across TS / Python
|
|
5
5
|
* / Go ports (asserted by the parity-fence CI).
|
|
6
6
|
*/
|
|
7
|
-
export declare const AUTH_SCHEMA_SQL = "CREATE SCHEMA IF NOT EXISTS auth;\n\nCREATE OR REPLACE FUNCTION auth.set_user_id(p_user_id uuid) RETURNS void\nLANGUAGE sql AS $$ SELECT set_config('app.user_id', p_user_id::text, true) $$;\n\nCREATE OR REPLACE FUNCTION auth.set_tenant_id(p_tenant_id uuid) RETURNS void\nLANGUAGE sql AS $$ SELECT set_config('app.tenant_id', p_tenant_id::text, true) $$;\n\nCREATE OR REPLACE FUNCTION auth.init(p_user_id uuid, p_tenant_id uuid) RETURNS void\nLANGUAGE sql AS $$\n SELECT set_config('app.user_id', p_user_id::text, true);\n SELECT set_config('app.tenant_id', p_tenant_id::text, true);\n$$;\n\nCREATE OR REPLACE FUNCTION auth.init_tenant(p_tenant_id uuid) RETURNS void\nLANGUAGE sql AS $$ SELECT set_config('app.tenant_id', p_tenant_id::text, true) $$;\n\nCREATE OR REPLACE FUNCTION auth.get_user_id() RETURNS uuid\nLANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('app.user_id', true), '')::uuid $$;\n\nCREATE OR REPLACE FUNCTION auth.get_tenant_id() RETURNS uuid\nLANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('app.tenant_id', true), '')::uuid $$;\n\n-- Stub variant. Replaced in-place by migrate-gen's 0001_user_tenant_ids.sql
|
|
7
|
+
export declare const AUTH_SCHEMA_SQL = "CREATE SCHEMA IF NOT EXISTS auth;\n\nCREATE OR REPLACE FUNCTION auth.set_user_id(p_user_id uuid) RETURNS void\nLANGUAGE sql AS $$ SELECT set_config('app.user_id', p_user_id::text, true) $$;\n\nCREATE OR REPLACE FUNCTION auth.set_tenant_id(p_tenant_id uuid) RETURNS void\nLANGUAGE sql AS $$ SELECT set_config('app.tenant_id', p_tenant_id::text, true) $$;\n\nCREATE OR REPLACE FUNCTION auth.init(p_user_id uuid, p_tenant_id uuid) RETURNS void\nLANGUAGE sql AS $$\n SELECT set_config('app.user_id', p_user_id::text, true);\n SELECT set_config('app.tenant_id', p_tenant_id::text, true);\n$$;\n\nCREATE OR REPLACE FUNCTION auth.init_tenant(p_tenant_id uuid) RETURNS void\nLANGUAGE sql AS $$ SELECT set_config('app.tenant_id', p_tenant_id::text, true) $$;\n\nCREATE OR REPLACE FUNCTION auth.get_user_id() RETURNS uuid\nLANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('app.user_id', true), '')::uuid $$;\n\nCREATE OR REPLACE FUNCTION auth.get_tenant_id() RETURNS uuid\nLANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('app.tenant_id', true), '')::uuid $$;\n\n-- Stub variant, installed ONLY IF ABSENT. Replaced in-place by migrate-gen's\n-- 0001_user_tenant_ids.sql once the service has employees_mirror +\n-- customers_mirror tables.\n--\n-- IT IS A GUARDED CREATE, NOT A `CREATE OR REPLACE`, AND THAT IS LOAD-BEARING.\n-- As an unguarded REPLACE this statement silently overwrote a service's REAL\n-- membership helper with the dead stub every time the auth schema was\n-- re-applied \u2014 turning a correctly-fenced service into a deny-all one with no\n-- error, which is the same fail-silent shape this release exists to remove.\n-- It also made `INSTALL_SESSION_RESOLVER_SQL` unable to classify: running\n-- immediately after this block, it would read the stub this block had just\n-- stamped and conclude EVERY service needed the degraded body.\n--\n-- A bootstrap default must never win against the real thing it is standing in\n-- for.\nDO $ensure_user_tenant_ids$\nBEGIN\n IF to_regprocedure('auth.user_tenant_ids(uuid)') IS NULL THEN\n EXECUTE $utid$CREATE FUNCTION auth.user_tenant_ids(p_user_id uuid) RETURNS SETOF uuid\nLANGUAGE sql STABLE AS $utid_body$ SELECT NULL::uuid WHERE false $utid_body$;$utid$;\n END IF;\nEND\n$ensure_user_tenant_ids$;\n";
|
|
8
|
+
/**
|
|
9
|
+
* `auth.session_tenant_ids()` — THE authenticated-pool resolver.
|
|
10
|
+
*
|
|
11
|
+
* Returns AT MOST ONE tenant: the one bound to `app.tenant_id`, and only if the
|
|
12
|
+
* principal in `app.user_id` genuinely holds it per `auth.user_tenant_ids`.
|
|
13
|
+
* Edge-signed session tenant **INTERSECT** server-resolved membership.
|
|
14
|
+
*
|
|
15
|
+
* WHY THIS EXISTS. The `authenticated` predicate used to be
|
|
16
|
+
* `tenant_id IN (SELECT auth.user_tenant_ids(auth.get_user_id()))` — the UNION
|
|
17
|
+
* of every tenant the user belongs to, with no tenant fence at all. A session
|
|
18
|
+
* opened for a user with memberships in tenants A and B could read AND write
|
|
19
|
+
* both. Reproduced end-to-end: a legitimate tenant-A session transitioned a
|
|
20
|
+
* tenant-B approval task, got HTTP 200, and released a 250,000 INR refund.
|
|
21
|
+
*
|
|
22
|
+
* WHY SUBSTITUTION RATHER THAN APPENDING A FENCE. The obvious repair is to
|
|
23
|
+
* append `AND tenant_id = auth.get_tenant_id()` at the top level of each
|
|
24
|
+
* policy. That breaks every policy whose shape is not a bare tenant equality,
|
|
25
|
+
* and the fleet is full of them:
|
|
26
|
+
*
|
|
27
|
+
* - tables with NO `tenant_id` column that reach it through a nested
|
|
28
|
+
* `EXISTS` or a sub-`SELECT` (`tenant_role_permissions`, `fx_rate_cache`)
|
|
29
|
+
* - platform-default escape hatches — `tenant_id IS NULL OR tenant_id IN (…)`
|
|
30
|
+
* (hr's statutory masters, `pricing_snapshots`)
|
|
31
|
+
* - additive ORs on a differently-named column — `is_public_listing = TRUE OR
|
|
32
|
+
* developer_tenant_id IN (…)` (`oauth_apps` marketplace listings)
|
|
33
|
+
* - policies fencing on a column that is not `tenant_id` at all (`tenants.id`,
|
|
34
|
+
* `legal_documents.scope`, `impersonation_sessions.target_tenant_id`)
|
|
35
|
+
* - TEXT-vs-uuid casts that need fixing in opposite directions per table
|
|
36
|
+
*
|
|
37
|
+
* Swapping the resolver INSIDE the subquery leaves the column, the cast and the
|
|
38
|
+
* surrounding OR untouched, so all of those keep working while the set the
|
|
39
|
+
* subquery yields narrows from "every tenant you belong to" to "the one you are
|
|
40
|
+
* logged into, if you really belong to it". One substitution, zero shape
|
|
41
|
+
* changes, and NO `AS RESTRICTIVE` (which would AND against the additive
|
|
42
|
+
* permissive master policies and kill every platform-default row).
|
|
43
|
+
*
|
|
44
|
+
* `user_tenant_ids` is deliberately LEFT INTACT. It still answers the honest
|
|
45
|
+
* question "which tenants does this user belong to", which a tenant-switcher or
|
|
46
|
+
* a login tenant-picker legitimately needs. Overloading it with session scope
|
|
47
|
+
* would have silently broken those callers with no error and no test coverage.
|
|
48
|
+
*
|
|
49
|
+
* SECURITY DEFINER is required, not decorative:
|
|
50
|
+
* - `authenticated` has no privilege on the membership mirror, so an INVOKER
|
|
51
|
+
* function raises `permission denied for table user_memberships`;
|
|
52
|
+
* - if the mirror is itself RLS-policied, an INVOKER function re-enters its
|
|
53
|
+
* own policy and dies with `stack depth limit exceeded` (54001).
|
|
54
|
+
*
|
|
55
|
+
* FAIL-CLOSED SPLIT, deliberate:
|
|
56
|
+
* - GUCs unset → RAISE 42501. Misconfiguration is LOUD. An unbound session
|
|
57
|
+
* silently yielding an empty grid reads to a human as "there is no data
|
|
58
|
+
* here", which is how a broken session survives a week of QA.
|
|
59
|
+
* - bound, but no membership → EMPTY SET, no error. A legitimate non-member
|
|
60
|
+
* seeing nothing is not an error condition, and platform-default rows
|
|
61
|
+
* reached through a sibling `OR` must still resolve.
|
|
62
|
+
*/
|
|
63
|
+
export declare const SESSION_TENANT_IDS_SQL = "CREATE OR REPLACE FUNCTION auth.session_tenant_ids() RETURNS SETOF uuid\nLANGUAGE plpgsql STABLE SECURITY DEFINER\nSET search_path = pg_catalog, public, pg_temp\nAS $session$\nDECLARE\n v_user uuid;\n v_tenant uuid;\nBEGIN\n v_user := nullif(current_setting('app.user_id', true), '')::uuid;\n v_tenant := nullif(current_setting('app.tenant_id', true), '')::uuid;\n IF v_user IS NULL THEN\n RAISE EXCEPTION 'db_rls_session_user_missing: app.user_id is not bound on this connection'\n USING ERRCODE = '42501',\n HINT = 'The authenticated pool is the user-session pool; a session with no principal cannot be membership-checked. Open it with withUserContext({ userId, tenantId }, fn) from @nodii/db-rls.';\n END IF;\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'db_rls_session_tenant_missing: app.tenant_id is not bound on this connection'\n USING ERRCODE = '42501',\n HINT = 'Every authenticated-pool session must be EXPLICITLY tenant-scoped. Open it with withUserContext({ userId, tenantId }, fn) from @nodii/db-rls.';\n END IF;\n RETURN QUERY SELECT v_tenant WHERE v_tenant IN (SELECT auth.user_tenant_ids(v_user));\nEND\n$session$;\n";
|
|
64
|
+
/**
|
|
65
|
+
* Marker embedded in the DEGRADED body so `pg_proc.prosrc` introspection can
|
|
66
|
+
* tell the two variants apart. {@link verifyTenantFence} reports any database
|
|
67
|
+
* running the degraded resolver — the point is that a service is never on it by
|
|
68
|
+
* accident.
|
|
69
|
+
*/
|
|
70
|
+
export declare const SESSION_TENANT_IDS_DEGRADED_MARKER = "DB_RLS_DEGRADED_NO_MEMBERSHIP_MIRROR";
|
|
71
|
+
/**
|
|
72
|
+
* DEGRADED variant of {@link SESSION_TENANT_IDS_SQL} — fence ONLY, no
|
|
73
|
+
* membership intersect.
|
|
74
|
+
*
|
|
75
|
+
* FOR SERVICES THAT SHIP NO MEMBERSHIP MIRROR, AND NOTHING ELSE. Such a service
|
|
76
|
+
* has `auth.user_tenant_ids` as the bootstrap stub (`SELECT NULL::uuid WHERE
|
|
77
|
+
* false`), so the strict resolver intersects to the empty set and the entire
|
|
78
|
+
* authenticated pool becomes silently deny-all — the P0 shape of drift
|
|
79
|
+
* 199feabc, where crm's 430 policies were dead and nobody noticed.
|
|
80
|
+
*
|
|
81
|
+
* THE LIST BELOW IS DERIVED FROM THE REPOS, NOT FROM MEMORY. An earlier
|
|
82
|
+
* revision of this comment asserted "nodii-crm-service alone", which was wrong
|
|
83
|
+
* by six services and wrong about crm's urgency. Several repos ship a phase-004
|
|
84
|
+
* or phase-005 `0002_create_rls_helpers.sql` whose real `user_tenant_ids` is
|
|
85
|
+
* FULLY COMMENTED OUT, so the phase-002 stub is still the winner:
|
|
86
|
+
*
|
|
87
|
+
* STUB + a LIVE authenticated pool → deny-all TODAY, needs this variant:
|
|
88
|
+
* - nodii-billing-service 77 call sites; saga + dsar migrations run at
|
|
89
|
+
* BOOT (`src/lib/bootstrap.ts`), so its
|
|
90
|
+
* `saga_outbox` / `lifecycle_requests` /
|
|
91
|
+
* `data_residency_holds` are library-owned. The
|
|
92
|
+
* money path. Highest risk in the fleet.
|
|
93
|
+
* - nodii-finance-service 14 call sites. Already self-mitigates via
|
|
94
|
+
* `010-lib-auth-pool-mode`, which rewrites the
|
|
95
|
+
* lib policies to `= auth.get_tenant_id()`.
|
|
96
|
+
* - nodii-notification-service 3 call sites. Its own policies use
|
|
97
|
+
* `= auth.get_tenant_id()` and keep working, so
|
|
98
|
+
* only the library-owned tables go dark — which
|
|
99
|
+
* is exactly the shape nobody notices.
|
|
100
|
+
*
|
|
101
|
+
* STUB + a pool that is built but has ZERO handler call sites → not broken
|
|
102
|
+
* today, breaks the moment a handler uses it:
|
|
103
|
+
* - nodii-crm-service, nodii-kyc-service, nodii-ticketing-service,
|
|
104
|
+
* nodii-product-service
|
|
105
|
+
*
|
|
106
|
+
* Does NOT need it: nodii-geo-service (no authenticated pool at all —
|
|
107
|
+
* 2-pool), nodii-edge (no `user_tenant_ids`, RLS mode `single`),
|
|
108
|
+
* nodii-customer-service and nodii-vendor-service (already GUC-bodied),
|
|
109
|
+
* nodii-auth / nodii-hr-service / nodii-task-tracking / nodii-tenant-service
|
|
110
|
+
* (REAL membership helpers).
|
|
111
|
+
*
|
|
112
|
+
* WHAT YOU GIVE UP. The database stops verifying the principal. Isolation rests
|
|
113
|
+
* entirely on the application binding `app.tenant_id` correctly — RLS degrades
|
|
114
|
+
* from an independent second layer into "the app said so". A forged or simply
|
|
115
|
+
* mistaken tenant binding becomes access. That is a real reduction in
|
|
116
|
+
* defence-in-depth and it is why this is a separate, named, reported constant
|
|
117
|
+
* rather than a boolean flag on the strict one.
|
|
118
|
+
*
|
|
119
|
+
* THE EXIT. Land a membership mirror, point `auth.user_tenant_ids` at it, then
|
|
120
|
+
* apply {@link SESSION_TENANT_IDS_SQL} over this. No policy changes — the
|
|
121
|
+
* predicate at every call site is identical for both variants.
|
|
122
|
+
*/
|
|
123
|
+
export declare const SESSION_TENANT_IDS_DEGRADED_SQL = "CREATE OR REPLACE FUNCTION auth.session_tenant_ids() RETURNS SETOF uuid\nLANGUAGE plpgsql STABLE SECURITY DEFINER\nSET search_path = pg_catalog, public, pg_temp\nAS $session$\nDECLARE\n v_tenant uuid;\nBEGIN\n -- DB_RLS_DEGRADED_NO_MEMBERSHIP_MIRROR: no membership mirror in this service,\n -- so the session tenant is accepted WITHOUT proving the principal holds it.\n -- Replace with the strict resolver as soon as a mirror exists.\n IF nullif(current_setting('app.user_id', true), '') IS NULL THEN\n RAISE EXCEPTION 'db_rls_session_user_missing: app.user_id is not bound on this connection'\n USING ERRCODE = '42501',\n HINT = 'Open the session with withUserContext({ userId, tenantId }, fn) from @nodii/db-rls.';\n END IF;\n v_tenant := nullif(current_setting('app.tenant_id', true), '')::uuid;\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'db_rls_session_tenant_missing: app.tenant_id is not bound on this connection'\n USING ERRCODE = '42501',\n HINT = 'Every authenticated-pool session must be EXPLICITLY tenant-scoped. Open it with withUserContext({ userId, tenantId }, fn) from @nodii/db-rls.';\n END IF;\n RETURN QUERY SELECT v_tenant;\nEND\n$session$;\n";
|
|
124
|
+
/** Grant for the resolver. Separate from {@link AUTH_SCHEMA_GRANTS_SQL} for the
|
|
125
|
+
* same reason: the roles must exist before the GRANT runs. */
|
|
126
|
+
export declare const SESSION_TENANT_IDS_GRANTS_SQL = "GRANT EXECUTE ON FUNCTION auth.session_tenant_ids() TO nodii_services, authenticated;\n";
|
|
127
|
+
/**
|
|
128
|
+
* THE DELIVERY UNIT. One idempotent, re-runnable, SELF-CLASSIFYING statement
|
|
129
|
+
* that installs whichever resolver body this database can actually support.
|
|
130
|
+
*
|
|
131
|
+
* WHY IT SELF-CLASSIFIES RATHER THAN TAKING A FLAG.
|
|
132
|
+
*
|
|
133
|
+
* The strict and degraded bodies are not interchangeable, and picking the wrong
|
|
134
|
+
* one is silent in BOTH directions:
|
|
135
|
+
*
|
|
136
|
+
* - strict on a service whose `auth.user_tenant_ids` is the bootstrap stub
|
|
137
|
+
* (`SELECT NULL::uuid WHERE false`) intersects to the EMPTY SET, so every
|
|
138
|
+
* authenticated policy denies every row. No error. Measured live on
|
|
139
|
+
* `nodii-dev-postgres`: crm carries 507 authenticated policies, and a strict
|
|
140
|
+
* install there takes ALL 507 dark, not just the 85 already dead.
|
|
141
|
+
* - degraded on a service that HAS a real membership mirror silently drops
|
|
142
|
+
* the principal check, which is the defence-in-depth this release exists to
|
|
143
|
+
* add.
|
|
144
|
+
*
|
|
145
|
+
* A caller-supplied boolean makes that choice a thing somebody has to know. The
|
|
146
|
+
* library shipped exactly such a list in prose — and it was WRONG BY SIX
|
|
147
|
+
* SERVICES on the first pass and had to be re-derived from the repos. A list
|
|
148
|
+
* that was wrong once, maintained by hand, across fifteen repos, on a
|
|
149
|
+
* fail-silent switch, is not a control. It is a countdown.
|
|
150
|
+
*
|
|
151
|
+
* So the choice is made from what the database ACTUALLY CONTAINS, at the moment
|
|
152
|
+
* of install, by the same structural test `isStubMembershipHelper` applies in
|
|
153
|
+
* TypeScript: a membership helper that is absent, or whose body can never
|
|
154
|
+
* return a row, cannot support the intersect. Nobody has to know anything, and
|
|
155
|
+
* there is no list to get wrong.
|
|
156
|
+
*
|
|
157
|
+
* D415 (LOCKED) is the governing decision: a single-tenant-session service
|
|
158
|
+
* whose `user_tenant_ids` is absent, stubbed or uncalled MAY fence on the
|
|
159
|
+
* session tenant — isolation-equivalent, forfeiting no cross-tenant protection.
|
|
160
|
+
* The predicate at every call site is IDENTICAL for both bodies, which is what
|
|
161
|
+
* makes this substitutable at all.
|
|
162
|
+
*
|
|
163
|
+
* IT IS ALSO D415's SUNSET, AUTOMATED. D415's exit path is "land a membership
|
|
164
|
+
* mirror → repoint the helper → swap in the strict body". Because this block
|
|
165
|
+
* re-reads the catalog on EVERY run and is `CREATE OR REPLACE`, the swap
|
|
166
|
+
* happens by itself on the next boot after the mirror lands. Nobody files a
|
|
167
|
+
* follow-up ticket, because there is nothing to follow up.
|
|
168
|
+
*
|
|
169
|
+
* THE ONE-WAY RATCHET. The condition is "absent OR stub", so a real helper
|
|
170
|
+
* ALWAYS yields strict. This block can upgrade degraded → strict; it can never
|
|
171
|
+
* downgrade strict → degraded. That direction is the one that matters: it means
|
|
172
|
+
* this delivery path cannot overwrite a correctly-strict service, and cannot
|
|
173
|
+
* overwrite crm's deliberately-degraded body with a strict one either, because
|
|
174
|
+
* crm's helper is still the stub and will keep classifying as such until it
|
|
175
|
+
* genuinely isn't.
|
|
176
|
+
*
|
|
177
|
+
* Byte-identical across the TS / Go / Python ports (asserted by the parity
|
|
178
|
+
* test). The nested dollar-quote tags are all distinct — `$fence_install$`
|
|
179
|
+
* outer, `$fence_strict$` / `$fence_degraded$` for the EXECUTE literals,
|
|
180
|
+
* `$session$` inside the bodies — so the statement survives every dollar-quote
|
|
181
|
+
* -aware splitter in the fleet, including the ones in @nodii/saga and
|
|
182
|
+
* @nodii/dsar that split the mirrors' DDL on `;\n`.
|
|
183
|
+
*/
|
|
184
|
+
export declare const INSTALL_SESSION_RESOLVER_SQL: string;
|
|
8
185
|
/**
|
|
9
186
|
* Grants for the 7 helper functions. Emitted separately because they
|
|
10
187
|
* reference roles that must exist before the GRANT runs — applied last
|
|
@@ -33,6 +210,35 @@ type AnyExecutor = Pool | PoolClient | SqlExecutor;
|
|
|
33
210
|
*/
|
|
34
211
|
export declare function applyAuthSchemaMigrations(client: AnyExecutor, opts?: {
|
|
35
212
|
includeGrants?: boolean;
|
|
213
|
+
degradedSessionResolver?: boolean;
|
|
214
|
+
}): Promise<void>;
|
|
215
|
+
/**
|
|
216
|
+
* THE WHOLE DELIVERY UNIT AS ONE STRING — resolver + gate, self-classifying,
|
|
217
|
+
* idempotent, re-runnable, and safe to apply on every boot.
|
|
218
|
+
*
|
|
219
|
+
* This is what the three mirrors inline byte-equal, what the CLI applies, and
|
|
220
|
+
* what a service with no other delivery path should call directly from its boot
|
|
221
|
+
* sequence, BEFORE any code that opens an authenticated transaction:
|
|
222
|
+
*
|
|
223
|
+
* import { applySessionFenceBootstrap } from "@nodii/db-rls";
|
|
224
|
+
* await applySessionFenceBootstrap(ownerSql);
|
|
225
|
+
*
|
|
226
|
+
* Grants are separate (as everywhere else in this package) because the runtime
|
|
227
|
+
* roles must exist before a GRANT naming them can run, and on a fresh database
|
|
228
|
+
* they do not yet.
|
|
229
|
+
*/
|
|
230
|
+
export declare const SESSION_FENCE_BOOTSTRAP_SQL: string;
|
|
231
|
+
/**
|
|
232
|
+
* Apply {@link SESSION_FENCE_BOOTSTRAP_SQL} against an owner-privileged client.
|
|
233
|
+
*
|
|
234
|
+
* Idempotent and safe on every boot: the resolver and gate are
|
|
235
|
+
* `CREATE OR REPLACE`, the schema is `IF NOT EXISTS`, and the body selection
|
|
236
|
+
* re-derives from the catalog each run — so a service that lands a membership
|
|
237
|
+
* mirror upgrades itself from the degraded body to the strict one on its next
|
|
238
|
+
* restart, with no migration and no policy change (D415's exit path).
|
|
239
|
+
*/
|
|
240
|
+
export declare function applySessionFenceBootstrap(client: AnyExecutor, opts?: {
|
|
241
|
+
includeGrants?: boolean;
|
|
36
242
|
}): Promise<void>;
|
|
37
243
|
/**
|
|
38
244
|
* Composite full DDL — the schema + helpers + grants, joined.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-schema.d.ts","sourceRoot":"","sources":["../src/auth-schema.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,IAAI,CAAC;
|
|
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,282 @@ LANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('app.user_id', true), ''
|
|
|
47
48
|
CREATE OR REPLACE FUNCTION auth.get_tenant_id() RETURNS uuid
|
|
48
49
|
LANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('app.tenant_id', true), '')::uuid $$;
|
|
49
50
|
|
|
50
|
-
-- Stub variant. Replaced in-place by migrate-gen's
|
|
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$;
|
|
74
|
+
`;
|
|
75
|
+
/**
|
|
76
|
+
* `auth.session_tenant_ids()` — THE authenticated-pool resolver.
|
|
77
|
+
*
|
|
78
|
+
* Returns AT MOST ONE tenant: the one bound to `app.tenant_id`, and only if the
|
|
79
|
+
* principal in `app.user_id` genuinely holds it per `auth.user_tenant_ids`.
|
|
80
|
+
* Edge-signed session tenant **INTERSECT** server-resolved membership.
|
|
81
|
+
*
|
|
82
|
+
* WHY THIS EXISTS. The `authenticated` predicate used to be
|
|
83
|
+
* `tenant_id IN (SELECT auth.user_tenant_ids(auth.get_user_id()))` — the UNION
|
|
84
|
+
* of every tenant the user belongs to, with no tenant fence at all. A session
|
|
85
|
+
* opened for a user with memberships in tenants A and B could read AND write
|
|
86
|
+
* both. Reproduced end-to-end: a legitimate tenant-A session transitioned a
|
|
87
|
+
* tenant-B approval task, got HTTP 200, and released a 250,000 INR refund.
|
|
88
|
+
*
|
|
89
|
+
* WHY SUBSTITUTION RATHER THAN APPENDING A FENCE. The obvious repair is to
|
|
90
|
+
* append `AND tenant_id = auth.get_tenant_id()` at the top level of each
|
|
91
|
+
* policy. That breaks every policy whose shape is not a bare tenant equality,
|
|
92
|
+
* and the fleet is full of them:
|
|
93
|
+
*
|
|
94
|
+
* - tables with NO `tenant_id` column that reach it through a nested
|
|
95
|
+
* `EXISTS` or a sub-`SELECT` (`tenant_role_permissions`, `fx_rate_cache`)
|
|
96
|
+
* - platform-default escape hatches — `tenant_id IS NULL OR tenant_id IN (…)`
|
|
97
|
+
* (hr's statutory masters, `pricing_snapshots`)
|
|
98
|
+
* - additive ORs on a differently-named column — `is_public_listing = TRUE OR
|
|
99
|
+
* developer_tenant_id IN (…)` (`oauth_apps` marketplace listings)
|
|
100
|
+
* - policies fencing on a column that is not `tenant_id` at all (`tenants.id`,
|
|
101
|
+
* `legal_documents.scope`, `impersonation_sessions.target_tenant_id`)
|
|
102
|
+
* - TEXT-vs-uuid casts that need fixing in opposite directions per table
|
|
103
|
+
*
|
|
104
|
+
* Swapping the resolver INSIDE the subquery leaves the column, the cast and the
|
|
105
|
+
* surrounding OR untouched, so all of those keep working while the set the
|
|
106
|
+
* subquery yields narrows from "every tenant you belong to" to "the one you are
|
|
107
|
+
* logged into, if you really belong to it". One substitution, zero shape
|
|
108
|
+
* changes, and NO `AS RESTRICTIVE` (which would AND against the additive
|
|
109
|
+
* permissive master policies and kill every platform-default row).
|
|
110
|
+
*
|
|
111
|
+
* `user_tenant_ids` is deliberately LEFT INTACT. It still answers the honest
|
|
112
|
+
* question "which tenants does this user belong to", which a tenant-switcher or
|
|
113
|
+
* a login tenant-picker legitimately needs. Overloading it with session scope
|
|
114
|
+
* would have silently broken those callers with no error and no test coverage.
|
|
115
|
+
*
|
|
116
|
+
* SECURITY DEFINER is required, not decorative:
|
|
117
|
+
* - `authenticated` has no privilege on the membership mirror, so an INVOKER
|
|
118
|
+
* function raises `permission denied for table user_memberships`;
|
|
119
|
+
* - if the mirror is itself RLS-policied, an INVOKER function re-enters its
|
|
120
|
+
* own policy and dies with `stack depth limit exceeded` (54001).
|
|
121
|
+
*
|
|
122
|
+
* FAIL-CLOSED SPLIT, deliberate:
|
|
123
|
+
* - GUCs unset → RAISE 42501. Misconfiguration is LOUD. An unbound session
|
|
124
|
+
* silently yielding an empty grid reads to a human as "there is no data
|
|
125
|
+
* here", which is how a broken session survives a week of QA.
|
|
126
|
+
* - bound, but no membership → EMPTY SET, no error. A legitimate non-member
|
|
127
|
+
* seeing nothing is not an error condition, and platform-default rows
|
|
128
|
+
* reached through a sibling `OR` must still resolve.
|
|
129
|
+
*/
|
|
130
|
+
export const SESSION_TENANT_IDS_SQL = `CREATE OR REPLACE FUNCTION auth.session_tenant_ids() RETURNS SETOF uuid
|
|
131
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
|
132
|
+
SET search_path = pg_catalog, public, pg_temp
|
|
133
|
+
AS $session$
|
|
134
|
+
DECLARE
|
|
135
|
+
v_user uuid;
|
|
136
|
+
v_tenant uuid;
|
|
137
|
+
BEGIN
|
|
138
|
+
v_user := nullif(current_setting('app.user_id', true), '')::uuid;
|
|
139
|
+
v_tenant := nullif(current_setting('app.tenant_id', true), '')::uuid;
|
|
140
|
+
IF v_user IS NULL THEN
|
|
141
|
+
RAISE EXCEPTION 'db_rls_session_user_missing: app.user_id is not bound on this connection'
|
|
142
|
+
USING ERRCODE = '42501',
|
|
143
|
+
HINT = 'The authenticated pool is the user-session pool; a session with no principal cannot be membership-checked. Open it with withUserContext({ userId, tenantId }, fn) from @nodii/db-rls.';
|
|
144
|
+
END IF;
|
|
145
|
+
IF v_tenant IS NULL THEN
|
|
146
|
+
RAISE EXCEPTION 'db_rls_session_tenant_missing: app.tenant_id is not bound on this connection'
|
|
147
|
+
USING ERRCODE = '42501',
|
|
148
|
+
HINT = 'Every authenticated-pool session must be EXPLICITLY tenant-scoped. Open it with withUserContext({ userId, tenantId }, fn) from @nodii/db-rls.';
|
|
149
|
+
END IF;
|
|
150
|
+
RETURN QUERY SELECT v_tenant WHERE v_tenant IN (SELECT auth.user_tenant_ids(v_user));
|
|
151
|
+
END
|
|
152
|
+
$session$;
|
|
153
|
+
`;
|
|
154
|
+
/**
|
|
155
|
+
* Marker embedded in the DEGRADED body so `pg_proc.prosrc` introspection can
|
|
156
|
+
* tell the two variants apart. {@link verifyTenantFence} reports any database
|
|
157
|
+
* running the degraded resolver — the point is that a service is never on it by
|
|
158
|
+
* accident.
|
|
159
|
+
*/
|
|
160
|
+
export const SESSION_TENANT_IDS_DEGRADED_MARKER = "DB_RLS_DEGRADED_NO_MEMBERSHIP_MIRROR";
|
|
161
|
+
/**
|
|
162
|
+
* DEGRADED variant of {@link SESSION_TENANT_IDS_SQL} — fence ONLY, no
|
|
163
|
+
* membership intersect.
|
|
164
|
+
*
|
|
165
|
+
* FOR SERVICES THAT SHIP NO MEMBERSHIP MIRROR, AND NOTHING ELSE. Such a service
|
|
166
|
+
* has `auth.user_tenant_ids` as the bootstrap stub (`SELECT NULL::uuid WHERE
|
|
167
|
+
* false`), so the strict resolver intersects to the empty set and the entire
|
|
168
|
+
* authenticated pool becomes silently deny-all — the P0 shape of drift
|
|
169
|
+
* 199feabc, where crm's 430 policies were dead and nobody noticed.
|
|
170
|
+
*
|
|
171
|
+
* THE LIST BELOW IS DERIVED FROM THE REPOS, NOT FROM MEMORY. An earlier
|
|
172
|
+
* revision of this comment asserted "nodii-crm-service alone", which was wrong
|
|
173
|
+
* by six services and wrong about crm's urgency. Several repos ship a phase-004
|
|
174
|
+
* or phase-005 `0002_create_rls_helpers.sql` whose real `user_tenant_ids` is
|
|
175
|
+
* FULLY COMMENTED OUT, so the phase-002 stub is still the winner:
|
|
176
|
+
*
|
|
177
|
+
* STUB + a LIVE authenticated pool → deny-all TODAY, needs this variant:
|
|
178
|
+
* - nodii-billing-service 77 call sites; saga + dsar migrations run at
|
|
179
|
+
* BOOT (`src/lib/bootstrap.ts`), so its
|
|
180
|
+
* `saga_outbox` / `lifecycle_requests` /
|
|
181
|
+
* `data_residency_holds` are library-owned. The
|
|
182
|
+
* money path. Highest risk in the fleet.
|
|
183
|
+
* - nodii-finance-service 14 call sites. Already self-mitigates via
|
|
184
|
+
* `010-lib-auth-pool-mode`, which rewrites the
|
|
185
|
+
* lib policies to `= auth.get_tenant_id()`.
|
|
186
|
+
* - nodii-notification-service 3 call sites. Its own policies use
|
|
187
|
+
* `= auth.get_tenant_id()` and keep working, so
|
|
188
|
+
* only the library-owned tables go dark — which
|
|
189
|
+
* is exactly the shape nobody notices.
|
|
190
|
+
*
|
|
191
|
+
* STUB + a pool that is built but has ZERO handler call sites → not broken
|
|
192
|
+
* today, breaks the moment a handler uses it:
|
|
193
|
+
* - nodii-crm-service, nodii-kyc-service, nodii-ticketing-service,
|
|
194
|
+
* nodii-product-service
|
|
195
|
+
*
|
|
196
|
+
* Does NOT need it: nodii-geo-service (no authenticated pool at all —
|
|
197
|
+
* 2-pool), nodii-edge (no `user_tenant_ids`, RLS mode `single`),
|
|
198
|
+
* nodii-customer-service and nodii-vendor-service (already GUC-bodied),
|
|
199
|
+
* nodii-auth / nodii-hr-service / nodii-task-tracking / nodii-tenant-service
|
|
200
|
+
* (REAL membership helpers).
|
|
201
|
+
*
|
|
202
|
+
* WHAT YOU GIVE UP. The database stops verifying the principal. Isolation rests
|
|
203
|
+
* entirely on the application binding `app.tenant_id` correctly — RLS degrades
|
|
204
|
+
* from an independent second layer into "the app said so". A forged or simply
|
|
205
|
+
* mistaken tenant binding becomes access. That is a real reduction in
|
|
206
|
+
* defence-in-depth and it is why this is a separate, named, reported constant
|
|
207
|
+
* rather than a boolean flag on the strict one.
|
|
208
|
+
*
|
|
209
|
+
* THE EXIT. Land a membership mirror, point `auth.user_tenant_ids` at it, then
|
|
210
|
+
* apply {@link SESSION_TENANT_IDS_SQL} over this. No policy changes — the
|
|
211
|
+
* predicate at every call site is identical for both variants.
|
|
212
|
+
*/
|
|
213
|
+
export const SESSION_TENANT_IDS_DEGRADED_SQL = `CREATE OR REPLACE FUNCTION auth.session_tenant_ids() RETURNS SETOF uuid
|
|
214
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
|
215
|
+
SET search_path = pg_catalog, public, pg_temp
|
|
216
|
+
AS $session$
|
|
217
|
+
DECLARE
|
|
218
|
+
v_tenant uuid;
|
|
219
|
+
BEGIN
|
|
220
|
+
-- ${SESSION_TENANT_IDS_DEGRADED_MARKER}: no membership mirror in this service,
|
|
221
|
+
-- so the session tenant is accepted WITHOUT proving the principal holds it.
|
|
222
|
+
-- Replace with the strict resolver as soon as a mirror exists.
|
|
223
|
+
IF nullif(current_setting('app.user_id', true), '') IS NULL THEN
|
|
224
|
+
RAISE EXCEPTION 'db_rls_session_user_missing: app.user_id is not bound on this connection'
|
|
225
|
+
USING ERRCODE = '42501',
|
|
226
|
+
HINT = 'Open the session with withUserContext({ userId, tenantId }, fn) from @nodii/db-rls.';
|
|
227
|
+
END IF;
|
|
228
|
+
v_tenant := nullif(current_setting('app.tenant_id', true), '')::uuid;
|
|
229
|
+
IF v_tenant IS NULL THEN
|
|
230
|
+
RAISE EXCEPTION 'db_rls_session_tenant_missing: app.tenant_id is not bound on this connection'
|
|
231
|
+
USING ERRCODE = '42501',
|
|
232
|
+
HINT = 'Every authenticated-pool session must be EXPLICITLY tenant-scoped. Open it with withUserContext({ userId, tenantId }, fn) from @nodii/db-rls.';
|
|
233
|
+
END IF;
|
|
234
|
+
RETURN QUERY SELECT v_tenant;
|
|
235
|
+
END
|
|
236
|
+
$session$;
|
|
237
|
+
`;
|
|
238
|
+
/** Grant for the resolver. Separate from {@link AUTH_SCHEMA_GRANTS_SQL} for the
|
|
239
|
+
* same reason: the roles must exist before the GRANT runs. */
|
|
240
|
+
export const SESSION_TENANT_IDS_GRANTS_SQL = `GRANT EXECUTE ON FUNCTION auth.session_tenant_ids() TO nodii_services, authenticated;
|
|
241
|
+
`;
|
|
242
|
+
/**
|
|
243
|
+
* THE DELIVERY UNIT. One idempotent, re-runnable, SELF-CLASSIFYING statement
|
|
244
|
+
* that installs whichever resolver body this database can actually support.
|
|
245
|
+
*
|
|
246
|
+
* WHY IT SELF-CLASSIFIES RATHER THAN TAKING A FLAG.
|
|
247
|
+
*
|
|
248
|
+
* The strict and degraded bodies are not interchangeable, and picking the wrong
|
|
249
|
+
* one is silent in BOTH directions:
|
|
250
|
+
*
|
|
251
|
+
* - strict on a service whose `auth.user_tenant_ids` is the bootstrap stub
|
|
252
|
+
* (`SELECT NULL::uuid WHERE false`) intersects to the EMPTY SET, so every
|
|
253
|
+
* authenticated policy denies every row. No error. Measured live on
|
|
254
|
+
* `nodii-dev-postgres`: crm carries 507 authenticated policies, and a strict
|
|
255
|
+
* install there takes ALL 507 dark, not just the 85 already dead.
|
|
256
|
+
* - degraded on a service that HAS a real membership mirror silently drops
|
|
257
|
+
* the principal check, which is the defence-in-depth this release exists to
|
|
258
|
+
* add.
|
|
259
|
+
*
|
|
260
|
+
* A caller-supplied boolean makes that choice a thing somebody has to know. The
|
|
261
|
+
* library shipped exactly such a list in prose — and it was WRONG BY SIX
|
|
262
|
+
* SERVICES on the first pass and had to be re-derived from the repos. A list
|
|
263
|
+
* that was wrong once, maintained by hand, across fifteen repos, on a
|
|
264
|
+
* fail-silent switch, is not a control. It is a countdown.
|
|
265
|
+
*
|
|
266
|
+
* So the choice is made from what the database ACTUALLY CONTAINS, at the moment
|
|
267
|
+
* of install, by the same structural test `isStubMembershipHelper` applies in
|
|
268
|
+
* TypeScript: a membership helper that is absent, or whose body can never
|
|
269
|
+
* return a row, cannot support the intersect. Nobody has to know anything, and
|
|
270
|
+
* there is no list to get wrong.
|
|
271
|
+
*
|
|
272
|
+
* D415 (LOCKED) is the governing decision: a single-tenant-session service
|
|
273
|
+
* whose `user_tenant_ids` is absent, stubbed or uncalled MAY fence on the
|
|
274
|
+
* session tenant — isolation-equivalent, forfeiting no cross-tenant protection.
|
|
275
|
+
* The predicate at every call site is IDENTICAL for both bodies, which is what
|
|
276
|
+
* makes this substitutable at all.
|
|
277
|
+
*
|
|
278
|
+
* IT IS ALSO D415's SUNSET, AUTOMATED. D415's exit path is "land a membership
|
|
279
|
+
* mirror → repoint the helper → swap in the strict body". Because this block
|
|
280
|
+
* re-reads the catalog on EVERY run and is `CREATE OR REPLACE`, the swap
|
|
281
|
+
* happens by itself on the next boot after the mirror lands. Nobody files a
|
|
282
|
+
* follow-up ticket, because there is nothing to follow up.
|
|
283
|
+
*
|
|
284
|
+
* THE ONE-WAY RATCHET. The condition is "absent OR stub", so a real helper
|
|
285
|
+
* ALWAYS yields strict. This block can upgrade degraded → strict; it can never
|
|
286
|
+
* downgrade strict → degraded. That direction is the one that matters: it means
|
|
287
|
+
* this delivery path cannot overwrite a correctly-strict service, and cannot
|
|
288
|
+
* overwrite crm's deliberately-degraded body with a strict one either, because
|
|
289
|
+
* crm's helper is still the stub and will keep classifying as such until it
|
|
290
|
+
* genuinely isn't.
|
|
291
|
+
*
|
|
292
|
+
* Byte-identical across the TS / Go / Python ports (asserted by the parity
|
|
293
|
+
* test). The nested dollar-quote tags are all distinct — `$fence_install$`
|
|
294
|
+
* outer, `$fence_strict$` / `$fence_degraded$` for the EXECUTE literals,
|
|
295
|
+
* `$session$` inside the bodies — so the statement survives every dollar-quote
|
|
296
|
+
* -aware splitter in the fleet, including the ones in @nodii/saga and
|
|
297
|
+
* @nodii/dsar that split the mirrors' DDL on `;\n`.
|
|
298
|
+
*/
|
|
299
|
+
export const INSTALL_SESSION_RESOLVER_SQL = `CREATE SCHEMA IF NOT EXISTS auth;
|
|
300
|
+
DO $fence_install$
|
|
301
|
+
DECLARE
|
|
302
|
+
v_src text;
|
|
303
|
+
v_is_stub boolean;
|
|
304
|
+
BEGIN
|
|
305
|
+
SELECT p.prosrc INTO v_src
|
|
306
|
+
FROM pg_proc p
|
|
307
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
308
|
+
WHERE n.nspname = 'auth' AND p.proname = 'user_tenant_ids'
|
|
309
|
+
LIMIT 1;
|
|
310
|
+
|
|
311
|
+
-- Structural, not an exact-string compare: the bootstrap stub is
|
|
312
|
+
-- \`SELECT NULL::uuid WHERE false\` — an unsatisfiable WHERE and no FROM. A
|
|
313
|
+
-- reformatted or re-indented copy of the same dead body still classifies as a
|
|
314
|
+
-- stub. Mirrors isStubMembershipHelper() in tenant-fence.ts exactly; the two
|
|
315
|
+
-- are asserted equivalent in the test-suite so a fix to one cannot leave the
|
|
316
|
+
-- other behind.
|
|
317
|
+
v_is_stub := v_src IS NULL
|
|
318
|
+
OR (v_src ~* 'where\\s+false' AND v_src !~* 'from\\s+\\w');
|
|
319
|
+
|
|
320
|
+
IF v_is_stub THEN
|
|
321
|
+
EXECUTE $fence_degraded$${SESSION_TENANT_IDS_DEGRADED_SQL.trimEnd()}$fence_degraded$;
|
|
322
|
+
ELSE
|
|
323
|
+
EXECUTE $fence_strict$${SESSION_TENANT_IDS_SQL.trimEnd()}$fence_strict$;
|
|
324
|
+
END IF;
|
|
325
|
+
END
|
|
326
|
+
$fence_install$;
|
|
54
327
|
`;
|
|
55
328
|
/**
|
|
56
329
|
* Grants for the 7 helper functions. Emitted separately because they
|
|
@@ -84,8 +357,62 @@ export async function applyAuthSchemaMigrations(client, opts = {}) {
|
|
|
84
357
|
// SqlExecutor`; cast to the minimal shape.
|
|
85
358
|
const exec = client;
|
|
86
359
|
await exec.query(AUTH_SCHEMA_SQL);
|
|
360
|
+
// The resolver is applied unconditionally alongside the seven. A database
|
|
361
|
+
// where the policies reference `auth.session_tenant_ids()` but the function is
|
|
362
|
+
// absent does not merely misbehave — Postgres validates the reference at
|
|
363
|
+
// CREATE POLICY time, so the migration itself fails with `function
|
|
364
|
+
// auth.session_tenant_ids() does not exist`. Installing both together makes
|
|
365
|
+
// that unreachable.
|
|
366
|
+
//
|
|
367
|
+
// WHICH BODY IS NOT A DECISION THIS FUNCTION MAKES. It is derived from the
|
|
368
|
+
// membership helper actually present in THIS database — see
|
|
369
|
+
// INSTALL_SESSION_RESOLVER_SQL. `degradedSessionResolver` remains as an
|
|
370
|
+
// explicit override for the one case derivation cannot cover: a service that
|
|
371
|
+
// has a real-looking helper but knows it is not yet populated. Passing it is
|
|
372
|
+
// a deliberate, argued act; the default is derivation, so a caller who knows
|
|
373
|
+
// nothing gets the right body.
|
|
374
|
+
await exec.query(opts.degradedSessionResolver
|
|
375
|
+
? SESSION_TENANT_IDS_DEGRADED_SQL
|
|
376
|
+
: INSTALL_SESSION_RESOLVER_SQL);
|
|
377
|
+
// The gate ships with the resolver, always. See sessionFenceBootstrapSql.
|
|
378
|
+
await exec.query(TENANT_FENCE_GATE_SQL);
|
|
87
379
|
if (includeGrants) {
|
|
88
380
|
await exec.query(AUTH_SCHEMA_GRANTS_SQL);
|
|
381
|
+
await exec.query(SESSION_TENANT_IDS_GRANTS_SQL);
|
|
382
|
+
await exec.query(TENANT_FENCE_GATE_GRANTS_SQL);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* THE WHOLE DELIVERY UNIT AS ONE STRING — resolver + gate, self-classifying,
|
|
387
|
+
* idempotent, re-runnable, and safe to apply on every boot.
|
|
388
|
+
*
|
|
389
|
+
* This is what the three mirrors inline byte-equal, what the CLI applies, and
|
|
390
|
+
* what a service with no other delivery path should call directly from its boot
|
|
391
|
+
* sequence, BEFORE any code that opens an authenticated transaction:
|
|
392
|
+
*
|
|
393
|
+
* import { applySessionFenceBootstrap } from "@nodii/db-rls";
|
|
394
|
+
* await applySessionFenceBootstrap(ownerSql);
|
|
395
|
+
*
|
|
396
|
+
* Grants are separate (as everywhere else in this package) because the runtime
|
|
397
|
+
* roles must exist before a GRANT naming them can run, and on a fresh database
|
|
398
|
+
* they do not yet.
|
|
399
|
+
*/
|
|
400
|
+
export const SESSION_FENCE_BOOTSTRAP_SQL = sessionFenceBootstrapSql(INSTALL_SESSION_RESOLVER_SQL);
|
|
401
|
+
/**
|
|
402
|
+
* Apply {@link SESSION_FENCE_BOOTSTRAP_SQL} against an owner-privileged client.
|
|
403
|
+
*
|
|
404
|
+
* Idempotent and safe on every boot: the resolver and gate are
|
|
405
|
+
* `CREATE OR REPLACE`, the schema is `IF NOT EXISTS`, and the body selection
|
|
406
|
+
* re-derives from the catalog each run — so a service that lands a membership
|
|
407
|
+
* mirror upgrades itself from the degraded body to the strict one on its next
|
|
408
|
+
* restart, with no migration and no policy change (D415's exit path).
|
|
409
|
+
*/
|
|
410
|
+
export async function applySessionFenceBootstrap(client, opts = {}) {
|
|
411
|
+
const exec = client;
|
|
412
|
+
await exec.query(SESSION_FENCE_BOOTSTRAP_SQL);
|
|
413
|
+
if (opts.includeGrants ?? false) {
|
|
414
|
+
await exec.query(SESSION_TENANT_IDS_GRANTS_SQL);
|
|
415
|
+
await exec.query(TENANT_FENCE_GATE_GRANTS_SQL);
|
|
89
416
|
}
|
|
90
417
|
}
|
|
91
418
|
/**
|
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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"}
|