@astram/guard 0.5.1 → 0.6.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/dist/auth/account.controller.d.ts.map +1 -1
- package/dist/auth/account.controller.js +3 -0
- package/dist/auth/auth.controller.d.ts +90 -1
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +245 -7
- package/dist/auth/auth.module.d.ts +21 -0
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +28 -0
- package/dist/auth/dto.d.ts +32 -0
- package/dist/auth/dto.d.ts.map +1 -1
- package/dist/auth/ports/membership-checker.d.ts +41 -0
- package/dist/auth/ports/membership-checker.d.ts.map +1 -0
- package/dist/auth/ports/membership-checker.js +32 -0
- package/dist/auth/ports/scope-subtree-resolver.d.ts +48 -0
- package/dist/auth/ports/scope-subtree-resolver.d.ts.map +1 -0
- package/dist/auth/ports/scope-subtree-resolver.js +38 -0
- package/dist/auth/tokens.d.ts +25 -0
- package/dist/auth/tokens.d.ts.map +1 -1
- package/dist/auth/tokens.js +26 -1
- package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
- package/dist/authentication/mariadb-profile-writer.js +10 -1
- package/dist/authz/list-scope-sql.d.ts +125 -0
- package/dist/authz/list-scope-sql.d.ts.map +1 -0
- package/dist/authz/list-scope-sql.js +477 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts +28 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js +49 -0
- package/dist/authz/mariadb-list-scope-adapter.d.ts +61 -0
- package/dist/authz/mariadb-list-scope-adapter.d.ts.map +1 -0
- package/dist/authz/mariadb-list-scope-adapter.js +157 -0
- package/dist/authz/mariadb-policy-store.d.ts +118 -0
- package/dist/authz/mariadb-policy-store.d.ts.map +1 -0
- package/dist/authz/mariadb-policy-store.js +225 -0
- package/dist/authz/mariadb-record-rule-adapter.d.ts +7 -3
- package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-record-rule-adapter.js +156 -13
- package/dist/authz/record-share-writer.d.ts +22 -0
- package/dist/authz/record-share-writer.d.ts.map +1 -1
- package/dist/authz/record-share-writer.js +41 -0
- package/dist/authz/wire-record-authorization.d.ts +35 -1
- package/dist/authz/wire-record-authorization.d.ts.map +1 -1
- package/dist/authz/wire-record-authorization.js +75 -0
- package/dist/identity/identity-actions.d.ts +7 -0
- package/dist/identity/identity-actions.d.ts.map +1 -1
- package/dist/identity/identity-actions.js +8 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +47 -3
- package/dist/module/shield.module.d.ts +33 -0
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +12 -0
- package/dist/request-auth/request-principal.d.ts.map +1 -1
- package/dist/request-auth/request-principal.js +12 -0
- package/dist/request-auth/session-token-binder.d.ts +35 -1
- package/dist/request-auth/session-token-binder.d.ts.map +1 -1
- package/dist/request-auth/session-token-binder.js +123 -8
- package/dist/scope/closure-builder.d.ts +75 -0
- package/dist/scope/closure-builder.d.ts.map +1 -0
- package/dist/scope/closure-builder.js +134 -0
- package/migrations/1710000000009-CreateRecordPermissionRule.cjs +56 -0
- package/migrations/1710000000014-CreateRecordShare.cjs +49 -0
- package/migrations/1710000000015-CreateScopeClosure.cjs +46 -0
- package/migrations/1710000000016-AlterCompanyParentFkRestrict.cjs +71 -0
- package/package.json +3 -3
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `translateListScope` — the PURE, SECURITY-CRITICAL translator that turns core's
|
|
4
|
+
* abstract {@link ListScopeSpec} (Phase 1, `list-scope.ts`) into a SAFE, parameterized
|
|
5
|
+
* SQL WHERE fragment. Phase 3 injects the returned fragment into the engine's `list()`
|
|
6
|
+
* so a collection endpoint returns ONLY the rows `decide()` would allow — filtered IN
|
|
7
|
+
* the query (Invariant #8), never fetch-then-discard.
|
|
8
|
+
*
|
|
9
|
+
* ── THE SHAPE IT EMITS (mirrors the record-permit tier composition, `permit-tier.ts`):
|
|
10
|
+
*
|
|
11
|
+
* <tenant-bound> AND ( <positive union> ) [ AND NOT ( <deny union> ) ]
|
|
12
|
+
*
|
|
13
|
+
* • positive union = ownerBaseline OR policyAllow OR share OR crossTenant — a row is
|
|
14
|
+
* INCLUDED if ANY term is true. NO positive term present ⇒ the union is `(FALSE)`
|
|
15
|
+
* (fail-closed — never an empty `()` and never a `TRUE`).
|
|
16
|
+
* • deny union = policyDeny AND-groups OR'd, wrapped in `AND NOT (...)` so a row
|
|
17
|
+
* matching ANY deny group is EXCLUDED even if a share/owner/allow would include it
|
|
18
|
+
* (deny-wins is load-bearing).
|
|
19
|
+
* • policyAllow / policyDeny are AND-GROUPS OR'd: each group is `( c1 AND c2 ... )`,
|
|
20
|
+
* groups joined by ` OR `. Preserving the AND-within / OR-across grouping EXACTLY is
|
|
21
|
+
* security-critical — flattening `(a AND b) OR (c)` into `a OR b OR c` over-widens.
|
|
22
|
+
*
|
|
23
|
+
* ── NO SQL INJECTION (the whole point): the ONLY tokens interpolated into the `sql`
|
|
24
|
+
* string are PHYSICAL COLUMN identifiers resolved from the {@link ObjectType} registry
|
|
25
|
+
* (via `columnOf`/`ownerColumn`/`uuidPkColumn`/`tenantColumnOf` from `@astram/engine` —
|
|
26
|
+
* the guard→engine→core DAG makes that import legal), backtick-quoted. Every SPEC VALUE
|
|
27
|
+
* is a `?` placeholder with the value pushed to `params` in emission order — NEVER
|
|
28
|
+
* interpolated. An UNKNOWN field key (not registered on the type) is REJECTED (throws) —
|
|
29
|
+
* a rule referencing an unregistered field is a misconfiguration, and emitting the raw
|
|
30
|
+
* key as a column IS the injection hole (fail the read closed instead).
|
|
31
|
+
*
|
|
32
|
+
* ── FAIL-CLOSED OPERATORS (mirrors `mariadb-record-rule-adapter.ts`'s `conditionHolds`
|
|
33
|
+
* so the SQL row set equals the per-record verdict, row-for-row):
|
|
34
|
+
* • the NEGATIVE operators (`ne`/`not_in`/`not_contains`/`is_not_owner`) emit an
|
|
35
|
+
* `IS NOT NULL` guard so a NULL column never wrongly matches (the Phase-0/0.4
|
|
36
|
+
* widening bug in SQL form — `NULL <> ?` must not include the row).
|
|
37
|
+
* • `is_owner`/`is_not_owner` additionally guard `<> ''` (an empty owner column must
|
|
38
|
+
* never "match" an empty principal id) and compare to `principal.id`, NOT the
|
|
39
|
+
* condition operand (the operand is ignored, exactly as `conditionHolds` does).
|
|
40
|
+
* • `contains`/`not_contains` escape the LIKE metacharacters (`\ % _`) and use
|
|
41
|
+
* `ESCAPE '\\'` (mirrors record-engine's search LIKE).
|
|
42
|
+
* • an UNKNOWN operator falls to a defensive `FALSE` (fail-closed — never omit-and-widen,
|
|
43
|
+
* never throw-open). The switch is over the closed {@link PolicyOperator} type, so this
|
|
44
|
+
* is belt-and-braces.
|
|
45
|
+
*
|
|
46
|
+
* ── `$actor` TOKENS (mirrors `resolveValue`): a scalar-op value of `'$actor.id'` /
|
|
47
|
+
* `'$actor.tenant'` resolves to the principal's id / tenant AS A BOUND PARAM (still `?`).
|
|
48
|
+
* Inside an `in`/`not_in` SET the adapter does NOT resolve `$actor` — neither do we.
|
|
49
|
+
*
|
|
50
|
+
* ── `in`/`not_in` SERIALIZATION (the Phase-1 review's Low-1/Low-2 obligation): the value
|
|
51
|
+
* may arrive as an ARRAY (`['us','eu']`) or a COMMA-STRING (`'us,eu'`); BOTH expand to
|
|
52
|
+
* the SAME token list (split on `,`, trim) so the emitted `IN (?, ?)` matches the
|
|
53
|
+
* adapter's `String(rpr_value).split(',')` membership test exactly.
|
|
54
|
+
*
|
|
55
|
+
* ── ROOT-UNBOUNDED: `tenantBound === 'root-unbounded'` emits NO tenant clause (root spans
|
|
56
|
+
* tenants) AND OMITS the share + crossTenant terms — root's ownerBaseline is `{kind:'all'}`
|
|
57
|
+
* ⇒ the union is `(TRUE)` (all rows), so share/crossTenant would be redundant (and share
|
|
58
|
+
* needs a tenant value root does not carry). crossTenant is ALWAYS omitted in Task 2.1
|
|
59
|
+
* (Phase 4 completes the real cross-tenant record set).
|
|
60
|
+
*
|
|
61
|
+
* ── `tenantSubtree` — the scoped-hierarchy tenant bound (ADR-0027). Core
|
|
62
|
+
* (`ScopeTenantBound`) emits `{role:'tenantSubtree', nodeIds}` for every non-root list
|
|
63
|
+
* (the generalization of the flat `tenantId` bound), where `nodeIds` is the PRE-EXPANDED
|
|
64
|
+
* reachable subtree (`principal.scopeSubtreeNodeIds` — the active node + descendants,
|
|
65
|
+
* resolved once at login and cached, Task 2.2). This translator emits a DIRECT membership:
|
|
66
|
+
* `tenant_col IN (?, ...)` over those node ids — a row is visible when its tenant node is
|
|
67
|
+
* IN the subtree. Every id is `?`-bound. It uses the SAME cached set the tenant GATE tests
|
|
68
|
+
* membership against, so list ≡ decide holds BY CONSTRUCTION (no closure query at list time
|
|
69
|
+
* → no divergence from a stale/absent `scope_closure` row, and no closure read on the list
|
|
70
|
+
* hot path — the ADR-0024 parity guarantee). The subtree clause is ANDed with the positive
|
|
71
|
+
* union + deny sections exactly like the flat `tenantId` case — NOT an early return. Fails
|
|
72
|
+
* CLOSED (`{sql:'FALSE', params:[]}`) when the type has no tenant column (misconfiguration)
|
|
73
|
+
* or when `nodeIds` is empty (no reachable scope) — never silently widen. Note:
|
|
74
|
+
* `tenantSubtree` carries no single flat tenant value, so the share term (which needs one)
|
|
75
|
+
* is naturally omitted here.
|
|
76
|
+
*
|
|
77
|
+
* PURE — no DB, no I/O. `translateListScope(spec, reg, principal) → { sql, params }`.
|
|
78
|
+
*/
|
|
79
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
80
|
+
exports.UnknownScopeFieldError = void 0;
|
|
81
|
+
exports.translateListScope = translateListScope;
|
|
82
|
+
const engine_1 = require("@astram/engine");
|
|
83
|
+
/** Schema the `record_share` table lives in (parity with the share adapter). */
|
|
84
|
+
const DEFAULT_SCHEMA = 'shield_authz';
|
|
85
|
+
/** Error thrown when a scope condition references a field the type does not register. */
|
|
86
|
+
class UnknownScopeFieldError extends Error {
|
|
87
|
+
constructor(typeKey, field) {
|
|
88
|
+
super(`list-scope: unknown field '${field}' on type '${typeKey}' — a rule references an ` +
|
|
89
|
+
`unregistered field (misconfiguration); the read fails closed rather than emit it as a column.`);
|
|
90
|
+
this.name = 'UnknownScopeFieldError';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
exports.UnknownScopeFieldError = UnknownScopeFieldError;
|
|
94
|
+
/** Emission accumulator — a fragment builder collects params in `?`-order. */
|
|
95
|
+
class Params {
|
|
96
|
+
values = [];
|
|
97
|
+
bind(value) {
|
|
98
|
+
this.values.push(value);
|
|
99
|
+
return '?';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Resolve a stable field KEY to its backtick-quoted physical column, or REJECT. */
|
|
103
|
+
function columnFor(reg, field) {
|
|
104
|
+
const spec = reg.fields.find((f) => f.key === field);
|
|
105
|
+
if (!spec)
|
|
106
|
+
throw new UnknownScopeFieldError(reg.typeKey, field);
|
|
107
|
+
return `\`${(0, engine_1.columnOf)(spec)}\``;
|
|
108
|
+
}
|
|
109
|
+
/** Escape the LIKE metacharacters (`\ % _`) so a literal value matches literally. */
|
|
110
|
+
function escapeLike(value) {
|
|
111
|
+
return value.replace(/[\\%_]/g, (ch) => `\\${ch}`);
|
|
112
|
+
}
|
|
113
|
+
/** Resolve a `$actor.*` token to the principal value, else the literal (scalar ops only). */
|
|
114
|
+
function resolveScalarValue(raw, principal) {
|
|
115
|
+
if (raw === '$actor.id')
|
|
116
|
+
return principal.principalId;
|
|
117
|
+
if (raw === '$actor.tenant')
|
|
118
|
+
return principal.principalTenantId;
|
|
119
|
+
return raw;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Split an `in`/`not_in` value into its canonical token list. An ARRAY is used as-is
|
|
123
|
+
* (stringified per element); a COMMA-STRING is split on `,` + trimmed — BOTH yield the
|
|
124
|
+
* SAME set the adapter's `String(rpr_value).split(',').map(trim)` produces. `$actor`
|
|
125
|
+
* tokens inside the set are NOT resolved (matches the adapter).
|
|
126
|
+
*/
|
|
127
|
+
function inTokens(value) {
|
|
128
|
+
if (Array.isArray(value))
|
|
129
|
+
return value.map((v) => String(v).trim());
|
|
130
|
+
return String(value)
|
|
131
|
+
.split(',')
|
|
132
|
+
.map((s) => s.trim());
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Translate ONE {@link ScopeCondition} to a SQL fragment + push its params. Mirrors
|
|
136
|
+
* `conditionHolds` per-operator, with the negative-operator `IS NOT NULL` guards and the
|
|
137
|
+
* ownership `<> ''` guards that keep a NULL/empty column from wrongly matching.
|
|
138
|
+
*/
|
|
139
|
+
function translateCondition(cond, reg, principal, p) {
|
|
140
|
+
const col = columnFor(reg, cond.field);
|
|
141
|
+
const op = cond.operator;
|
|
142
|
+
switch (op) {
|
|
143
|
+
case 'eq':
|
|
144
|
+
return `${col} = ${p.bind(resolveScalarValue(cond.value, principal))}`;
|
|
145
|
+
case 'ne':
|
|
146
|
+
// IS NOT NULL guard = fail-closed on absent (mirrors isPresent(actual) && actual !== expected).
|
|
147
|
+
return `(${col} IS NOT NULL AND ${col} <> ${p.bind(resolveScalarValue(cond.value, principal))})`;
|
|
148
|
+
case 'in': {
|
|
149
|
+
const tokens = inTokens(cond.value);
|
|
150
|
+
const placeholders = tokens.map((t) => p.bind(t)).join(', ');
|
|
151
|
+
return `${col} IN (${placeholders})`;
|
|
152
|
+
}
|
|
153
|
+
case 'not_in': {
|
|
154
|
+
// IS NOT NULL guard (fail-closed on absent — the exact Task-0.4 fix in SQL form).
|
|
155
|
+
const tokens = inTokens(cond.value);
|
|
156
|
+
const placeholders = tokens.map((t) => p.bind(t)).join(', ');
|
|
157
|
+
return `(${col} IS NOT NULL AND ${col} NOT IN (${placeholders}))`;
|
|
158
|
+
}
|
|
159
|
+
case 'gt':
|
|
160
|
+
return `${col} > ${p.bind(resolveScalarValue(cond.value, principal))}`;
|
|
161
|
+
case 'gte':
|
|
162
|
+
return `${col} >= ${p.bind(resolveScalarValue(cond.value, principal))}`;
|
|
163
|
+
case 'lt':
|
|
164
|
+
return `${col} < ${p.bind(resolveScalarValue(cond.value, principal))}`;
|
|
165
|
+
case 'lte':
|
|
166
|
+
return `${col} <= ${p.bind(resolveScalarValue(cond.value, principal))}`;
|
|
167
|
+
case 'contains': {
|
|
168
|
+
// IS NOT NULL guard = fail-closed on absent; LIKE with escaped metacharacters.
|
|
169
|
+
const pattern = `%${escapeLike(String(resolveScalarValue(cond.value, principal)))}%`;
|
|
170
|
+
return `(${col} IS NOT NULL AND ${col} LIKE ${p.bind(pattern)} ESCAPE '\\\\')`;
|
|
171
|
+
}
|
|
172
|
+
case 'not_contains': {
|
|
173
|
+
// IS NOT NULL guard (fail-closed on absent — the safer non-widening choice).
|
|
174
|
+
const pattern = `%${escapeLike(String(resolveScalarValue(cond.value, principal)))}%`;
|
|
175
|
+
return `(${col} IS NOT NULL AND ${col} NOT LIKE ${p.bind(pattern)} ESCAPE '\\\\')`;
|
|
176
|
+
}
|
|
177
|
+
case 'is_null':
|
|
178
|
+
return `${col} IS NULL`;
|
|
179
|
+
case 'is_not_null':
|
|
180
|
+
return `${col} IS NOT NULL`;
|
|
181
|
+
case 'is_owner':
|
|
182
|
+
// Compare the field to principal.id (operand IGNORED); <> '' guard = fail-closed on empty.
|
|
183
|
+
return `(${col} IS NOT NULL AND ${col} <> '' AND ${col} = ${p.bind(principal.principalId)})`;
|
|
184
|
+
case 'is_not_owner':
|
|
185
|
+
// Same guards, mirrored for the negative ownership operator.
|
|
186
|
+
return `(${col} IS NOT NULL AND ${col} <> '' AND ${col} <> ${p.bind(principal.principalId)})`;
|
|
187
|
+
default:
|
|
188
|
+
// Unknown operator → a condition that never matches (fail-closed; never widen).
|
|
189
|
+
return 'FALSE';
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Translate ONE AND-group: `( c1 AND c2 ... )`. An empty group ⇒ `(TRUE)` (unconditional). */
|
|
193
|
+
function translateGroup(group, reg, principal, p) {
|
|
194
|
+
if (group.length === 0)
|
|
195
|
+
return '(TRUE)';
|
|
196
|
+
const parts = group.map((c) => translateCondition(c, reg, principal, p));
|
|
197
|
+
return `( ${parts.join(' AND ')} )`;
|
|
198
|
+
}
|
|
199
|
+
/** Translate AND-groups OR'd: `( grp1 ) OR ( grp2 ) ...`, or null when there are none. */
|
|
200
|
+
function translateGroups(groups, reg, principal, p) {
|
|
201
|
+
if (groups.length === 0)
|
|
202
|
+
return null;
|
|
203
|
+
return groups.map((g) => translateGroup(g, reg, principal, p)).join(' OR ');
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Translate the full {@link ListScopeSpec} into a SAFE parameterized WHERE fragment.
|
|
207
|
+
*
|
|
208
|
+
* @param spec the abstract visible-set description (from `computeListScope`).
|
|
209
|
+
* @param reg the target object type registration (the ONLY source of columns).
|
|
210
|
+
* @param principal the actor's id + tenant, for `is_owner`/`$actor.*` resolution.
|
|
211
|
+
* @returns `{ sql, params }` — `sql` carries ONLY `?` + backtick columns; `params` the
|
|
212
|
+
* bound values in `?`-order.
|
|
213
|
+
* @throws {@link UnknownScopeFieldError} if any condition names an unregistered field.
|
|
214
|
+
*/
|
|
215
|
+
function translateListScope(spec, reg, principal, opts = {}) {
|
|
216
|
+
const schema = opts.schema ?? DEFAULT_SCHEMA;
|
|
217
|
+
// The cross-tenant expiry filter binds THIS clock's epoch-ms against the BIGINT
|
|
218
|
+
// `ctg_expires_at_ms` column — timezone-immune (mirrors MariaDbCrossTenantGrantAdapter's
|
|
219
|
+
// injectable clock). Injectable for deterministic tests.
|
|
220
|
+
const nowMs = opts.nowMs ?? (() => Date.now());
|
|
221
|
+
const p = new Params();
|
|
222
|
+
const clauses = [];
|
|
223
|
+
// ── 1. Tenant bound ────────────────────────────────────────────────────────
|
|
224
|
+
const rootUnbounded = spec.tenantBound === 'root-unbounded';
|
|
225
|
+
let tenantValue = null;
|
|
226
|
+
// The node set the SHARE term scopes to (a shared record must belong to a node the
|
|
227
|
+
// principal can reach): flat → [the one tenant]; subtree → the whole reachable node set.
|
|
228
|
+
// Null while root-unbounded (share omitted — root's baseline already includes all).
|
|
229
|
+
let tenantNodeIds = null;
|
|
230
|
+
// The tenant clause is captured here (not pushed directly) so the cross-tenant term can OR
|
|
231
|
+
// into it: a cross-tenant grant reaches rows in the OWNING tenant, which are OUTSIDE the
|
|
232
|
+
// actor's own tenant/subtree — so it WIDENS the tenant gate (the list-form of decide()'s
|
|
233
|
+
// tenant gate PROCEEDING on a grant), it does NOT join the positive union (which filters
|
|
234
|
+
// WITHIN the visible tenants). `null` while root-unbounded (no tenant clause at all).
|
|
235
|
+
let tenantClause = null;
|
|
236
|
+
if (!rootUnbounded) {
|
|
237
|
+
if (spec.tenantBound.role === 'tenantSubtree') {
|
|
238
|
+
// Scoped-hierarchy bound (ADR-0027): the row's tenant node must be IN the principal's
|
|
239
|
+
// reachable subtree. `nodeIds` is the PRE-EXPANDED cached set (self + descendants,
|
|
240
|
+
// resolved once at login — Task 2.2), the SAME set the tenant GATE tests membership
|
|
241
|
+
// against — so `tenant_col IN (?, ...)` over these ids makes list ≡ decide equivalence
|
|
242
|
+
// hold BY CONSTRUCTION (no closure query here → no divergence from a stale/absent
|
|
243
|
+
// closure row, and no closure read on the list hot path — the parity guarantee). All
|
|
244
|
+
// ids are `?`-bound. Falls THROUGH to the union/deny sections (ANDed — NOT early return).
|
|
245
|
+
const tenantCol = (0, engine_1.tenantColumnOf)(reg);
|
|
246
|
+
if (tenantCol === null) {
|
|
247
|
+
// No `trusted:'tenantId'` column. If the type declares a `readScope` (the
|
|
248
|
+
// decoupled read-wall, Task 4.1 — the identity system types company/user/role/…),
|
|
249
|
+
// that clause IS the tenant wall (applied by the engine over the SAME subtree), so
|
|
250
|
+
// the list-scope tenant bound defers to it: emit NO tenant clause here (the positive
|
|
251
|
+
// union still governs, and the engine's readScope ANDs the subtree wall). Without a
|
|
252
|
+
// readScope, a subtree bound on a tenant-column-less type is a misconfiguration →
|
|
253
|
+
// fail closed. tenantNodeIds stays null → the share term (which needs a tenant value)
|
|
254
|
+
// is naturally omitted (a readScope type is not record-shared in v1).
|
|
255
|
+
if (reg.readScope) {
|
|
256
|
+
// defer to readScope — no tenant clause added; fall through to the union/deny.
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
return { sql: 'FALSE', params: [] };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
const nodeIds = spec.tenantBound.nodeIds;
|
|
264
|
+
if (nodeIds.length === 0) {
|
|
265
|
+
// No reachable scope → no rows (fail-closed; shouldn't happen — Task 3.2 always
|
|
266
|
+
// emits at least the active node).
|
|
267
|
+
return { sql: 'FALSE', params: [] };
|
|
268
|
+
}
|
|
269
|
+
const nodePlaceholders = nodeIds.map((n) => p.bind(n)).join(', ');
|
|
270
|
+
tenantClause = `\`${tenantCol}\` IN (${nodePlaceholders})`;
|
|
271
|
+
// The SHARE term scopes to the WHOLE reachable subtree (a record shared to the
|
|
272
|
+
// principal may live at any node it can reach), so `rs_tenant_uuid IN (subtree)`.
|
|
273
|
+
tenantNodeIds = nodeIds;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
tenantValue = spec.tenantBound.value;
|
|
278
|
+
const tenantCol = (0, engine_1.tenantColumnOf)(reg);
|
|
279
|
+
if (tenantCol === null) {
|
|
280
|
+
// A tenant-scoped bound on a type with no tenant column is a misconfiguration →
|
|
281
|
+
// fail closed (the whole fragment is FALSE — no rows). Documented (Decision).
|
|
282
|
+
return { sql: 'FALSE', params: [] };
|
|
283
|
+
}
|
|
284
|
+
tenantClause = `\`${tenantCol}\` = ${p.bind(tenantValue)}`;
|
|
285
|
+
tenantNodeIds = [tenantValue]; // the flat case: the share is scoped to the one tenant
|
|
286
|
+
}
|
|
287
|
+
if (tenantClause !== null)
|
|
288
|
+
clauses.push(tenantClause);
|
|
289
|
+
}
|
|
290
|
+
// ── 2. Positive union ( ownerBaseline OR policyAllow OR share OR crossTenant ) ──
|
|
291
|
+
const positive = [];
|
|
292
|
+
// ownerBaseline
|
|
293
|
+
const ob = spec.ownerBaseline;
|
|
294
|
+
if (ob.kind === 'all') {
|
|
295
|
+
positive.push('TRUE');
|
|
296
|
+
}
|
|
297
|
+
else if (ob.kind === 'owned') {
|
|
298
|
+
const ownerCol = (0, engine_1.ownerColumn)(reg);
|
|
299
|
+
if (ownerCol === null) {
|
|
300
|
+
// owned type with no owner column (misconfig) → contributes FALSE (fail-closed).
|
|
301
|
+
positive.push('FALSE');
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
positive.push(`\`${ownerCol}\` = ${p.bind(ob.principalId)}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// kind:'none' → contributes nothing.
|
|
308
|
+
// policyAllow (AND-groups OR'd)
|
|
309
|
+
const allow = translateGroups(spec.policyAllow, reg, principal, p);
|
|
310
|
+
if (allow !== null)
|
|
311
|
+
positive.push(allow);
|
|
312
|
+
// share — OMITTED for root-unbounded (baseline TRUE already includes everything). For a
|
|
313
|
+
// non-root list (flat OR subtree), the share is scoped to the reachable node set:
|
|
314
|
+
// `rs_tenant_uuid IN (nodes)` (flat = the one tenant; subtree = self + descendants). A
|
|
315
|
+
// record shared to the principal at any reachable node is unioned in. Decision C.
|
|
316
|
+
if (!rootUnbounded && tenantNodeIds !== null && tenantNodeIds.length > 0) {
|
|
317
|
+
const share = translateShareOrdered(spec.share, tenantNodeIds, spec.typeKey, reg, p, schema);
|
|
318
|
+
if (share !== null)
|
|
319
|
+
positive.push(share);
|
|
320
|
+
}
|
|
321
|
+
const union = positive.length === 0 ? 'FALSE' : positive.join(' OR ');
|
|
322
|
+
clauses.push(`( ${union} )`);
|
|
323
|
+
// At this point `clauses` = [ <tenant clause?>, ( <positive union> ) ] — the SAME-TENANT
|
|
324
|
+
// visible positive set: a row must be IN the tenant gate AND satisfy a positive permit.
|
|
325
|
+
const sameTenantPositive = clauses.join(' AND ');
|
|
326
|
+
// ── 3. PER-BRANCH deny scoping (Task 4.2 — the fail-open leak fix) ───────────────────────
|
|
327
|
+
// decide() judges each record AT ITS OWN TENANT, so a Tenant-B deny touches only Tenant-B
|
|
328
|
+
// records. The list must mirror that: the actor-subtree deny (`spec.policyDeny`) scopes the
|
|
329
|
+
// SAME-TENANT branch; the owning-tenant deny (`opts.owningTenantDeny`, fetched over the
|
|
330
|
+
// grant's owning tenants) scopes the CROSS-TENANT branch. A SINGLE global `AND NOT (deny)`
|
|
331
|
+
// over both would (a) leak — miss the owning tenant's deny on a cross-tenant row (the caught
|
|
332
|
+
// bug), and (b) over-restrict — wrongly exclude a same-tenant row matching the owning tenant's
|
|
333
|
+
// deny. Bind order follows emission: actorDeny (inside the same-tenant branch) → crossTenant
|
|
334
|
+
// reach → owningDeny (inside the cross-tenant branch) — the `Params` positional-bind contract.
|
|
335
|
+
const actorDeny = translateGroups(spec.policyDeny, reg, principal, p);
|
|
336
|
+
const sameTenantBranch = actorDeny !== null ? `( ${sameTenantPositive} ) AND NOT ( ${actorDeny} )` : sameTenantPositive;
|
|
337
|
+
// ── Cross-tenant grant reach (§10bis.5) — a FULL permit source, its OWN deny-scoped branch ──
|
|
338
|
+
// In decide(), an active, consented cross-tenant grant BOTH lets the tenant gate proceed
|
|
339
|
+
// (gate 3/5) AND contributes the record-permit (gate 6 via evaluateRecordShare) — so a
|
|
340
|
+
// cross-tenant-reachable row is visible on the grant ALONE (it need NOT be in the actor's own
|
|
341
|
+
// tenant NOR satisfy the actor's own owner/policy/share union). It remains subject to the
|
|
342
|
+
// OWNING tenant's deny-wins (`decide()` evaluates the record's rules at the record's tenant).
|
|
343
|
+
// OMITTED for root-unbounded (baseline TRUE covers all; root carries no grantee identity).
|
|
344
|
+
let result;
|
|
345
|
+
if (!rootUnbounded) {
|
|
346
|
+
const crossTenant = translateCrossTenant(spec, reg, schema, nowMs, p);
|
|
347
|
+
if (crossTenant !== null) {
|
|
348
|
+
const owningDeny = translateGroups(opts.owningTenantDeny ?? [], reg, principal, p);
|
|
349
|
+
const crossBranch = owningDeny !== null ? `( ${crossTenant} ) AND NOT ( ${owningDeny} )` : crossTenant;
|
|
350
|
+
result = `( ${sameTenantBranch} OR ${crossBranch} )`;
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
result = sameTenantBranch;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
result = sameTenantBranch;
|
|
358
|
+
}
|
|
359
|
+
return { sql: result, params: p.values };
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* The share subquery with CORRECT param ordering: tenant + typeKey bind BEFORE the grantee
|
|
363
|
+
* params, matching the emitted SQL left-to-right. Returns null when the share is absent or
|
|
364
|
+
* the type has no uuid PK (composite-PK types key the share off the uuid record id, which
|
|
365
|
+
* they lack in v1 — omit + documented).
|
|
366
|
+
*/
|
|
367
|
+
function translateShareOrdered(share, tenantNodeIds, typeKey, reg, p, schema) {
|
|
368
|
+
if (share.kind !== 'shared-records')
|
|
369
|
+
return null;
|
|
370
|
+
if (reg.primaryKey.kind !== 'uuid')
|
|
371
|
+
return null;
|
|
372
|
+
if (tenantNodeIds.length === 0)
|
|
373
|
+
return null;
|
|
374
|
+
const pkCol = `\`${(0, engine_1.uuidPkColumn)(reg.primaryKey)}\``;
|
|
375
|
+
// Bind in emission order: tenant node(s), typeKey, principalId, roleIds..., action.
|
|
376
|
+
// The share is scoped to the principal's reachable node set (`rs_tenant_uuid IN (...)`):
|
|
377
|
+
// flat = the one tenant; subtree = self + descendants. Every node id is `?`-bound.
|
|
378
|
+
const tenantPlaceholders = tenantNodeIds.map((n) => p.bind(n)).join(', ');
|
|
379
|
+
const typeKeyP = p.bind(typeKey);
|
|
380
|
+
let granteeSql = `(rs_grantee_kind = 'user' AND rs_grantee_uuid = ${p.bind(share.principalId)})`;
|
|
381
|
+
if (share.roleIds.length > 0) {
|
|
382
|
+
const placeholders = share.roleIds.map((r) => p.bind(r)).join(', ');
|
|
383
|
+
granteeSql += ` OR (rs_grantee_kind = 'role' AND rs_grantee_uuid IN (${placeholders}))`;
|
|
384
|
+
}
|
|
385
|
+
const actionP = p.bind(share.action);
|
|
386
|
+
return (`${pkCol} IN (` +
|
|
387
|
+
`SELECT rs_record_uuid FROM \`${schema}\`.\`record_share\`` +
|
|
388
|
+
` WHERE rs_tenant_uuid IN (${tenantPlaceholders})` +
|
|
389
|
+
` AND rs_type_key = ${typeKeyP}` +
|
|
390
|
+
` AND (${granteeSql})` +
|
|
391
|
+
` AND (rs_action = ${actionP} OR rs_action = '*')` +
|
|
392
|
+
`)`);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* The cross-tenant grant reach (§10bis.5) — the list-form of decide()'s tenant gate PROCEEDING
|
|
396
|
+
* on an active, consented cross-tenant grant (ADR-0009). A non-root grantee that holds such a
|
|
397
|
+
* grant reaches the OWNING tenant's granted rows, which live OUTSIDE its own subtree, so this
|
|
398
|
+
* fragment WIDENS the tenant gate (it is OR'd into the tenant clause — never the positive
|
|
399
|
+
* union, which filters WITHIN the visible tenants). Returns `null` when there is no grant
|
|
400
|
+
* descriptor (`kind:'none'`) or the widening is impossible (no tenant column AND no uuid PK).
|
|
401
|
+
*
|
|
402
|
+
* ── MIRRORS MariaDbCrossTenantGrantAdapter's active-grant filter EXACTLY:
|
|
403
|
+
* `ctg_status='accepted' AND ctg_revoked_at IS NULL AND ctg_expires_at_ms > ?` (bound
|
|
404
|
+
* epoch-ms, timezone-immune) AND `ctg_type_key = ?` AND the grantee predicate
|
|
405
|
+
* (`user` OR — only when roles exist — `role IN (...)`; an empty role list emits NO
|
|
406
|
+
* `IN ()`). The DIFFERENCE from the adapter (which answers ONE record and left the ceiling
|
|
407
|
+
* to core): the list CANNOT post-filter, so the ACTION CEILING is filtered IN-QUERY via
|
|
408
|
+
* `JSON_CONTAINS(ctg_allowed_actions, JSON_QUOTE(?))` (the JSON array membership test) — a
|
|
409
|
+
* row whose grant does not permit the listed action never surfaces.
|
|
410
|
+
*
|
|
411
|
+
* ── TWO REACH SHAPES, OR'd (a grant may be either):
|
|
412
|
+
* • `object_type` grant → ALL of the owning tenant's rows of this type: widen by the tenant
|
|
413
|
+
* column — `tenant_col IN (SELECT ctg_owning_tenant_uuid FROM … WHERE ctg_scope='object_type' …)`.
|
|
414
|
+
* Omitted when the type has no tenant column (nothing to widen).
|
|
415
|
+
* • `record` grant → the SPECIFIC record: widen by the uuid PK —
|
|
416
|
+
* `pk IN (SELECT ctg_record_uuid FROM … WHERE ctg_scope='record' AND ctg_record_uuid IS NOT NULL …)`.
|
|
417
|
+
* Omitted when the type has no uuid PK.
|
|
418
|
+
*
|
|
419
|
+
* ── SAFETY: every value (nowMs, typeKey, grantee, each roleId, action) is `?`-bound in
|
|
420
|
+
* emission order — never interpolated. Mirrors {@link translateShareOrdered}.
|
|
421
|
+
*/
|
|
422
|
+
function translateCrossTenant(spec, reg, schema, nowMs, p) {
|
|
423
|
+
const xt = spec.crossTenant;
|
|
424
|
+
if (xt.kind !== 'granted')
|
|
425
|
+
return null;
|
|
426
|
+
const tenantCol = (0, engine_1.tenantColumnOf)(reg);
|
|
427
|
+
const hasUuidPk = reg.primaryKey.kind === 'uuid';
|
|
428
|
+
if (tenantCol === null && !hasUuidPk)
|
|
429
|
+
return null; // nothing to widen — fail-closed (omit)
|
|
430
|
+
const table = `\`${schema}\`.\`cross_tenant_grant\``;
|
|
431
|
+
// The shared active-grant WHERE tail — binds in EMISSION ORDER (typeKey, now, grantee(+roles),
|
|
432
|
+
// action) so the `?` placeholders line up with `Params` positional binds. Emitted (and bound)
|
|
433
|
+
// fresh per subquery so `Params` stays in `?`-order. ⚠️ typeKey binds BEFORE now because
|
|
434
|
+
// `ctg_type_key = ?` is emitted BEFORE `ctg_expires_at_ms > ?` — binding now first swapped the
|
|
435
|
+
// two, feeding the timestamp to ctg_type_key and the typeKey to the expiry compare (a silent
|
|
436
|
+
// no-match the live equivalence gate caught).
|
|
437
|
+
const activeTail = (scopeClause) => {
|
|
438
|
+
const typeKeyP = p.bind(spec.typeKey);
|
|
439
|
+
const nowP = p.bind(nowMs());
|
|
440
|
+
let granteeSql = `(ctg_grantee_kind = 'user' AND ctg_grantee_uuid = ${p.bind(xt.principalId)})`;
|
|
441
|
+
if (xt.roleIds.length > 0) {
|
|
442
|
+
const placeholders = xt.roleIds.map((r) => p.bind(r)).join(', ');
|
|
443
|
+
granteeSql += ` OR (ctg_grantee_kind = 'role' AND ctg_grantee_uuid IN (${placeholders}))`;
|
|
444
|
+
}
|
|
445
|
+
// The action ceiling: a JSON array membership test (the list cannot post-filter). A grant
|
|
446
|
+
// whose ceiling does not contain the listed action contributes NO owning tenant / record.
|
|
447
|
+
const actionP = p.bind(xt.action);
|
|
448
|
+
return (` WHERE ctg_type_key = ${typeKeyP}` +
|
|
449
|
+
` AND ctg_status = 'accepted'` +
|
|
450
|
+
` AND ctg_revoked_at IS NULL` +
|
|
451
|
+
` AND ctg_expires_at_ms > ${nowP}` +
|
|
452
|
+
` AND (${granteeSql})` +
|
|
453
|
+
` AND JSON_CONTAINS(ctg_allowed_actions, JSON_QUOTE(${actionP}))` +
|
|
454
|
+
` AND ${scopeClause}`);
|
|
455
|
+
};
|
|
456
|
+
const reaches = [];
|
|
457
|
+
// object_type reach — widen the tenant column to the owning tenants that granted the whole
|
|
458
|
+
// type. Emitted FIRST (so its params bind first). Omitted when there is no tenant column.
|
|
459
|
+
if (tenantCol !== null) {
|
|
460
|
+
reaches.push(`\`${tenantCol}\` IN (` +
|
|
461
|
+
`SELECT ctg_owning_tenant_uuid FROM ${table}` +
|
|
462
|
+
activeTail(`ctg_scope = 'object_type'`) +
|
|
463
|
+
`)`);
|
|
464
|
+
}
|
|
465
|
+
// record reach — widen the uuid PK to the specific granted records. Omitted when there is no
|
|
466
|
+
// uuid PK. `ctg_record_uuid IS NOT NULL` guards a record row against a spurious NULL match.
|
|
467
|
+
if (hasUuidPk) {
|
|
468
|
+
const pkCol = `\`${(0, engine_1.uuidPkColumn)(reg.primaryKey)}\``;
|
|
469
|
+
reaches.push(`${pkCol} IN (` +
|
|
470
|
+
`SELECT ctg_record_uuid FROM ${table}` +
|
|
471
|
+
activeTail(`ctg_scope = 'record' AND ctg_record_uuid IS NOT NULL`) +
|
|
472
|
+
`)`);
|
|
473
|
+
}
|
|
474
|
+
if (reaches.length === 0)
|
|
475
|
+
return null;
|
|
476
|
+
return reaches.length === 1 ? reaches[0] : `( ${reaches.join(' OR ')} )`;
|
|
477
|
+
}
|
|
@@ -86,5 +86,33 @@ export declare class MariaDbCrossTenantGrantAdapter implements CrossTenantGrantP
|
|
|
86
86
|
nowMs?: () => number;
|
|
87
87
|
});
|
|
88
88
|
findActiveGrant(query: CrossTenantGrantQuery): Promise<CrossTenantGrant | null>;
|
|
89
|
+
/**
|
|
90
|
+
* The DISTINCT owning tenants a grantee reaches for `(typeKey, action)` via an ACTIVE,
|
|
91
|
+
* consented cross-tenant grant — the LIST-form companion to {@link findActiveGrant}. Used by
|
|
92
|
+
* the list-scope wiring to fetch the OWNING tenant's deny rules so a cross-tenant-reached
|
|
93
|
+
* record the owning tenant DENIES is excluded from the grantee's list (deny-wins per the
|
|
94
|
+
* resource's tenant, exactly as `decide()` applies it — closing the fail-open leak the Phase-4
|
|
95
|
+
* adversarial review found).
|
|
96
|
+
*
|
|
97
|
+
* ── SAME active-grant filter as findActiveGrant: `ctg_status='accepted' AND
|
|
98
|
+
* ctg_revoked_at IS NULL AND ctg_expires_at_ms > ?` (bound epoch-ms, timezone-immune) AND
|
|
99
|
+
* the grantee predicate (user OR — only when roles exist — role IN (...)) AND the action
|
|
100
|
+
* ceiling (`JSON_CONTAINS(ctg_allowed_actions, JSON_QUOTE(?))`). Covers BOTH `object_type`
|
|
101
|
+
* and `record` grants (any active grant into a tenant means that tenant's deny rules must
|
|
102
|
+
* scope the grantee's view of it). Returns `[]` when there is no active grant (a normal
|
|
103
|
+
* absence — the caller then adds NO owning-tenant deny).
|
|
104
|
+
*
|
|
105
|
+
* ── FAIL-CLOSED (ADR-0016): a genuine store outage THROWS
|
|
106
|
+
* {@link AuthorizationUnavailableError} — never a silent `[]` that would drop the owning
|
|
107
|
+
* tenant's deny and REOPEN the leak.
|
|
108
|
+
*
|
|
109
|
+
* ── SAFETY: every value (`?`-bound); empty roleIds omits the role branch (never `IN ()`).
|
|
110
|
+
*/
|
|
111
|
+
listActiveOwningTenants(query: {
|
|
112
|
+
grantee: string;
|
|
113
|
+
roleIds: readonly string[];
|
|
114
|
+
typeKey: string;
|
|
115
|
+
action: string;
|
|
116
|
+
}): Promise<string[]>;
|
|
89
117
|
}
|
|
90
118
|
//# sourceMappingURL=mariadb-cross-tenant-grant-adapter.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mariadb-cross-tenant-grant-adapter.d.ts","sourceRoot":"","sources":["../../src/authz/mariadb-cross-tenant-grant-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkEG;AAEH,OAAO,KAAK,EACV,gBAAgB,EAChB,oBAAoB,EACpB,qBAAqB,EAEtB,MAAM,cAAc,CAAC;AAGtB,wFAAwF;AACxF,eAAO,MAAM,8BAA8B,eAA2C,CAAC;AAKvF,oFAAoF;AACpF,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAsDD;;;;GAIG;AACH,qBAAa,8BAA+B,YAAW,oBAAoB;IACzE,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAiB;IACpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;gBAEvB,EAAE,EAAE,cAAc,EAAE,IAAI,GAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,MAAM,CAAA;KAAO;IAQ9E,eAAe,CAAC,KAAK,EAAE,qBAAqB,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"mariadb-cross-tenant-grant-adapter.d.ts","sourceRoot":"","sources":["../../src/authz/mariadb-cross-tenant-grant-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkEG;AAEH,OAAO,KAAK,EACV,gBAAgB,EAChB,oBAAoB,EACpB,qBAAqB,EAEtB,MAAM,cAAc,CAAC;AAGtB,wFAAwF;AACxF,eAAO,MAAM,8BAA8B,eAA2C,CAAC;AAKvF,oFAAoF;AACpF,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAsDD;;;;GAIG;AACH,qBAAa,8BAA+B,YAAW,oBAAoB;IACzE,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAiB;IACpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;gBAEvB,EAAE,EAAE,cAAc,EAAE,IAAI,GAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,MAAM,CAAA;KAAO;IAQ9E,eAAe,CAAC,KAAK,EAAE,qBAAqB,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC;IAwFrF;;;;;;;;;;;;;;;;;;;;;OAqBG;IACG,uBAAuB,CAAC,KAAK,EAAE;QACnC,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;QAC3B,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,MAAM,CAAC;KAChB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;CAgCtB"}
|
|
@@ -207,5 +207,54 @@ class MariaDbCrossTenantGrantAdapter {
|
|
|
207
207
|
};
|
|
208
208
|
return grant;
|
|
209
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* The DISTINCT owning tenants a grantee reaches for `(typeKey, action)` via an ACTIVE,
|
|
212
|
+
* consented cross-tenant grant — the LIST-form companion to {@link findActiveGrant}. Used by
|
|
213
|
+
* the list-scope wiring to fetch the OWNING tenant's deny rules so a cross-tenant-reached
|
|
214
|
+
* record the owning tenant DENIES is excluded from the grantee's list (deny-wins per the
|
|
215
|
+
* resource's tenant, exactly as `decide()` applies it — closing the fail-open leak the Phase-4
|
|
216
|
+
* adversarial review found).
|
|
217
|
+
*
|
|
218
|
+
* ── SAME active-grant filter as findActiveGrant: `ctg_status='accepted' AND
|
|
219
|
+
* ctg_revoked_at IS NULL AND ctg_expires_at_ms > ?` (bound epoch-ms, timezone-immune) AND
|
|
220
|
+
* the grantee predicate (user OR — only when roles exist — role IN (...)) AND the action
|
|
221
|
+
* ceiling (`JSON_CONTAINS(ctg_allowed_actions, JSON_QUOTE(?))`). Covers BOTH `object_type`
|
|
222
|
+
* and `record` grants (any active grant into a tenant means that tenant's deny rules must
|
|
223
|
+
* scope the grantee's view of it). Returns `[]` when there is no active grant (a normal
|
|
224
|
+
* absence — the caller then adds NO owning-tenant deny).
|
|
225
|
+
*
|
|
226
|
+
* ── FAIL-CLOSED (ADR-0016): a genuine store outage THROWS
|
|
227
|
+
* {@link AuthorizationUnavailableError} — never a silent `[]` that would drop the owning
|
|
228
|
+
* tenant's deny and REOPEN the leak.
|
|
229
|
+
*
|
|
230
|
+
* ── SAFETY: every value (`?`-bound); empty roleIds omits the role branch (never `IN ()`).
|
|
231
|
+
*/
|
|
232
|
+
async listActiveOwningTenants(query) {
|
|
233
|
+
let granteeSql = `(ctg_grantee_kind = 'user' AND ctg_grantee_uuid = ?)`;
|
|
234
|
+
const granteeParams = [query.grantee];
|
|
235
|
+
if (query.roleIds.length > 0) {
|
|
236
|
+
const placeholders = query.roleIds.map(() => '?').join(', ');
|
|
237
|
+
granteeSql += ` OR (ctg_grantee_kind = 'role' AND ctg_grantee_uuid IN (${placeholders}))`;
|
|
238
|
+
granteeParams.push(...query.roleIds);
|
|
239
|
+
}
|
|
240
|
+
const nowMs = this.now();
|
|
241
|
+
const params = [query.typeKey, nowMs, ...granteeParams, query.action];
|
|
242
|
+
let rows;
|
|
243
|
+
try {
|
|
244
|
+
rows = (await this.db.query(`SELECT DISTINCT ctg_owning_tenant_uuid` +
|
|
245
|
+
` FROM \`${this.schema}\`.\`cross_tenant_grant\`` +
|
|
246
|
+
` WHERE ctg_type_key = ?` +
|
|
247
|
+
` AND ctg_status = 'accepted'` +
|
|
248
|
+
` AND ctg_revoked_at IS NULL` +
|
|
249
|
+
` AND ctg_expires_at_ms > ?` +
|
|
250
|
+
` AND (${granteeSql})` +
|
|
251
|
+
` AND JSON_CONTAINS(ctg_allowed_actions, JSON_QUOTE(?))`, params));
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
throw new core_1.AuthorizationUnavailableError(`cross_tenant_grant store unavailable resolving owning tenants for ` +
|
|
255
|
+
`${query.typeKey}:${query.action}: ${err.message}`);
|
|
256
|
+
}
|
|
257
|
+
return rows.map((r) => r.ctg_owning_tenant_uuid).filter((t) => typeof t === 'string');
|
|
258
|
+
}
|
|
210
259
|
}
|
|
211
260
|
exports.MariaDbCrossTenantGrantAdapter = MariaDbCrossTenantGrantAdapter;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MariaDbListScopeAdapter` — the concrete {@link ListScopePort} for `@astram/guard`,
|
|
3
|
+
* backed by the SAME `record_permission_rule` table the record-permit rule adapter
|
|
4
|
+
* ({@link MariaDbRecordPermissionRuleAdapter}) enforces. This is the LIST-form sibling
|
|
5
|
+
* to that adapter's `evaluate`: where `evaluate` folds the rules into an allow/deny
|
|
6
|
+
* verdict for ONE record, this projects them into the abstract allow/deny
|
|
7
|
+
* {@link ScopeCondition} GROUPS that core's `computeListScope` hands the guard translator
|
|
8
|
+
* ({@link translateListScope}) to push into a collection's WHERE (Invariant #8).
|
|
9
|
+
*
|
|
10
|
+
* ── WHY HERE, NOT CORE (ADR-0003): the `mariadb` driver is a real runtime dependency;
|
|
11
|
+
* core defines only the {@link ListScopePort}. This adapter is INJECTED into the wired
|
|
12
|
+
* list-scope computation. It uses `@astram/core` TYPES only (erased).
|
|
13
|
+
*
|
|
14
|
+
* ── THE SELECTION (identical to the rule adapter's `evaluate`): rows for
|
|
15
|
+
* `(rpr_type_key = ? AND (rpr_tenant_uuid = ? OR rpr_tenant_uuid IS NULL)
|
|
16
|
+
* AND (rpr_action = ? OR rpr_action = '*'))` — the exact-tenant OR global-NULL rule
|
|
17
|
+
* and the exact-action OR `*` wildcard, all values BOUND as `?` (never interpolated).
|
|
18
|
+
* NO OR-IS-NULL escalation of a tenant-scoped rule beyond that global-baseline match.
|
|
19
|
+
*
|
|
20
|
+
* ── THE GROUPING (Phase-0 / Task-0.3, mirrors both the rule adapter's `evaluate` and the
|
|
21
|
+
* policy store's `listPolicies`): rows sharing a non-null `rpr_policy_uuid` form ONE
|
|
22
|
+
* AND-group (a guided policy's N AND-composed conditions); a NULL-uuid row is its OWN
|
|
23
|
+
* singleton group (legacy single-row rule — an OR term, never merged). Allow-effect
|
|
24
|
+
* groups → `allow: ScopeCondition[][]`; deny-effect groups → `deny: ScopeCondition[][]`.
|
|
25
|
+
* A NULL-field row contributes NO condition — an all-`TRUE` (unconditional) group,
|
|
26
|
+
* which the translator renders as `(TRUE)` (a valid "allow/deny all of this type" rule).
|
|
27
|
+
*
|
|
28
|
+
* ── VALUE FIDELITY: `rpr_value` is read back as its stored VARCHAR string — the SAME form
|
|
29
|
+
* the enforcement adapter's `conditionHolds` sees (it always coerces via `String(...)`).
|
|
30
|
+
* For `in`/`not_in` this string is the canonical comma form the translator splits on `,`,
|
|
31
|
+
* so the emitted `IN (...)` list equals the adapter's `String(rpr_value).split(',')` set
|
|
32
|
+
* (the Phase-1 review's serialization obligation — Decision B in the task report).
|
|
33
|
+
*
|
|
34
|
+
* ── FAIL-CLOSED (ADR-0016 / Invariant #8): a genuine store outage THROWS
|
|
35
|
+
* {@link AuthorizationUnavailableError} — NEVER an empty `{allow:[],deny:[]}` that would
|
|
36
|
+
* silently unbound/mis-scope the list (an unbounded list is a leak). An empty result is
|
|
37
|
+
* returned ONLY for a genuine data-absence (no applicable rule), NEVER to mask an outage.
|
|
38
|
+
*
|
|
39
|
+
* ── SAFETY: parameterized SQL only; schema `shield_authz`. Mirrors
|
|
40
|
+
* {@link MariaDbRecordPermissionRuleAdapter}.
|
|
41
|
+
*/
|
|
42
|
+
import type { ListScopePort, ListPolicyConditions, ListScopeSubject } from '@astram/core';
|
|
43
|
+
/** The DI token the adapter is bound under (parity with the rule-port token). */
|
|
44
|
+
export declare const SHIELD_LIST_SCOPE_PORT: unique symbol;
|
|
45
|
+
/** The minimal query surface this adapter needs — a `mariadb` Pool satisfies it. */
|
|
46
|
+
export interface ListScopeQueryable {
|
|
47
|
+
query(sql: string, params?: readonly unknown[]): Promise<unknown>;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The concrete list-scope adapter. Reads the ADMIN rules for `(tenant, type_key, action|*)`
|
|
51
|
+
* and projects them into allow/deny {@link ScopeCondition} AND-groups for the translator.
|
|
52
|
+
*/
|
|
53
|
+
export declare class MariaDbListScopeAdapter implements ListScopePort {
|
|
54
|
+
private readonly db;
|
|
55
|
+
private readonly schema;
|
|
56
|
+
constructor(db: ListScopeQueryable, opts?: {
|
|
57
|
+
schema?: string;
|
|
58
|
+
});
|
|
59
|
+
listPolicyConditions(typeKey: string, tenantNodeIds: readonly string[], action: string, subject: ListScopeSubject): Promise<ListPolicyConditions>;
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=mariadb-list-scope-adapter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mariadb-list-scope-adapter.d.ts","sourceRoot":"","sources":["../../src/authz/mariadb-list-scope-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,OAAO,KAAK,EACV,aAAa,EACb,oBAAoB,EACpB,gBAAgB,EAGjB,MAAM,cAAc,CAAC;AAGtB,iFAAiF;AACjF,eAAO,MAAM,sBAAsB,eAAmC,CAAC;AAKvE,oFAAoF;AACpF,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAoCD;;;GAGG;AACH,qBAAa,uBAAwB,YAAW,aAAa;IAC3D,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAqB;IACxC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;gBAEpB,EAAE,EAAE,kBAAkB,EAAE,IAAI,GAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAO;IAK5D,oBAAoB,CACxB,OAAO,EAAE,MAAM,EACf,aAAa,EAAE,SAAS,MAAM,EAAE,EAChC,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,oBAAoB,CAAC;CA8EjC"}
|