@astram/guard 0.5.0 → 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.
Files changed (64) hide show
  1. package/dist/auth/account.controller.d.ts.map +1 -1
  2. package/dist/auth/account.controller.js +3 -0
  3. package/dist/auth/auth.controller.d.ts +90 -1
  4. package/dist/auth/auth.controller.d.ts.map +1 -1
  5. package/dist/auth/auth.controller.js +245 -7
  6. package/dist/auth/auth.module.d.ts +21 -0
  7. package/dist/auth/auth.module.d.ts.map +1 -1
  8. package/dist/auth/auth.module.js +28 -0
  9. package/dist/auth/dto.d.ts +32 -0
  10. package/dist/auth/dto.d.ts.map +1 -1
  11. package/dist/auth/ports/membership-checker.d.ts +41 -0
  12. package/dist/auth/ports/membership-checker.d.ts.map +1 -0
  13. package/dist/auth/ports/membership-checker.js +32 -0
  14. package/dist/auth/ports/scope-subtree-resolver.d.ts +48 -0
  15. package/dist/auth/ports/scope-subtree-resolver.d.ts.map +1 -0
  16. package/dist/auth/ports/scope-subtree-resolver.js +38 -0
  17. package/dist/auth/tokens.d.ts +25 -0
  18. package/dist/auth/tokens.d.ts.map +1 -1
  19. package/dist/auth/tokens.js +26 -1
  20. package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
  21. package/dist/authentication/mariadb-profile-writer.js +10 -1
  22. package/dist/authz/list-scope-sql.d.ts +125 -0
  23. package/dist/authz/list-scope-sql.d.ts.map +1 -0
  24. package/dist/authz/list-scope-sql.js +477 -0
  25. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts +28 -0
  26. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -1
  27. package/dist/authz/mariadb-cross-tenant-grant-adapter.js +49 -0
  28. package/dist/authz/mariadb-list-scope-adapter.d.ts +61 -0
  29. package/dist/authz/mariadb-list-scope-adapter.d.ts.map +1 -0
  30. package/dist/authz/mariadb-list-scope-adapter.js +157 -0
  31. package/dist/authz/mariadb-policy-store.d.ts +118 -0
  32. package/dist/authz/mariadb-policy-store.d.ts.map +1 -0
  33. package/dist/authz/mariadb-policy-store.js +225 -0
  34. package/dist/authz/mariadb-record-rule-adapter.d.ts +7 -3
  35. package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -1
  36. package/dist/authz/mariadb-record-rule-adapter.js +156 -13
  37. package/dist/authz/record-share-writer.d.ts +22 -0
  38. package/dist/authz/record-share-writer.d.ts.map +1 -1
  39. package/dist/authz/record-share-writer.js +41 -0
  40. package/dist/authz/wire-record-authorization.d.ts +35 -1
  41. package/dist/authz/wire-record-authorization.d.ts.map +1 -1
  42. package/dist/authz/wire-record-authorization.js +75 -0
  43. package/dist/identity/identity-actions.d.ts +7 -0
  44. package/dist/identity/identity-actions.d.ts.map +1 -1
  45. package/dist/identity/identity-actions.js +8 -1
  46. package/dist/index.d.ts +7 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +47 -3
  49. package/dist/module/shield.module.d.ts +33 -0
  50. package/dist/module/shield.module.d.ts.map +1 -1
  51. package/dist/module/shield.module.js +12 -0
  52. package/dist/request-auth/request-principal.d.ts.map +1 -1
  53. package/dist/request-auth/request-principal.js +12 -0
  54. package/dist/request-auth/session-token-binder.d.ts +35 -1
  55. package/dist/request-auth/session-token-binder.d.ts.map +1 -1
  56. package/dist/request-auth/session-token-binder.js +123 -8
  57. package/dist/scope/closure-builder.d.ts +75 -0
  58. package/dist/scope/closure-builder.d.ts.map +1 -0
  59. package/dist/scope/closure-builder.js +134 -0
  60. package/migrations/1710000000009-CreateRecordPermissionRule.cjs +56 -0
  61. package/migrations/1710000000014-CreateRecordShare.cjs +49 -0
  62. package/migrations/1710000000015-CreateScopeClosure.cjs +46 -0
  63. package/migrations/1710000000016-AlterCompanyParentFkRestrict.cjs +71 -0
  64. 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;CAuFtF"}
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"}