@astram/identity 0.2.0 → 0.3.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 (56) hide show
  1. package/dist/company-membership.object.d.ts.map +1 -1
  2. package/dist/company-membership.object.js +7 -0
  3. package/dist/company.object.d.ts.map +1 -1
  4. package/dist/company.object.js +5 -0
  5. package/dist/features/authz/catalog-bootstrap.provider.d.ts +6 -1
  6. package/dist/features/authz/catalog-bootstrap.provider.d.ts.map +1 -1
  7. package/dist/features/authz/catalog-bootstrap.provider.js +28 -4
  8. package/dist/features/authz/grant-admin.controller.d.ts +4 -3
  9. package/dist/features/authz/grant-admin.controller.d.ts.map +1 -1
  10. package/dist/features/authz/grant-admin.controller.js +22 -9
  11. package/dist/features/authz/grant-admin.service.d.ts +19 -3
  12. package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
  13. package/dist/features/authz/grant-admin.service.js +43 -5
  14. package/dist/features/authz/record-authorization.d.ts +11 -2
  15. package/dist/features/authz/record-authorization.d.ts.map +1 -1
  16. package/dist/features/authz/record-authorization.js +11 -3
  17. package/dist/features/cross-tenant/grant-authorize.d.ts.map +1 -1
  18. package/dist/features/cross-tenant/grant-authorize.js +3 -0
  19. package/dist/features/identity-feature.module.d.ts.map +1 -1
  20. package/dist/features/identity-feature.module.js +136 -4
  21. package/dist/features/membership/membership-checker.d.ts +43 -0
  22. package/dist/features/membership/membership-checker.d.ts.map +1 -0
  23. package/dist/features/membership/membership-checker.js +25 -0
  24. package/dist/features/policy/policy.controller.d.ts +46 -0
  25. package/dist/features/policy/policy.controller.d.ts.map +1 -0
  26. package/dist/features/policy/policy.controller.js +150 -0
  27. package/dist/features/policy/policy.service.d.ts +129 -0
  28. package/dist/features/policy/policy.service.d.ts.map +1 -0
  29. package/dist/features/policy/policy.service.js +142 -0
  30. package/dist/features/scope/scope-closure.service.d.ts +82 -0
  31. package/dist/features/scope/scope-closure.service.d.ts.map +1 -0
  32. package/dist/features/scope/scope-closure.service.js +222 -0
  33. package/dist/features/scope/scope-subtree.resolver.d.ts +47 -0
  34. package/dist/features/scope/scope-subtree.resolver.d.ts.map +1 -0
  35. package/dist/features/scope/scope-subtree.resolver.js +24 -0
  36. package/dist/features/share/share.controller.d.ts +35 -0
  37. package/dist/features/share/share.controller.d.ts.map +1 -0
  38. package/dist/features/share/share.controller.js +143 -0
  39. package/dist/features/share/share.service.d.ts +90 -0
  40. package/dist/features/share/share.service.d.ts.map +1 -0
  41. package/dist/features/share/share.service.js +58 -0
  42. package/dist/index.d.ts +17 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +20 -1
  45. package/dist/membership-posting.object.d.ts.map +1 -1
  46. package/dist/membership-posting.object.js +10 -0
  47. package/dist/objects.d.ts.map +1 -1
  48. package/dist/objects.js +2 -1
  49. package/dist/record-share.object.d.ts +24 -0
  50. package/dist/record-share.object.d.ts.map +1 -0
  51. package/dist/record-share.object.js +60 -0
  52. package/dist/role.object.d.ts.map +1 -1
  53. package/dist/role.object.js +10 -0
  54. package/dist/user.object.d.ts.map +1 -1
  55. package/dist/user.object.js +10 -0
  56. package/package.json +4 -4
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PolicyService = exports.PolicyNotFoundError = exports.PolicyAuthorDeniedError = exports.InvalidPolicyError = void 0;
4
+ exports.validOperatorsForFieldType = validOperatorsForFieldType;
5
+ const core_1 = require("@astram/core");
6
+ /** A guided policy could not be authored because it is structurally invalid (→ 400). */
7
+ class InvalidPolicyError extends Error {
8
+ constructor(message = 'The policy is invalid.') {
9
+ super(message);
10
+ this.name = 'InvalidPolicyError';
11
+ }
12
+ }
13
+ exports.InvalidPolicyError = InvalidPolicyError;
14
+ /** The acting admin exceeds their authority (delegated-admin ceiling) — author denied (→ 403). */
15
+ class PolicyAuthorDeniedError extends Error {
16
+ constructor(message = 'You may not author this policy.') {
17
+ super(message);
18
+ this.name = 'PolicyAuthorDeniedError';
19
+ }
20
+ }
21
+ exports.PolicyAuthorDeniedError = PolicyAuthorDeniedError;
22
+ /** A policy id was not found in the acting admin's tenant (absent OR another tenant's) (→ 404). */
23
+ class PolicyNotFoundError extends Error {
24
+ constructor(message = 'Policy not found.') {
25
+ super(message);
26
+ this.name = 'PolicyNotFoundError';
27
+ }
28
+ }
29
+ exports.PolicyNotFoundError = PolicyNotFoundError;
30
+ // Operator families, mirroring core's validator (policies.ts) narrowed for GUIDED-BUILDER UX.
31
+ // The server's `validateGuidedPolicy` remains the authority; this is what the builder OFFERS.
32
+ const NULLARY = ['is_null', 'is_not_null'];
33
+ const ARRAY_OPS = ['in', 'not_in'];
34
+ const RELATIONAL = ['gt', 'lt', 'gte', 'lte'];
35
+ const TEXT = ['contains', 'not_contains'];
36
+ const EQUALITY = ['eq', 'ne', 'in', 'not_in'];
37
+ /**
38
+ * The operators the guided builder offers for a FIELD TYPE. Equality + null apply to every type;
39
+ * relational (gt/lt/…) only to numeric + timestamp (the recorded v1 limit — lexical vs numeric
40
+ * ordering on a VARCHAR diverges, so relational is NOT offered on string/uuid); text
41
+ * (contains/…) only to string. boolean/json get equality + null only. This is INTENTIONALLY a
42
+ * subset the builder shows; the server accepts anything `operatorValidForField` allows.
43
+ */
44
+ function validOperatorsForFieldType(type) {
45
+ const base = [...EQUALITY, ...NULLARY];
46
+ switch (type) {
47
+ case 'number':
48
+ case 'timestamp':
49
+ return [...base, ...RELATIONAL];
50
+ case 'string':
51
+ return [...base, ...TEXT];
52
+ case 'boolean':
53
+ // No membership on a boolean (in/not_in over true/false is pointless) → eq/ne + null.
54
+ return ['eq', 'ne', ...NULLARY];
55
+ case 'json':
56
+ case 'uuid':
57
+ default:
58
+ return base;
59
+ }
60
+ }
61
+ class PolicyService {
62
+ store;
63
+ authorPolicy;
64
+ schemaByType;
65
+ constructor(deps) {
66
+ this.store = deps.policyStore;
67
+ this.schemaByType = new Map((deps.schemaSource ?? []).map((s) => [s.typeKey, s]));
68
+ this.authorPolicy = (0, core_1.makeAuthorPolicy)({
69
+ registry: deps.registry,
70
+ // makeAuthorPolicy's PolicyStore only needs savePolicy; the concrete store satisfies it.
71
+ policyStore: deps.policyStore,
72
+ decide: deps.decide,
73
+ audit: deps.audit,
74
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
75
+ });
76
+ }
77
+ /**
78
+ * The guided-builder SCHEMA for a type: its authorable fields (each with its type + the
79
+ * operators the builder offers, split into array/nullary families the UI renders differently)
80
+ * + the type's actions. Server-derived from the registered object type so the builder's
81
+ * dropdowns are ONE source of truth with enforcement (no client-side operator table to drift).
82
+ * A type not in the schema source → PolicyNotFoundError (not authorable via the builder).
83
+ */
84
+ schema(typeKey) {
85
+ const src = this.schemaByType.get(typeKey);
86
+ if (src === undefined) {
87
+ throw new PolicyNotFoundError(`No authorable schema for type '${typeKey}'.`);
88
+ }
89
+ const fields = src.fields.map((f) => {
90
+ const valid = validOperatorsForFieldType(f.type);
91
+ return {
92
+ key: f.key,
93
+ label: f.key,
94
+ type: f.type,
95
+ validOperators: valid,
96
+ arrayOperators: valid.filter((o) => ARRAY_OPS.includes(o)),
97
+ nullaryOperators: valid.filter((o) => NULLARY.includes(o)),
98
+ };
99
+ });
100
+ return { typeKey: src.typeKey, label: src.label, actions: src.actions, fields };
101
+ }
102
+ /**
103
+ * Author a guided policy in the acting admin's OWN tenant. The target tenant is
104
+ * `actor.tenantId` — a `tenantId` in the body is IGNORED (cross-tenant author impossible).
105
+ * Runs the core pipeline (delegated-admin ceiling → validate → write → audit). Maps deny
106
+ * reasons to typed errors; a transient throw PROPAGATES (→ 503).
107
+ */
108
+ async author(actor, policy) {
109
+ const mutation = {
110
+ kind: 'author_policy',
111
+ admin: actor,
112
+ targetTenantId: actor.tenantId, // SERVER-resolved — never the body
113
+ };
114
+ const result = await this.authorPolicy(mutation, policy);
115
+ if (result.ok === true) {
116
+ return result.value;
117
+ }
118
+ // Map the core deny reason → a typed error. `invalid_policy` is a 400; every authority /
119
+ // delegation deny is a 403 (a non-root admin exceeding what they hold, or a plain deny).
120
+ if (result.reason === 'invalid_policy') {
121
+ throw new InvalidPolicyError();
122
+ }
123
+ throw new PolicyAuthorDeniedError(`Policy author denied: ${result.reason}.`);
124
+ }
125
+ /** List the policies governing `typeKey` in the acting admin's tenant (tenant-scoped read). */
126
+ async list(actor, typeKey) {
127
+ return this.store.listPolicies(typeKey, actor.tenantId);
128
+ }
129
+ /**
130
+ * Delete a policy the acting admin owns. TENANT-SCOPED: `deletePolicyForTenant` removes rows
131
+ * only when they belong to `actor.tenantId`, so a foreign tenant's policy id can never be
132
+ * deleted. Zero rows removed ⇒ the policy is absent OR another tenant's ⇒ NotFound (never a
133
+ * false "deleted someone else's").
134
+ */
135
+ async delete(actor, policyId) {
136
+ const removed = await this.store.deletePolicyForTenant(policyId, actor.tenantId);
137
+ if (removed === 0) {
138
+ throw new PolicyNotFoundError();
139
+ }
140
+ }
141
+ }
142
+ exports.PolicyService = PolicyService;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The minimal query surface a transaction connection must expose. A `mariadb`
3
+ * `PoolConnection` satisfies it structurally; kept framework-agnostic (mirrors `Queryable`
4
+ * in `permission-version.bumper.ts`) so this signature doesn't force `mariadb` on callers.
5
+ */
6
+ export interface ScopeConn {
7
+ query(sql: string, params?: readonly unknown[]): Promise<unknown>;
8
+ }
9
+ /** The pool surface this service needs: `query` for reads + `getConnection` for transactions. */
10
+ export interface ScopePool {
11
+ query(sql: string, params?: readonly unknown[]): Promise<unknown>;
12
+ getConnection(): Promise<ScopeConn & {
13
+ beginTransaction(): Promise<void>;
14
+ commit(): Promise<void>;
15
+ rollback(): Promise<void>;
16
+ release(): Promise<void>;
17
+ }>;
18
+ }
19
+ /** The version-bump surface moveNode needs — satisfied by `PermissionVersionBumper`. */
20
+ export interface ScopeVersionBumper {
21
+ bumpForTenant(tenantUuid: string, conn: ScopeConn): Promise<void>;
22
+ }
23
+ export declare class ScopeClosureService {
24
+ private readonly pool;
25
+ private readonly bumper;
26
+ constructor(pool: ScopePool, bumper: ScopeVersionBumper);
27
+ /**
28
+ * Rebuild `scope_closure` from the current `companies.c_parent_uuid` edges. Idempotent
29
+ * (delete-then-insert in a tx → a second run over the same tree writes the identical set).
30
+ * FAIL-LOUD: an orphaned/cyclic/too-deep edge makes `buildClosureRows` throw and that
31
+ * propagates (the tx rolls back — never a partial closure). Returns a small summary + logs.
32
+ */
33
+ reconcileClosure(): Promise<{
34
+ nodes: number;
35
+ rows: number;
36
+ }>;
37
+ /**
38
+ * Re-parent `nodeUuid` under `newParentUuid` (or null for a root). Cycle-checked FIRST via the
39
+ * closure: if `newParentUuid` is already a descendant of `nodeUuid`, throw `ScopeCycleError`
40
+ * and touch NOTHING. Otherwise, atomically: UPDATE the edge, rebuild the closure, and bump the
41
+ * permission version of the MOVED SUBTREE's tenants.
42
+ *
43
+ * WHICH NODES ARE BUMPED — BOTH sides of the cut, read from the closure BEFORE the update:
44
+ * (a) the moved node + every descendant (its subtree) — their ancestry changed; AND
45
+ * (b) the FORMER-PARENT ANCESTRY (`node`'s old ancestors, excluding self) — they LOST the
46
+ * moved subtree from their reach.
47
+ * Rationale: a re-parent changes the reachable set of BOTH the moved subtree (new ancestry)
48
+ * and the losing parents (they can no longer reach the moved-away tenant). BOTH sides' admins
49
+ * have stale cached session subtrees; bumping each forces those tenants to re-derive their
50
+ * subtree at their NEXT REFRESH (ADR-0011 bound — no forced re-login). Missing (b) was the
51
+ * HIGH stale-subtree-after-move leak: a parent admin kept reaching a moved-away tenant.
52
+ * Nodes on neither side keep their exact set and are not bumped.
53
+ */
54
+ moveNode(nodeUuid: string, newParentUuid: string | null): Promise<void>;
55
+ /**
56
+ * Delete `nodeUuid`, FAIL-CLOSED. Refuses (throws) if the node has ANY child company — never
57
+ * silently orphans/promotes a sub-org (the `...016` FK RESTRICT enforces this at the DB too; the
58
+ * explicit check gives a clear early error). A leaf is deleted with its closure rows in one tx.
59
+ * BUMPS the FORMER-PARENT ANCESTRY: deleting a leaf removes it from its parents' reachable
60
+ * subtree, so those admins' cached session subtrees are stale (they'd keep the deleted node in
61
+ * their set) — bump them so they re-derive at the next refresh (same discipline as moveNode's
62
+ * former-parent bump; without it, a parent admin's list/decide would reference a gone node).
63
+ *
64
+ * v1 SCOPE NOTE: this refuses a node with CHILDREN. Broader resource-orphan cleanup (rows in
65
+ * the many tenant-scoped app tables that reference the deleted node) is out of scope for v1 and
66
+ * is tracked as a follow-up; deleteNode v1 only guarantees the HIERARCHY stays consistent.
67
+ */
68
+ deleteNode(nodeUuid: string): Promise<void>;
69
+ /** Read the node forest's parent edges (`c_uuid` → `c_parent_uuid`) as `ScopeEdge[]`. */
70
+ private readEdges;
71
+ /**
72
+ * Replace the entire `scope_closure` with `rows` over a tx connection: DELETE-all then a
73
+ * single multi-row INSERT (all values `?`-bound). Delete-then-insert makes reconcile trivially
74
+ * idempotent (a re-run writes the identical target set) and correct for a full rebuild.
75
+ */
76
+ private rewriteClosure;
77
+ }
78
+ /** deleteNode fail-closed guard: the node still has child companies (re-parent/cascade first). */
79
+ export declare class NodeHasChildrenError extends Error {
80
+ constructor(nodeUuid: string);
81
+ }
82
+ //# sourceMappingURL=scope-closure.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope-closure.service.d.ts","sourceRoot":"","sources":["../../../src/features/scope/scope-closure.service.ts"],"names":[],"mappings":"AAqCA;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAED,iGAAiG;AACjG,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAClE,aAAa,IAAI,OAAO,CACtB,SAAS,GAAG;QACV,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QAClC,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QACxB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QAC1B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;KAC1B,CACF,CAAC;CACH;AAED,wFAAwF;AACxF,MAAM,WAAW,kBAAkB;IACjC,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnE;AAED,qBAAa,mBAAmB;IAE5B,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,MAAM;gBADN,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,kBAAkB;IAG7C;;;;;OAKG;IACG,gBAAgB,IAAI,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IAoBlE;;;;;;;;;;;;;;;;OAgBG;IACG,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IA2E7E;;;;;;;;;;;;OAYG;IACG,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAyCjD,yFAAyF;YAC3E,SAAS;IAOvB;;;;OAIG;YACW,cAAc;CAiB7B;AAED,kGAAkG;AAClG,qBAAa,oBAAqB,SAAQ,KAAK;gBACjC,QAAQ,EAAE,MAAM;CAQ7B"}
@@ -0,0 +1,222 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NodeHasChildrenError = exports.ScopeClosureService = void 0;
4
+ // astram-framework/packages/identity/src/features/scope/scope-closure.service.ts
5
+ /**
6
+ * `ScopeClosureService` — the LIVE, I/O half of Task 1.2 (Phase 1 of the scoped-hierarchy
7
+ * tenancy program, ADR-0027). Task 1.1 shipped the `shield_authz.scope_closure` table and the
8
+ * PURE `buildClosureRows` (guard); THIS service makes the closure live against the real node
9
+ * tree (`shield_app.companies`, parent edge `c_parent_uuid`):
10
+ *
11
+ * • `reconcileClosure()` — (re)build the whole closure from the current parent edges. Runs at
12
+ * boot (mirroring the catalog reconcile) and on demand, so the closure is always consistent
13
+ * with the tree. Idempotent (delete-then-insert in ONE tx → a second run yields the identical
14
+ * row set) and FAIL-LOUD: an orphaned parent ref makes the pure builder throw, and that
15
+ * throw PROPAGATES (never a silent skip — a mis-scoped closure is a tenant-isolation bug).
16
+ *
17
+ * • `moveNode(node, newParent)` — re-parent a node (E-1). Validates NO cycle FIRST (the new
18
+ * parent must not sit inside the moved node's own subtree), then, in ONE transaction:
19
+ * UPDATE the edge, rebuild the closure, and BUMP the permission version of every tenant in
20
+ * the moved subtree so active sessions re-derive their (now-changed) subtree WITHOUT re-login
21
+ * (ADR-0011). A failed rebuild rolls the whole move back — atomic.
22
+ *
23
+ * • `deleteNode(node)` — remove a node (E-2), FAIL-CLOSED: it DENIES (throws) if the node has
24
+ * children, never letting the DB's old `ON DELETE SET NULL` silently promote a sub-org to a
25
+ * root (a hierarchy-boundary violation). The `...016` migration also flips the FK to
26
+ * RESTRICT so the DB refuses too; this service checks explicitly for a clear, early error.
27
+ * A leaf (no children) is deleted with its closure rows in one tx.
28
+ *
29
+ * Reuse (Task 1.1 / Phase 3): the pure `buildClosureRows` + the `Scope*Error` classes from
30
+ * `@astram/guard`, and `PermissionVersionBumper.bumpForTenant` for moveNode's session
31
+ * invalidation. All SQL values are `?`-bound (never interpolated). `companies` lives in
32
+ * `shield_app`; `scope_closure` lives in `shield_authz` — the shared authz pool reaches both.
33
+ */
34
+ const guard_1 = require("@astram/guard");
35
+ /** Schema the node tree (`companies`) lives in. */
36
+ const APP = 'shield_app';
37
+ /** Schema the closure table lives in (ADR-0011 / Task 1.1). */
38
+ const AUTHZ = 'shield_authz';
39
+ class ScopeClosureService {
40
+ pool;
41
+ bumper;
42
+ constructor(pool, bumper) {
43
+ this.pool = pool;
44
+ this.bumper = bumper;
45
+ }
46
+ /**
47
+ * Rebuild `scope_closure` from the current `companies.c_parent_uuid` edges. Idempotent
48
+ * (delete-then-insert in a tx → a second run over the same tree writes the identical set).
49
+ * FAIL-LOUD: an orphaned/cyclic/too-deep edge makes `buildClosureRows` throw and that
50
+ * propagates (the tx rolls back — never a partial closure). Returns a small summary + logs.
51
+ */
52
+ async reconcileClosure() {
53
+ const edges = await this.readEdges(this.pool);
54
+ const rows = (0, guard_1.buildClosureRows)(edges); // throws on cycle/depth/orphan — propagate (fail-loud).
55
+ const conn = await this.pool.getConnection();
56
+ try {
57
+ await conn.beginTransaction();
58
+ await this.rewriteClosure(conn, rows);
59
+ await conn.commit();
60
+ }
61
+ catch (cause) {
62
+ await conn.rollback();
63
+ throw cause;
64
+ }
65
+ finally {
66
+ await conn.release();
67
+ }
68
+ console.log(`[scope] closure: ${edges.length} nodes, ${rows.length} rows`);
69
+ return { nodes: edges.length, rows: rows.length };
70
+ }
71
+ /**
72
+ * Re-parent `nodeUuid` under `newParentUuid` (or null for a root). Cycle-checked FIRST via the
73
+ * closure: if `newParentUuid` is already a descendant of `nodeUuid`, throw `ScopeCycleError`
74
+ * and touch NOTHING. Otherwise, atomically: UPDATE the edge, rebuild the closure, and bump the
75
+ * permission version of the MOVED SUBTREE's tenants.
76
+ *
77
+ * WHICH NODES ARE BUMPED — BOTH sides of the cut, read from the closure BEFORE the update:
78
+ * (a) the moved node + every descendant (its subtree) — their ancestry changed; AND
79
+ * (b) the FORMER-PARENT ANCESTRY (`node`'s old ancestors, excluding self) — they LOST the
80
+ * moved subtree from their reach.
81
+ * Rationale: a re-parent changes the reachable set of BOTH the moved subtree (new ancestry)
82
+ * and the losing parents (they can no longer reach the moved-away tenant). BOTH sides' admins
83
+ * have stale cached session subtrees; bumping each forces those tenants to re-derive their
84
+ * subtree at their NEXT REFRESH (ADR-0011 bound — no forced re-login). Missing (b) was the
85
+ * HIGH stale-subtree-after-move leak: a parent admin kept reaching a moved-away tenant.
86
+ * Nodes on neither side keep their exact set and are not bumped.
87
+ */
88
+ async moveNode(nodeUuid, newParentUuid) {
89
+ if (newParentUuid === nodeUuid) {
90
+ // A node cannot be its own parent (a trivial 1-cycle).
91
+ throw new guard_1.ScopeCycleError(nodeUuid);
92
+ }
93
+ if (newParentUuid !== null) {
94
+ // Cycle guard: the new parent must NOT be at/under the moved node. A closure hit means
95
+ // newParent is a descendant of node → re-parenting node under it would form a cycle.
96
+ const cyclic = (await this.pool.query(`SELECT 1 FROM \`${AUTHZ}\`.\`scope_closure\`
97
+ WHERE ancestor_uuid = ? AND descendant_uuid = ? LIMIT 1`, [nodeUuid, newParentUuid]));
98
+ if (cyclic.length > 0) {
99
+ throw new guard_1.ScopeCycleError(nodeUuid);
100
+ }
101
+ }
102
+ // The moved subtree — the nodes whose ancestry changes (node + all descendants). Read
103
+ // BEFORE the update (the move doesn't change the subtree's internal membership).
104
+ const subtreeRows = (await this.pool.query(`SELECT descendant_uuid FROM \`${AUTHZ}\`.\`scope_closure\` WHERE ancestor_uuid = ?`, [nodeUuid]));
105
+ // The FORMER-PARENT ANCESTRY — the nodes ABOVE `node` in the OLD tree (its ancestors,
106
+ // excluding self). They just LOST `node`'s subtree from their reach, so their admins' cached
107
+ // session subtrees are now stale (they'd otherwise keep reaching the moved-away tenant — the
108
+ // HIGH stale-subtree-after-move leak). Read BEFORE the update (the pre-move closure has the
109
+ // old ancestry). Bump these too so those sessions re-derive their (now-smaller) subtree at
110
+ // their next refresh (ADR-0011 bound — no forced re-login).
111
+ const formerAncestryRows = (await this.pool.query(`SELECT ancestor_uuid FROM \`${AUTHZ}\`.\`scope_closure\`
112
+ WHERE descendant_uuid = ? AND ancestor_uuid <> ?`, [nodeUuid, nodeUuid]));
113
+ // Fall back to the node itself if the closure has no self-row yet (pre-first-reconcile).
114
+ const movedSubtree = subtreeRows.length > 0 ? subtreeRows.map((r) => r.descendant_uuid) : [nodeUuid];
115
+ // The full bump set: the moved subtree (their ancestry grew/changed) UNION the former-parent
116
+ // ancestry (they lost the moved subtree). Deduped.
117
+ const affected = [
118
+ ...new Set([...movedSubtree, ...formerAncestryRows.map((r) => r.ancestor_uuid)]),
119
+ ];
120
+ const conn = await this.pool.getConnection();
121
+ try {
122
+ await conn.beginTransaction();
123
+ // 1) Re-parent the edge.
124
+ await conn.query(`UPDATE \`${APP}\`.\`companies\` SET c_parent_uuid = ? WHERE c_uuid = ?`, [newParentUuid, nodeUuid]);
125
+ // 2) Rebuild the whole closure from the now-updated edges (full rebuild — safe + simple;
126
+ // a targeted subtree rebuild is a later optimization). Re-reads edges over the SAME tx
127
+ // connection so it sees the UPDATE. Fail-loud propagates → the whole move rolls back.
128
+ const edges = await this.readEdges(conn);
129
+ const rows = (0, guard_1.buildClosureRows)(edges);
130
+ await this.rewriteClosure(conn, rows);
131
+ // 3) Session invalidation: bump each moved-subtree tenant so its cached subtree re-derives.
132
+ for (const tenantUuid of affected) {
133
+ await this.bumper.bumpForTenant(tenantUuid, conn);
134
+ }
135
+ await conn.commit();
136
+ }
137
+ catch (cause) {
138
+ await conn.rollback();
139
+ throw cause;
140
+ }
141
+ finally {
142
+ await conn.release();
143
+ }
144
+ }
145
+ /**
146
+ * Delete `nodeUuid`, FAIL-CLOSED. Refuses (throws) if the node has ANY child company — never
147
+ * silently orphans/promotes a sub-org (the `...016` FK RESTRICT enforces this at the DB too; the
148
+ * explicit check gives a clear early error). A leaf is deleted with its closure rows in one tx.
149
+ * BUMPS the FORMER-PARENT ANCESTRY: deleting a leaf removes it from its parents' reachable
150
+ * subtree, so those admins' cached session subtrees are stale (they'd keep the deleted node in
151
+ * their set) — bump them so they re-derive at the next refresh (same discipline as moveNode's
152
+ * former-parent bump; without it, a parent admin's list/decide would reference a gone node).
153
+ *
154
+ * v1 SCOPE NOTE: this refuses a node with CHILDREN. Broader resource-orphan cleanup (rows in
155
+ * the many tenant-scoped app tables that reference the deleted node) is out of scope for v1 and
156
+ * is tracked as a follow-up; deleteNode v1 only guarantees the HIERARCHY stays consistent.
157
+ */
158
+ async deleteNode(nodeUuid) {
159
+ const children = (await this.pool.query(`SELECT 1 FROM \`${APP}\`.\`companies\` WHERE c_parent_uuid = ? LIMIT 1`, [nodeUuid]));
160
+ if (children.length > 0) {
161
+ throw new NodeHasChildrenError(nodeUuid);
162
+ }
163
+ // The former-parent ancestry — the deleted node's ancestors (excluding self). They lose the
164
+ // node from their reach → bump so their sessions re-derive. Read BEFORE the delete.
165
+ const formerAncestryRows = (await this.pool.query(`SELECT ancestor_uuid FROM \`${AUTHZ}\`.\`scope_closure\`
166
+ WHERE descendant_uuid = ? AND ancestor_uuid <> ?`, [nodeUuid, nodeUuid]));
167
+ const conn = await this.pool.getConnection();
168
+ try {
169
+ await conn.beginTransaction();
170
+ // Remove the node's closure rows (self-row + any it appears in as ancestor/descendant).
171
+ await conn.query(`DELETE FROM \`${AUTHZ}\`.\`scope_closure\`
172
+ WHERE ancestor_uuid = ? OR descendant_uuid = ?`, [nodeUuid, nodeUuid]);
173
+ // Remove the company row itself.
174
+ await conn.query(`DELETE FROM \`${APP}\`.\`companies\` WHERE c_uuid = ?`, [nodeUuid]);
175
+ // Bump the former-parent ancestry so their cached subtrees re-derive at next refresh.
176
+ for (const { ancestor_uuid } of formerAncestryRows) {
177
+ await this.bumper.bumpForTenant(ancestor_uuid, conn);
178
+ }
179
+ await conn.commit();
180
+ }
181
+ catch (cause) {
182
+ await conn.rollback();
183
+ throw cause;
184
+ }
185
+ finally {
186
+ await conn.release();
187
+ }
188
+ }
189
+ /** Read the node forest's parent edges (`c_uuid` → `c_parent_uuid`) as `ScopeEdge[]`. */
190
+ async readEdges(q) {
191
+ const rows = (await q.query(`SELECT c_uuid, c_parent_uuid FROM \`${APP}\`.\`companies\``));
192
+ return rows.map((r) => ({ node: r.c_uuid, parent: r.c_parent_uuid ?? null }));
193
+ }
194
+ /**
195
+ * Replace the entire `scope_closure` with `rows` over a tx connection: DELETE-all then a
196
+ * single multi-row INSERT (all values `?`-bound). Delete-then-insert makes reconcile trivially
197
+ * idempotent (a re-run writes the identical target set) and correct for a full rebuild.
198
+ */
199
+ async rewriteClosure(conn, rows) {
200
+ await conn.query(`DELETE FROM \`${AUTHZ}\`.\`scope_closure\``);
201
+ if (rows.length === 0)
202
+ return;
203
+ const placeholders = rows.map(() => '(?, ?, ?)').join(', ');
204
+ const params = [];
205
+ for (const r of rows) {
206
+ params.push(r.ancestor, r.descendant, r.depth);
207
+ }
208
+ await conn.query(`INSERT INTO \`${AUTHZ}\`.\`scope_closure\` (ancestor_uuid, descendant_uuid, depth)
209
+ VALUES ${placeholders}`, params);
210
+ }
211
+ }
212
+ exports.ScopeClosureService = ScopeClosureService;
213
+ /** deleteNode fail-closed guard: the node still has child companies (re-parent/cascade first). */
214
+ class NodeHasChildrenError extends Error {
215
+ constructor(nodeUuid) {
216
+ super(`scope: refusing to delete node '${nodeUuid}' — it still has child nodes. Re-parent or ` +
217
+ `delete its children first (fail-closed: a silent promote/orphan would break the ` +
218
+ `hierarchy boundary).`);
219
+ this.name = 'NodeHasChildrenError';
220
+ }
221
+ }
222
+ exports.NodeHasChildrenError = NodeHasChildrenError;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `DbScopeSubtreeResolver` — the identity-side implementation of the guard-defined
3
+ * `ScopeSubtreeResolver` port (Task 2.2 of the scoped-hierarchy tenancy program,
4
+ * ADR-0027 / ADR-0024). It answers ONE question at LOGIN time: given a principal's
5
+ * active scope node, what is the reachable subtree (that node + all its descendants)?
6
+ *
7
+ * The answer is a SINGLE closure read over `shield_authz.scope_closure` (built + kept
8
+ * live by {@link import('./scope-closure.service.js').ScopeClosureService}):
9
+ *
10
+ * SELECT descendant_uuid FROM shield_authz.scope_closure WHERE ancestor_uuid = ?
11
+ *
12
+ * (the closure stores a self-row `(node, node, 0)`, so the node itself is included).
13
+ * The guard caches the returned set on the Redis session binding, so the per-request
14
+ * tenant gate reads it from the binding with ZERO extra DB — the closure query happens
15
+ * ONLY here, at login (the ADR-0024 parity guarantee).
16
+ *
17
+ * ── LAYERING ──────────────────────────────────────────────────────────────────────
18
+ * The PORT (`ScopeSubtreeResolver`) is defined in `@astram/guard`; this IMPLEMENTATION
19
+ * lives in `@astram/identity` (which owns the app/authz schemas + the closure). The
20
+ * consumer injects an instance through `ShieldModule.forRoot({ scopeSubtreeResolver })`
21
+ * — the same sanctioned direction as the record-tier adapters + the facts projector.
22
+ * Guard never imports identity.
23
+ *
24
+ * ── FAIL-SAFE (non-widening) ───────────────────────────────────────────────────────
25
+ * If the closure has NO rows for the node yet (a pre-first-reconcile boot), this returns
26
+ * `[nodeId]` — the self-node, exactly today's flat isolation. It NEVER widens the subtree
27
+ * on missing data. A store OUTAGE THROWS (the guard's login path swallows it best-effort
28
+ * → the binding omits the subtree → the per-request path still falls back to the
29
+ * self-node): a resolution failure degrades to today's correct, non-widening behavior and
30
+ * NEVER fails login.
31
+ *
32
+ * All SQL values are `?`-bound (never interpolated). `scope_closure` lives in
33
+ * `shield_authz`; the shared authz pool reaches it.
34
+ */
35
+ import type { Pool } from 'mariadb';
36
+ export declare class DbScopeSubtreeResolver {
37
+ private readonly pool;
38
+ constructor(pool: Pool);
39
+ /**
40
+ * Resolve the active-scope subtree for `nodeId` — the node itself plus every
41
+ * descendant — from `scope_closure`. Returns `[nodeId]` (self-node, non-widening)
42
+ * when the closure has no rows for the node yet (pre-first-reconcile). Throws on a
43
+ * store outage (the login path swallows it → self-node fallback per request).
44
+ */
45
+ resolveSubtree(nodeId: string): Promise<readonly string[]>;
46
+ }
47
+ //# sourceMappingURL=scope-subtree.resolver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope-subtree.resolver.d.ts","sourceRoot":"","sources":["../../../src/features/scope/scope-subtree.resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAKpC,qBAAa,sBAAsB;IACrB,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,IAAI;IAEvC;;;;;OAKG;IACG,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC;CAQjE"}
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DbScopeSubtreeResolver = void 0;
4
+ /** Schema the closure table lives in (Task 1.1 / ADR-0011). */
5
+ const AUTHZ = 'shield_authz';
6
+ class DbScopeSubtreeResolver {
7
+ pool;
8
+ constructor(pool) {
9
+ this.pool = pool;
10
+ }
11
+ /**
12
+ * Resolve the active-scope subtree for `nodeId` — the node itself plus every
13
+ * descendant — from `scope_closure`. Returns `[nodeId]` (self-node, non-widening)
14
+ * when the closure has no rows for the node yet (pre-first-reconcile). Throws on a
15
+ * store outage (the login path swallows it → self-node fallback per request).
16
+ */
17
+ async resolveSubtree(nodeId) {
18
+ const rows = (await this.pool.query(`SELECT descendant_uuid FROM \`${AUTHZ}\`.\`scope_closure\` WHERE ancestor_uuid = ?`, [nodeId]));
19
+ if (rows.length === 0)
20
+ return [nodeId]; // pre-first-reconcile → self-node (non-widening).
21
+ return rows.map((r) => r.descendant_uuid);
22
+ }
23
+ }
24
+ exports.DbScopeSubtreeResolver = DbScopeSubtreeResolver;
@@ -0,0 +1,35 @@
1
+ import type { Principal } from '@astram/core';
2
+ import { ShareService } from './share.service.js';
3
+ /** The request shape after the enforcement guard attaches the resolved principal. */
4
+ interface AuthedRequest {
5
+ principal: Principal;
6
+ }
7
+ export declare class ShareController {
8
+ private readonly shares;
9
+ constructor(shares: ShareService);
10
+ /**
11
+ * List the shares on a record the caller owns — grantee NAMES resolved (no raw uuid).
12
+ * `GET /identity/record-shares?typeKey=&recordId=`. Gated `record_share:read`. Only the owner
13
+ * sees a record's shares (the writer's owner-bound); a non-owner → 403.
14
+ */
15
+ list(req: AuthedRequest, typeKey?: string, recordId?: string): Promise<{
16
+ shares: readonly import("./share.service.js").ShareView[];
17
+ }>;
18
+ /**
19
+ * Share a record the caller owns. `POST /identity/record-shares` with
20
+ * `{ typeKey, recordId, granteeKind, granteeUuid, action }`. Gated `record_share:share`. The
21
+ * writer proves the caller owns the record before writing (non-owner → 403).
22
+ */
23
+ share(req: AuthedRequest, body: unknown): Promise<{
24
+ success: boolean;
25
+ }>;
26
+ /**
27
+ * Revoke a share the caller owns. `DELETE /identity/record-shares` with the same body shape
28
+ * (the exact tuple to remove). Gated `record_share:share`. Owner-only (the writer enforces it).
29
+ */
30
+ unshare(req: AuthedRequest, body: unknown): Promise<{
31
+ success: boolean;
32
+ }>;
33
+ }
34
+ export {};
35
+ //# sourceMappingURL=share.controller.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"share.controller.d.ts","sourceRoot":"","sources":["../../../src/features/share/share.controller.ts"],"names":[],"mappings":"AAyBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAqB,MAAM,oBAAoB,CAAC;AAErE,qFAAqF;AACrF,UAAU,aAAa;IACrB,SAAS,EAAE,SAAS,CAAC;CACtB;AAqBD,qBACa,eAAe;IACd,OAAO,CAAC,QAAQ,CAAC,MAAM;gBAAN,MAAM,EAAE,YAAY;IAEjD;;;;OAIG;IAGG,IAAI,CACD,GAAG,EAAE,aAAa,EACP,OAAO,CAAC,EAAE,MAAM,EACf,QAAQ,CAAC,EAAE,MAAM;;;IAYtC;;;;OAIG;IAGG,KAAK,CAAQ,GAAG,EAAE,aAAa,EAAU,IAAI,EAAE,OAAO;;;IAW5D;;;OAGG;IAGG,OAAO,CAAQ,GAAG,EAAE,aAAa,EAAU,IAAI,EAAE,OAAO;;;CAU/D"}