@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.
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
@@ -52,6 +52,27 @@ export interface SessionTokensView {
52
52
  readonly accessToken: string;
53
53
  readonly refreshToken: string;
54
54
  }
55
+ /**
56
+ * The caller's own coarse authorization-fact projection — the EXACT shape
57
+ * `GET /identity/auth-facts` returns. Inlined into the login / mfa success body
58
+ * (ADDITIVE, optional) so the frontend can seed its permission-gated nav from
59
+ * the login result in ONE round-trip, instead of a second `/identity/auth-facts`
60
+ * fetch after login resolves. Carries `permissionsVersion` so the SDK's
61
+ * version-staleness contract still holds: a later poke detects a bump and
62
+ * refetches. A single ALLOWED fact per granted key (an absent fact is the
63
+ * fail-closed deny). Carries NO secret.
64
+ */
65
+ export interface AuthFactsView {
66
+ readonly principalId: string;
67
+ readonly facts: ReadonlyArray<{
68
+ readonly action: string;
69
+ readonly allowed: boolean;
70
+ readonly resource?: string;
71
+ readonly reasonCode?: string;
72
+ }>;
73
+ readonly projectedAt: number;
74
+ readonly permissionsVersion?: number;
75
+ }
55
76
  /** A successful session establishment payload (login / mfa / refresh success). */
56
77
  export interface SessionSuccess {
57
78
  readonly session: SessionView;
@@ -64,6 +85,17 @@ export interface SessionSuccess {
64
85
  * the enforced re-enroll flow. Carries NO secret — a pure signal flag.
65
86
  */
66
87
  readonly mfaRecoveryUsed?: boolean;
88
+ /**
89
+ * The caller's own auth-facts projection (same shape as `/identity/auth-facts`),
90
+ * inlined so the permission-gated nav is ready the instant login returns — no
91
+ * second round-trip on the login path. ADDITIVE + OPTIONAL: absent when the
92
+ * projector isn't wired or a projection couldn't be computed (best-effort — a
93
+ * projection failure NEVER fails the login); the frontend then falls back to
94
+ * its separate `/identity/auth-facts` fetch (graceful degradation for an older
95
+ * server too). Attached on login + mfa success; NEVER on the mfa-CHALLENGE
96
+ * branch (no session yet ⇒ no facts).
97
+ */
98
+ readonly authFacts?: AuthFactsView;
67
99
  }
68
100
  /** `POST /auth/login` request body. */
69
101
  export interface LoginRequest {
@@ -1 +1 @@
1
- {"version":3,"file":"dto.d.ts","sourceRoot":"","sources":["../../src/auth/dto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AASH;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,QAAQ,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,CAAC;IAC9D,oBAAoB;IACpB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,oBAAoB;IACpB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAED,kFAAkF;AAClF,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,iBAAiB,CAAC;IACnC;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,CAAC;CACpC;AAMD,uCAAuC;AACvC,MAAM,WAAW,YAAY;IAC3B,8EAA8E;IAC9E,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,+EAA+E;IAC/E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;GAMG;AACH,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,WAAW,EAAE,IAAI,CAAC;IAC3B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;CACjC;AAED;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GAAG,cAAc,GAAG,oBAAoB,CAAC;AAMlE;;;;;;;GAOG;AACH,MAAM,WAAW,UAAU;IACzB,kFAAkF;IAClF,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,eAAe,CAAC;IAC1C,iEAAiE;IACjE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED;;;;GAIG;AACH,MAAM,MAAM,WAAW,GAAG,cAAc,CAAC;AAMzC;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,eAAe,GAAG,cAAc,CAAC;AAM7C;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC;CACxB"}
1
+ {"version":3,"file":"dto.d.ts","sourceRoot":"","sources":["../../src/auth/dto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AASH;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,QAAQ,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,CAAC;IAC9D,oBAAoB;IACpB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,oBAAoB;IACpB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC;QAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;QACxB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;QAC1B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAC3B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;KAC9B,CAAC,CAAC;IACH,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;CACtC;AAED,kFAAkF;AAClF,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,iBAAiB,CAAC;IACnC;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,CAAC;IACnC;;;;;;;;;OASG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,aAAa,CAAC;CACpC;AAMD,uCAAuC;AACvC,MAAM,WAAW,YAAY;IAC3B,8EAA8E;IAC9E,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,+EAA+E;IAC/E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;GAMG;AACH,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,WAAW,EAAE,IAAI,CAAC;IAC3B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;CACjC;AAED;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GAAG,cAAc,GAAG,oBAAoB,CAAC;AAMlE;;;;;;;GAOG;AACH,MAAM,WAAW,UAAU;IACzB,kFAAkF;IAClF,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,eAAe,CAAC;IAC1C,iEAAiE;IACjE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED;;;;GAIG;AACH,MAAM,MAAM,WAAW,GAAG,cAAc,CAAC;AAMzC;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,eAAe,GAAG,cAAc,CAAC;AAM7C;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC;CACxB"}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The `MembershipChecker` PORT (Task 5.2 of the scoped-hierarchy tenancy program,
3
+ * ADR-0027 / ADR-0024) — the OPT-IN seam the auth surface uses to GATE a SCOPE-SWITCH:
4
+ * before a principal may re-bind its session to act under a different active node, the
5
+ * framework MUST confirm the principal actually HOLDS a binding at that node (an active
6
+ * `company_membership`). This is the fail-closed gate that stops a principal switching
7
+ * to a FOREIGN node it is not bound at (which would be a fail-open tenancy hole).
8
+ *
9
+ * ── ONE QUESTION ────────────────────────────────────────────────────────────────────
10
+ * `hasActiveMembership(principalId, nodeId)` → does `principalId` hold an ACTIVE
11
+ * membership at `nodeId`? The identity impl answers with a single existence read over
12
+ * `shield_app.company_membership` (`cm_is_active = 1 AND cm_status = 'active'`).
13
+ *
14
+ * ── LAYERING ────────────────────────────────────────────────────────────────────────
15
+ * The PORT is DEFINED here in `@astram/guard`; the IMPLEMENTATION lives in
16
+ * `@astram/identity` (`DbMembershipChecker`, which owns the app schema + the
17
+ * `company_membership` table). The consumer injects an instance through
18
+ * `ShieldModule.forRoot({ membershipChecker })` — the sanctioned direction (port in
19
+ * guard, impl in identity). Guard never imports identity. Mirrors `ScopeSubtreeResolver`.
20
+ *
21
+ * ── FAIL-CLOSED (the security point) ────────────────────────────────────────────────
22
+ * A scope-switch is DENIED unless the checker AFFIRMATIVELY returns `true`:
23
+ * - ABSENT (no checker wired) → the switch fails closed (cannot verify the binding →
24
+ * do not grant it). An unwired checker NEVER allows a switch.
25
+ * - `false` (no active membership at the target) → DENIED (403). The old binding stands.
26
+ * - a THROW (store outage) → treated as DENY (cannot verify → fail closed).
27
+ * The framework NEVER trusts `targetNodeId` from the client without this affirmative
28
+ * binding check — that would be the fail-open hole (a principal switching to a node it
29
+ * isn't bound at, inheriting that node's subtree + authority).
30
+ */
31
+ export interface MembershipChecker {
32
+ /**
33
+ * Does `principalId` hold an ACTIVE membership at `nodeId` (an active
34
+ * `company_membership` — `cm_is_active = 1 AND cm_status = 'active'`)? Returns `true`
35
+ * ONLY when such a binding exists. The scope-switch gate treats anything but a
36
+ * `true` (a `false`, or a thrown store outage the caller swallows) as a DENY —
37
+ * fail-closed, never fail-open.
38
+ */
39
+ hasActiveMembership(principalId: string, nodeId: string): Promise<boolean>;
40
+ }
41
+ //# sourceMappingURL=membership-checker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"membership-checker.d.ts","sourceRoot":"","sources":["../../../src/auth/ports/membership-checker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,MAAM,WAAW,iBAAiB;IAChC;;;;;;OAMG;IACH,mBAAmB,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC5E"}
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ /**
3
+ * The `MembershipChecker` PORT (Task 5.2 of the scoped-hierarchy tenancy program,
4
+ * ADR-0027 / ADR-0024) — the OPT-IN seam the auth surface uses to GATE a SCOPE-SWITCH:
5
+ * before a principal may re-bind its session to act under a different active node, the
6
+ * framework MUST confirm the principal actually HOLDS a binding at that node (an active
7
+ * `company_membership`). This is the fail-closed gate that stops a principal switching
8
+ * to a FOREIGN node it is not bound at (which would be a fail-open tenancy hole).
9
+ *
10
+ * ── ONE QUESTION ────────────────────────────────────────────────────────────────────
11
+ * `hasActiveMembership(principalId, nodeId)` → does `principalId` hold an ACTIVE
12
+ * membership at `nodeId`? The identity impl answers with a single existence read over
13
+ * `shield_app.company_membership` (`cm_is_active = 1 AND cm_status = 'active'`).
14
+ *
15
+ * ── LAYERING ────────────────────────────────────────────────────────────────────────
16
+ * The PORT is DEFINED here in `@astram/guard`; the IMPLEMENTATION lives in
17
+ * `@astram/identity` (`DbMembershipChecker`, which owns the app schema + the
18
+ * `company_membership` table). The consumer injects an instance through
19
+ * `ShieldModule.forRoot({ membershipChecker })` — the sanctioned direction (port in
20
+ * guard, impl in identity). Guard never imports identity. Mirrors `ScopeSubtreeResolver`.
21
+ *
22
+ * ── FAIL-CLOSED (the security point) ────────────────────────────────────────────────
23
+ * A scope-switch is DENIED unless the checker AFFIRMATIVELY returns `true`:
24
+ * - ABSENT (no checker wired) → the switch fails closed (cannot verify the binding →
25
+ * do not grant it). An unwired checker NEVER allows a switch.
26
+ * - `false` (no active membership at the target) → DENIED (403). The old binding stands.
27
+ * - a THROW (store outage) → treated as DENY (cannot verify → fail closed).
28
+ * The framework NEVER trusts `targetNodeId` from the client without this affirmative
29
+ * binding check — that would be the fail-open hole (a principal switching to a node it
30
+ * isn't bound at, inheriting that node's subtree + authority).
31
+ */
32
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The `ScopeSubtreeResolver` PORT (Task 2.2 of the scoped-hierarchy tenancy program,
3
+ * ADR-0027 / ADR-0024) — the OPT-IN seam the auth surface uses to resolve a principal's
4
+ * ACTIVE-SCOPE SUBTREE (its active scope node + all descendants) ONCE at LOGIN time, so
5
+ * the resolved set can be cached on the session binding and the per-request tenant gate
6
+ * reads it from the binding with ZERO extra DB (the parity guarantee).
7
+ *
8
+ * ── WHY A DEDICATED PORT (a sibling to `SessionFactsProjector`, not an extension) ─────
9
+ * The subtree is a TENANCY-ISOLATION concern (the reach of a principal's active scope),
10
+ * distinct from `SessionFactsProjector`'s PERFORMANCE opt-in (caching coarse grants to
11
+ * skip a gate-5 DB reload). They are wired INDEPENDENTLY: a deployment may want the real
12
+ * hierarchical subtree without turning on facts-caching (the reference consumer wires no
13
+ * facts projector today). One tiny single-responsibility interface, its own fail-safe
14
+ * semantics — no coupling of tenancy correctness to a caching opt-in.
15
+ *
16
+ * ── LAYERING ──────────────────────────────────────────────────────────────────────
17
+ * The PORT is DEFINED here in `@astram/guard`; the IMPLEMENTATION lives in
18
+ * `@astram/identity` (`DbScopeSubtreeResolver`, which owns the app/authz schemas + the
19
+ * `scope_closure` the subtree is read from). The consumer injects an instance through
20
+ * `ShieldModule.forRoot({ scopeSubtreeResolver })` — the sanctioned direction (port in
21
+ * guard, impl in identity). Guard never imports identity.
22
+ *
23
+ * ── OPT-IN / ADDITIVE / FAIL-SAFE ──────────────────────────────────────────────────
24
+ * ABSENT (the default): the auth controller omits `scopeSubtreeNodeIds` when binding the
25
+ * session → the per-request `principalFromClaims` falls back to `[tenantId]` (the self
26
+ * node) = today's exact flat self-tenant isolation (non-widening).
27
+ * PRESENT: the controller calls `resolveSubtree(activeScopeNodeId)` at login and stores
28
+ * the returned set on the binding, so the per-request tenant gate reads the real subtree.
29
+ * A resolver that THROWS is swallowed best-effort at login (→ subtree omitted → self-node
30
+ * fallback): a resolution failure NEVER fails the login and NEVER widens reach. An EMPTY
31
+ * result is likewise treated as "omit" → self-node fallback (non-widening).
32
+ *
33
+ * ── ROOT ──────────────────────────────────────────────────────────────────────────
34
+ * The subtree is resolved the SAME way for every principal; a root's extra cross-tree span
35
+ * comes from `isRoot` in the tenant gate (authoritative), NOT from a widened subtree here.
36
+ */
37
+ export interface ScopeSubtreeResolver {
38
+ /**
39
+ * Resolve the active-scope subtree for `activeScopeNodeId` — that node plus all its
40
+ * descendants — from the scope closure, at login time (ADR-0027). Returns the set of node
41
+ * ids to cache on the session binding. Implementations return `[activeScopeNodeId]` (the
42
+ * self-node, non-widening) when the closure has no rows for the node yet (pre-first-reconcile),
43
+ * and THROW on a store outage (the auth controller swallows it best-effort → the binding
44
+ * omits the subtree → the per-request path falls back to the self-node).
45
+ */
46
+ resolveSubtree(activeScopeNodeId: string): Promise<readonly string[]>;
47
+ }
48
+ //# sourceMappingURL=scope-subtree-resolver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope-subtree-resolver.d.ts","sourceRoot":"","sources":["../../../src/auth/ports/scope-subtree-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,MAAM,WAAW,oBAAoB;IACnC;;;;;;;OAOG;IACH,cAAc,CAAC,iBAAiB,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CAAC;CACvE"}
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ /**
3
+ * The `ScopeSubtreeResolver` PORT (Task 2.2 of the scoped-hierarchy tenancy program,
4
+ * ADR-0027 / ADR-0024) — the OPT-IN seam the auth surface uses to resolve a principal's
5
+ * ACTIVE-SCOPE SUBTREE (its active scope node + all descendants) ONCE at LOGIN time, so
6
+ * the resolved set can be cached on the session binding and the per-request tenant gate
7
+ * reads it from the binding with ZERO extra DB (the parity guarantee).
8
+ *
9
+ * ── WHY A DEDICATED PORT (a sibling to `SessionFactsProjector`, not an extension) ─────
10
+ * The subtree is a TENANCY-ISOLATION concern (the reach of a principal's active scope),
11
+ * distinct from `SessionFactsProjector`'s PERFORMANCE opt-in (caching coarse grants to
12
+ * skip a gate-5 DB reload). They are wired INDEPENDENTLY: a deployment may want the real
13
+ * hierarchical subtree without turning on facts-caching (the reference consumer wires no
14
+ * facts projector today). One tiny single-responsibility interface, its own fail-safe
15
+ * semantics — no coupling of tenancy correctness to a caching opt-in.
16
+ *
17
+ * ── LAYERING ──────────────────────────────────────────────────────────────────────
18
+ * The PORT is DEFINED here in `@astram/guard`; the IMPLEMENTATION lives in
19
+ * `@astram/identity` (`DbScopeSubtreeResolver`, which owns the app/authz schemas + the
20
+ * `scope_closure` the subtree is read from). The consumer injects an instance through
21
+ * `ShieldModule.forRoot({ scopeSubtreeResolver })` — the sanctioned direction (port in
22
+ * guard, impl in identity). Guard never imports identity.
23
+ *
24
+ * ── OPT-IN / ADDITIVE / FAIL-SAFE ──────────────────────────────────────────────────
25
+ * ABSENT (the default): the auth controller omits `scopeSubtreeNodeIds` when binding the
26
+ * session → the per-request `principalFromClaims` falls back to `[tenantId]` (the self
27
+ * node) = today's exact flat self-tenant isolation (non-widening).
28
+ * PRESENT: the controller calls `resolveSubtree(activeScopeNodeId)` at login and stores
29
+ * the returned set on the binding, so the per-request tenant gate reads the real subtree.
30
+ * A resolver that THROWS is swallowed best-effort at login (→ subtree omitted → self-node
31
+ * fallback): a resolution failure NEVER fails the login and NEVER widens reach. An EMPTY
32
+ * result is likewise treated as "omit" → self-node fallback (non-widening).
33
+ *
34
+ * ── ROOT ──────────────────────────────────────────────────────────────────────────
35
+ * The subtree is resolved the SAME way for every principal; a root's extra cross-tree span
36
+ * comes from `isRoot` in the tenant gate (authoritative), NOT from a widened subtree here.
37
+ */
38
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -185,6 +185,31 @@ export declare const SHIELD_ROOT_IDS: unique symbol;
185
185
  * `auth: true`.
186
186
  */
187
187
  export declare const SHIELD_SESSION_FACTS_PROJECTOR: unique symbol;
188
+ /**
189
+ * OPTIONAL {@link import('./ports/scope-subtree-resolver.js').ScopeSubtreeResolver} — the
190
+ * Task 2.2 (ADR-0027 / ADR-0024) active-scope-subtree seam. Threaded through
191
+ * `ShieldModule.forRoot({ scopeSubtreeResolver })` → `AuthModule.forRoot`, `@Optional()`
192
+ * (bound `null` when absent). When PRESENT, the auth controller calls
193
+ * `resolveSubtree(activeScopeNodeId)` ONCE at login and caches the returned node-id set on
194
+ * the session binding (`scopeSubtreeNodeIds`), so the per-request tenant gate reads the real
195
+ * closure-resolved subtree from the binding with ZERO extra DB (the parity guarantee). ABSENT
196
+ * → the controller omits `scopeSubtreeNodeIds` → the per-request path falls back to the
197
+ * self-node `[tenantId]` (today's flat isolation, non-widening). Fail-safe: a resolver that
198
+ * throws NEVER fails login. Purely additive + opt-in. Only consulted under `auth: true`.
199
+ */
200
+ export declare const SHIELD_SCOPE_SUBTREE_RESOLVER: unique symbol;
201
+ /**
202
+ * OPTIONAL {@link import('./ports/membership-checker.js').MembershipChecker} — the Task 5.2
203
+ * (ADR-0027 / ADR-0024) SCOPE-SWITCH gate seam. Threaded through
204
+ * `ShieldModule.forRoot({ membershipChecker })` → `AuthModule.forRoot`, `@Optional()` (bound
205
+ * `null` when absent). When PRESENT, `POST /auth/scope-switch` calls
206
+ * `hasActiveMembership(principalId, targetNodeId)` BEFORE re-binding the session to the new
207
+ * active node — a principal may only switch to a node it HOLDS an active membership at. ABSENT
208
+ * (or a `false`/thrown result) → the switch is DENIED (fail-closed: cannot verify the binding →
209
+ * do not grant it; `targetNodeId` is NEVER trusted without this affirmative check). Only
210
+ * consulted under `auth: true`. Mirrors {@link SHIELD_SCOPE_SUBTREE_RESOLVER}'s wiring.
211
+ */
212
+ export declare const SHIELD_MEMBERSHIP_CHECKER: unique symbol;
188
213
  /**
189
214
  * The password-login {@link AttemptLimiter} (spec §5 / IdP-hardening H1) — keyed
190
215
  * `login:<identityHint>`, default 10 failures / 15-min window. Consumed at the TOP
@@ -1 +1 @@
1
- {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/auth/tokens.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,sBAAsB,EAAE,MAAM,+CAA+C,CAAC;AAEvF;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,eAA2B,CAAC;AAEvD;;;;GAIG;AACH,eAAO,MAAM,YAAY,eAAyB,CAAC;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAW3E;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,eAAgC,CAAC;AAEjE;;;GAGG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;GAGG;AACH,eAAO,MAAM,qBAAqB,eAAkC,CAAC;AAErE;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,eAA8B,CAAC;AAE7D;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,eAAmC,CAAC;AAEvE;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,6BAA6B,eAA0C,CAAC;AAErF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,eAA4B,CAAC;AAEzD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,8BAA8B,eAA2C,CAAC;AAUvF;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,eAAkC,CAAC;AAErE;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC"}
1
+ {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/auth/tokens.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,sBAAsB,EAAE,MAAM,+CAA+C,CAAC;AAEvF;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,eAA2B,CAAC;AAEvD;;;;GAIG;AACH,eAAO,MAAM,YAAY,eAAyB,CAAC;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAW3E;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,eAAgC,CAAC;AAEjE;;;GAGG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;GAGG;AACH,eAAO,MAAM,qBAAqB,eAAkC,CAAC;AAErE;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,eAA8B,CAAC;AAE7D;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,eAAmC,CAAC;AAEvE;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,6BAA6B,eAA0C,CAAC;AAErF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,eAA4B,CAAC;AAEzD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,8BAA8B,eAA2C,CAAC;AAEvF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,6BAA6B,eAA0C,CAAC;AAErF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,yBAAyB,eAAsC,CAAC;AAU7E;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,eAAkC,CAAC;AAErE;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC"}
@@ -23,7 +23,7 @@
23
23
  * ADR-0003: no framework/DB/crypto imports here — just Symbols (+ one re-export).
24
24
  */
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.SHIELD_PERMISSION_FACTS = exports.SHIELD_RESET_LIMITER = exports.SHIELD_FORGOT_HINT_LIMITER = exports.SHIELD_FORGOT_LIMITER = exports.SHIELD_RECOVERY_LIMITER = exports.SHIELD_LOGIN_LIMITER = exports.SHIELD_SESSION_FACTS_PROJECTOR = exports.SHIELD_ROOT_IDS = exports.SHIELD_TOTP_VERIFIER_OVERRIDE = exports.SHIELD_TOTP_VERIFIER = exports.SHIELD_RESET_LINK_BASE_URL = exports.SHIELD_SESSION_SWEEPER = exports.SHIELD_MFA_DEVICE_STORE = exports.SHIELD_AUDIT_SINK = exports.SHIELD_PROFILE_WRITER = exports.SHIELD_RECOVERY_CODE_STORE = exports.SHIELD_EMAIL_SENDER = exports.SHIELD_RESET_TOKEN_STORE = exports.SHIELD_CREDENTIAL_WRITER = exports.SHIELD_SESSION_AUTHORITY = exports.SHIELD_AUTHENTICATOR = exports.SHIELD_PROFILE_RESOLVER = exports.SHIELD_CREDENTIAL_RESOLVER = exports.SHIELD_REDIS = exports.SHIELD_DB_POOL = exports.SHIELD_PASSWORD_HASHER = void 0;
26
+ exports.SHIELD_PERMISSION_FACTS = exports.SHIELD_RESET_LIMITER = exports.SHIELD_FORGOT_HINT_LIMITER = exports.SHIELD_FORGOT_LIMITER = exports.SHIELD_RECOVERY_LIMITER = exports.SHIELD_LOGIN_LIMITER = exports.SHIELD_MEMBERSHIP_CHECKER = exports.SHIELD_SCOPE_SUBTREE_RESOLVER = exports.SHIELD_SESSION_FACTS_PROJECTOR = exports.SHIELD_ROOT_IDS = exports.SHIELD_TOTP_VERIFIER_OVERRIDE = exports.SHIELD_TOTP_VERIFIER = exports.SHIELD_RESET_LINK_BASE_URL = exports.SHIELD_SESSION_SWEEPER = exports.SHIELD_MFA_DEVICE_STORE = exports.SHIELD_AUDIT_SINK = exports.SHIELD_PROFILE_WRITER = exports.SHIELD_RECOVERY_CODE_STORE = exports.SHIELD_EMAIL_SENDER = exports.SHIELD_RESET_TOKEN_STORE = exports.SHIELD_CREDENTIAL_WRITER = exports.SHIELD_SESSION_AUTHORITY = exports.SHIELD_AUTHENTICATOR = exports.SHIELD_PROFILE_RESOLVER = exports.SHIELD_CREDENTIAL_RESOLVER = exports.SHIELD_REDIS = exports.SHIELD_DB_POOL = exports.SHIELD_PASSWORD_HASHER = void 0;
27
27
  var argon2id_password_hasher_js_1 = require("../authentication/argon2id-password-hasher.js");
28
28
  Object.defineProperty(exports, "SHIELD_PASSWORD_HASHER", { enumerable: true, get: function () { return argon2id_password_hasher_js_1.SHIELD_PASSWORD_HASHER; } });
29
29
  /**
@@ -197,6 +197,31 @@ exports.SHIELD_ROOT_IDS = Symbol('SHIELD_ROOT_IDS');
197
197
  * `auth: true`.
198
198
  */
199
199
  exports.SHIELD_SESSION_FACTS_PROJECTOR = Symbol('SHIELD_SESSION_FACTS_PROJECTOR');
200
+ /**
201
+ * OPTIONAL {@link import('./ports/scope-subtree-resolver.js').ScopeSubtreeResolver} — the
202
+ * Task 2.2 (ADR-0027 / ADR-0024) active-scope-subtree seam. Threaded through
203
+ * `ShieldModule.forRoot({ scopeSubtreeResolver })` → `AuthModule.forRoot`, `@Optional()`
204
+ * (bound `null` when absent). When PRESENT, the auth controller calls
205
+ * `resolveSubtree(activeScopeNodeId)` ONCE at login and caches the returned node-id set on
206
+ * the session binding (`scopeSubtreeNodeIds`), so the per-request tenant gate reads the real
207
+ * closure-resolved subtree from the binding with ZERO extra DB (the parity guarantee). ABSENT
208
+ * → the controller omits `scopeSubtreeNodeIds` → the per-request path falls back to the
209
+ * self-node `[tenantId]` (today's flat isolation, non-widening). Fail-safe: a resolver that
210
+ * throws NEVER fails login. Purely additive + opt-in. Only consulted under `auth: true`.
211
+ */
212
+ exports.SHIELD_SCOPE_SUBTREE_RESOLVER = Symbol('SHIELD_SCOPE_SUBTREE_RESOLVER');
213
+ /**
214
+ * OPTIONAL {@link import('./ports/membership-checker.js').MembershipChecker} — the Task 5.2
215
+ * (ADR-0027 / ADR-0024) SCOPE-SWITCH gate seam. Threaded through
216
+ * `ShieldModule.forRoot({ membershipChecker })` → `AuthModule.forRoot`, `@Optional()` (bound
217
+ * `null` when absent). When PRESENT, `POST /auth/scope-switch` calls
218
+ * `hasActiveMembership(principalId, targetNodeId)` BEFORE re-binding the session to the new
219
+ * active node — a principal may only switch to a node it HOLDS an active membership at. ABSENT
220
+ * (or a `false`/thrown result) → the switch is DENIED (fail-closed: cannot verify the binding →
221
+ * do not grant it; `targetNodeId` is NEVER trusted without this affirmative check). Only
222
+ * consulted under `auth: true`. Mirrors {@link SHIELD_SCOPE_SUBTREE_RESOLVER}'s wiring.
223
+ */
224
+ exports.SHIELD_MEMBERSHIP_CHECKER = Symbol('SHIELD_MEMBERSHIP_CHECKER');
200
225
  // ---------------------------------------------------------------------------
201
226
  // Rate-limit seams (anti-brute-force, spec §5) — ONE `AttemptLimiter` class
202
227
  // (Redis-backed), provided as TWO instances with independent thresholds under
@@ -1 +1 @@
1
- {"version":3,"file":"mariadb-profile-writer.d.ts","sourceRoot":"","sources":["../../src/authentication/mariadb-profile-writer.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AACrE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,6BAA6B,CAAC;AAOtE,qBAAa,oBAAqB,YAAW,aAAa;IAE5C,OAAO,CAAC,QAAQ,CAAC,IAAI;IADjC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyB;gBACrB,IAAI,EAAE,IAAI;IAIjC,MAAM,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,kBAAkB,CAAC;CA0B3G"}
1
+ {"version":3,"file":"mariadb-profile-writer.d.ts","sourceRoot":"","sources":["../../src/authentication/mariadb-profile-writer.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AACrE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,6BAA6B,CAAC;AAOtE,qBAAa,oBAAqB,YAAW,aAAa;IAE5C,OAAO,CAAC,QAAQ,CAAC,IAAI;IADjC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyB;gBACrB,IAAI,EAAE,IAAI;IAIjC,MAAM,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,kBAAkB,CAAC;CAmC3G"}
@@ -27,7 +27,16 @@ class MariaDbProfileWriter {
27
27
  // Resolve the caller's active company so the re-read profile is tenant-correct.
28
28
  const active = (await this.pool.query("SELECT cm_company_uuid FROM `shield_app`.`company_membership` WHERE cm_user_uuid = ? AND cm_is_active = 1 AND cm_status = 'active' LIMIT 1", [principalId]));
29
29
  const tenantId = active[0]?.cm_company_uuid ?? '';
30
- const profile = await this.resolver.resolve({ kind: 'user', id: principalId, tenantId, roleIds: [], isRoot: false });
30
+ const profile = await this.resolver.resolve({
31
+ kind: 'user',
32
+ id: principalId,
33
+ // Scope (ADR-0027) — INTERIM self-node default (Task 2.1): non-widening, = today's isolation.
34
+ activeScopeNodeId: tenantId,
35
+ scopeSubtreeNodeIds: [tenantId],
36
+ tenantId,
37
+ roleIds: [],
38
+ isRoot: false,
39
+ });
31
40
  if (profile === null)
32
41
  throw new Error('profile_unavailable'); // fail-closed: never a partial write result
33
42
  return profile;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `translateListScope` — the PURE, SECURITY-CRITICAL translator that turns core's
3
+ * abstract {@link ListScopeSpec} (Phase 1, `list-scope.ts`) into a SAFE, parameterized
4
+ * SQL WHERE fragment. Phase 3 injects the returned fragment into the engine's `list()`
5
+ * so a collection endpoint returns ONLY the rows `decide()` would allow — filtered IN
6
+ * the query (Invariant #8), never fetch-then-discard.
7
+ *
8
+ * ── THE SHAPE IT EMITS (mirrors the record-permit tier composition, `permit-tier.ts`):
9
+ *
10
+ * <tenant-bound> AND ( <positive union> ) [ AND NOT ( <deny union> ) ]
11
+ *
12
+ * • positive union = ownerBaseline OR policyAllow OR share OR crossTenant — a row is
13
+ * INCLUDED if ANY term is true. NO positive term present ⇒ the union is `(FALSE)`
14
+ * (fail-closed — never an empty `()` and never a `TRUE`).
15
+ * • deny union = policyDeny AND-groups OR'd, wrapped in `AND NOT (...)` so a row
16
+ * matching ANY deny group is EXCLUDED even if a share/owner/allow would include it
17
+ * (deny-wins is load-bearing).
18
+ * • policyAllow / policyDeny are AND-GROUPS OR'd: each group is `( c1 AND c2 ... )`,
19
+ * groups joined by ` OR `. Preserving the AND-within / OR-across grouping EXACTLY is
20
+ * security-critical — flattening `(a AND b) OR (c)` into `a OR b OR c` over-widens.
21
+ *
22
+ * ── NO SQL INJECTION (the whole point): the ONLY tokens interpolated into the `sql`
23
+ * string are PHYSICAL COLUMN identifiers resolved from the {@link ObjectType} registry
24
+ * (via `columnOf`/`ownerColumn`/`uuidPkColumn`/`tenantColumnOf` from `@astram/engine` —
25
+ * the guard→engine→core DAG makes that import legal), backtick-quoted. Every SPEC VALUE
26
+ * is a `?` placeholder with the value pushed to `params` in emission order — NEVER
27
+ * interpolated. An UNKNOWN field key (not registered on the type) is REJECTED (throws) —
28
+ * a rule referencing an unregistered field is a misconfiguration, and emitting the raw
29
+ * key as a column IS the injection hole (fail the read closed instead).
30
+ *
31
+ * ── FAIL-CLOSED OPERATORS (mirrors `mariadb-record-rule-adapter.ts`'s `conditionHolds`
32
+ * so the SQL row set equals the per-record verdict, row-for-row):
33
+ * • the NEGATIVE operators (`ne`/`not_in`/`not_contains`/`is_not_owner`) emit an
34
+ * `IS NOT NULL` guard so a NULL column never wrongly matches (the Phase-0/0.4
35
+ * widening bug in SQL form — `NULL <> ?` must not include the row).
36
+ * • `is_owner`/`is_not_owner` additionally guard `<> ''` (an empty owner column must
37
+ * never "match" an empty principal id) and compare to `principal.id`, NOT the
38
+ * condition operand (the operand is ignored, exactly as `conditionHolds` does).
39
+ * • `contains`/`not_contains` escape the LIKE metacharacters (`\ % _`) and use
40
+ * `ESCAPE '\\'` (mirrors record-engine's search LIKE).
41
+ * • an UNKNOWN operator falls to a defensive `FALSE` (fail-closed — never omit-and-widen,
42
+ * never throw-open). The switch is over the closed {@link PolicyOperator} type, so this
43
+ * is belt-and-braces.
44
+ *
45
+ * ── `$actor` TOKENS (mirrors `resolveValue`): a scalar-op value of `'$actor.id'` /
46
+ * `'$actor.tenant'` resolves to the principal's id / tenant AS A BOUND PARAM (still `?`).
47
+ * Inside an `in`/`not_in` SET the adapter does NOT resolve `$actor` — neither do we.
48
+ *
49
+ * ── `in`/`not_in` SERIALIZATION (the Phase-1 review's Low-1/Low-2 obligation): the value
50
+ * may arrive as an ARRAY (`['us','eu']`) or a COMMA-STRING (`'us,eu'`); BOTH expand to
51
+ * the SAME token list (split on `,`, trim) so the emitted `IN (?, ?)` matches the
52
+ * adapter's `String(rpr_value).split(',')` membership test exactly.
53
+ *
54
+ * ── ROOT-UNBOUNDED: `tenantBound === 'root-unbounded'` emits NO tenant clause (root spans
55
+ * tenants) AND OMITS the share + crossTenant terms — root's ownerBaseline is `{kind:'all'}`
56
+ * ⇒ the union is `(TRUE)` (all rows), so share/crossTenant would be redundant (and share
57
+ * needs a tenant value root does not carry). crossTenant is ALWAYS omitted in Task 2.1
58
+ * (Phase 4 completes the real cross-tenant record set).
59
+ *
60
+ * ── `tenantSubtree` — the scoped-hierarchy tenant bound (ADR-0027). Core
61
+ * (`ScopeTenantBound`) emits `{role:'tenantSubtree', nodeIds}` for every non-root list
62
+ * (the generalization of the flat `tenantId` bound), where `nodeIds` is the PRE-EXPANDED
63
+ * reachable subtree (`principal.scopeSubtreeNodeIds` — the active node + descendants,
64
+ * resolved once at login and cached, Task 2.2). This translator emits a DIRECT membership:
65
+ * `tenant_col IN (?, ...)` over those node ids — a row is visible when its tenant node is
66
+ * IN the subtree. Every id is `?`-bound. It uses the SAME cached set the tenant GATE tests
67
+ * membership against, so list ≡ decide holds BY CONSTRUCTION (no closure query at list time
68
+ * → no divergence from a stale/absent `scope_closure` row, and no closure read on the list
69
+ * hot path — the ADR-0024 parity guarantee). The subtree clause is ANDed with the positive
70
+ * union + deny sections exactly like the flat `tenantId` case — NOT an early return. Fails
71
+ * CLOSED (`{sql:'FALSE', params:[]}`) when the type has no tenant column (misconfiguration)
72
+ * or when `nodeIds` is empty (no reachable scope) — never silently widen. Note:
73
+ * `tenantSubtree` carries no single flat tenant value, so the share term (which needs one)
74
+ * is naturally omitted here.
75
+ *
76
+ * PURE — no DB, no I/O. `translateListScope(spec, reg, principal) → { sql, params }`.
77
+ */
78
+ import type { ListScopeSpec, ScopeCondition } from '@astram/core';
79
+ import { type ObjectType } from '@astram/engine';
80
+ /** The translated fragment: a boolean WHERE expression + its ordered bind params. */
81
+ export interface TranslatedListScope {
82
+ /** A SQL boolean expression carrying ONLY `?` placeholders + backtick columns. */
83
+ readonly sql: string;
84
+ /** The bind values, in the exact order the `?` placeholders appear in `sql`. */
85
+ readonly params: unknown[];
86
+ }
87
+ /**
88
+ * The principal facts the translator needs to resolve `is_owner`/`is_not_owner` and
89
+ * `$actor.*` tokens — threaded EXPLICITLY (not read off the spec) so the translator's
90
+ * ownership/token resolution matches the adapter's `input.principal.id` /
91
+ * `input.principal.tenantId` exactly (Decision A in the task report).
92
+ */
93
+ export interface ListScopePrincipal {
94
+ readonly principalId: string;
95
+ readonly principalTenantId: string;
96
+ }
97
+ /** Error thrown when a scope condition references a field the type does not register. */
98
+ export declare class UnknownScopeFieldError extends Error {
99
+ constructor(typeKey: string, field: string);
100
+ }
101
+ /**
102
+ * Translate the full {@link ListScopeSpec} into a SAFE parameterized WHERE fragment.
103
+ *
104
+ * @param spec the abstract visible-set description (from `computeListScope`).
105
+ * @param reg the target object type registration (the ONLY source of columns).
106
+ * @param principal the actor's id + tenant, for `is_owner`/`$actor.*` resolution.
107
+ * @returns `{ sql, params }` — `sql` carries ONLY `?` + backtick columns; `params` the
108
+ * bound values in `?`-order.
109
+ * @throws {@link UnknownScopeFieldError} if any condition names an unregistered field.
110
+ */
111
+ export declare function translateListScope(spec: ListScopeSpec, reg: ObjectType, principal: ListScopePrincipal, opts?: {
112
+ schema?: string;
113
+ nowMs?: () => number;
114
+ /**
115
+ * The OWNING tenant's deny AND-groups (Task 4.2 — the fail-open leak fix). Applied ONLY to
116
+ * the cross-tenant branch (`crossTenantReach AND NOT (owningTenantDeny)`), mirroring
117
+ * `decide()`'s per-record evaluation at the resource's tenant. Empty/omitted ⇒ no
118
+ * owning-tenant deny (the common no-cross-grant path — the structure is then unchanged).
119
+ * The same-tenant deny stays `spec.policyDeny` (scoped to the actor's subtree). Global
120
+ * (NULL-tenant) deny rules appear in BOTH sets (the wiring fetches them for both tenant
121
+ * sets), so they exclude rows in either branch, exactly as `decide()` applies them.
122
+ */
123
+ owningTenantDeny?: readonly (readonly ScopeCondition[])[];
124
+ }): TranslatedListScope;
125
+ //# sourceMappingURL=list-scope-sql.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"list-scope-sql.d.ts","sourceRoot":"","sources":["../../src/authz/list-scope-sql.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4EG;AAEH,OAAO,KAAK,EACV,aAAa,EACb,cAAc,EAIf,MAAM,cAAc,CAAC;AACtB,OAAO,EAKL,KAAK,UAAU,EAChB,MAAM,gBAAgB,CAAC;AAKxB,qFAAqF;AACrF,MAAM,WAAW,mBAAmB;IAClC,kFAAkF;IAClF,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,gFAAgF;IAChF,QAAQ,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC;CAC5B;AAED;;;;;GAKG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;CACpC;AAED,yFAAyF;AACzF,qBAAa,sBAAuB,SAAQ,KAAK;gBACnC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM;CAO3C;AAmID;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,aAAa,EACnB,GAAG,EAAE,UAAU,EACf,SAAS,EAAE,kBAAkB,EAC7B,IAAI,GAAE;IACJ,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,MAAM,CAAC;IACrB;;;;;;;;OAQG;IACH,gBAAgB,CAAC,EAAE,SAAS,CAAC,SAAS,cAAc,EAAE,CAAC,EAAE,CAAC;CACtD,GACL,mBAAmB,CAoJrB"}