@astram/identity 0.4.0 → 0.5.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 (172) hide show
  1. package/dist/company-membership.object.d.ts.map +1 -1
  2. package/dist/company-membership.object.js +3 -2
  3. package/dist/company.object.d.ts.map +1 -1
  4. package/dist/company.object.js +8 -3
  5. package/dist/features/access/index.d.ts.map +1 -1
  6. package/dist/features/access/index.js +1 -5
  7. package/dist/features/audit/audit-read.service.d.ts.map +1 -1
  8. package/dist/features/audit/audit-read.service.js +13 -6
  9. package/dist/features/audit/audit.controller.d.ts.map +1 -1
  10. package/dist/features/audit/audit.controller.js +2 -0
  11. package/dist/features/audit/mariadb-audit-reader.d.ts.map +1 -1
  12. package/dist/features/authz/access-bundle-reconcile.job.d.ts.map +1 -1
  13. package/dist/features/authz/catalog-reconcile.job.d.ts.map +1 -1
  14. package/dist/features/authz/catalog-reconcile.job.js +1 -3
  15. package/dist/features/authz/db-permission-facts.adapter.d.ts +16 -0
  16. package/dist/features/authz/db-permission-facts.adapter.d.ts.map +1 -1
  17. package/dist/features/authz/db-permission-facts.adapter.js +46 -2
  18. package/dist/features/authz/grant-admin.controller.d.ts +8 -5
  19. package/dist/features/authz/grant-admin.controller.d.ts.map +1 -1
  20. package/dist/features/authz/grant-admin.controller.js +20 -18
  21. package/dist/features/authz/grant-admin.service.d.ts +49 -16
  22. package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
  23. package/dist/features/authz/grant-admin.service.js +155 -96
  24. package/dist/features/authz/grant-ceiling.d.ts +52 -1
  25. package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
  26. package/dist/features/authz/grant-ceiling.js +156 -2
  27. package/dist/features/authz/mariadb-catalog-store.d.ts.map +1 -1
  28. package/dist/features/authz/mariadb-catalog-store.js +15 -3
  29. package/dist/features/authz/permission-version.bumper.d.ts +45 -1
  30. package/dist/features/authz/permission-version.bumper.d.ts.map +1 -1
  31. package/dist/features/authz/permission-version.bumper.js +106 -16
  32. package/dist/features/authz/record-authorization.d.ts.map +1 -1
  33. package/dist/features/authz/role-binding.d.ts +36 -0
  34. package/dist/features/authz/role-binding.d.ts.map +1 -0
  35. package/dist/features/authz/role-binding.js +53 -0
  36. package/dist/features/company/company-hierarchy.service.d.ts +46 -0
  37. package/dist/features/company/company-hierarchy.service.d.ts.map +1 -0
  38. package/dist/features/company/company-hierarchy.service.js +100 -0
  39. package/dist/features/company/company.controller.d.ts +23 -5
  40. package/dist/features/company/company.controller.d.ts.map +1 -1
  41. package/dist/features/company/company.controller.js +47 -12
  42. package/dist/features/company/company.service.d.ts +28 -12
  43. package/dist/features/company/company.service.d.ts.map +1 -1
  44. package/dist/features/company/company.service.js +61 -14
  45. package/dist/features/cross-tenant/cross-tenant-grant.controller.d.ts.map +1 -1
  46. package/dist/features/cross-tenant/cross-tenant-grant.controller.js +2 -0
  47. package/dist/features/identity-feature.module.d.ts.map +1 -1
  48. package/dist/features/identity-feature.module.js +60 -3
  49. package/dist/features/membership/membership.controller.d.ts +6 -5
  50. package/dist/features/membership/membership.controller.d.ts.map +1 -1
  51. package/dist/features/membership/membership.controller.js +34 -22
  52. package/dist/features/membership/membership.service.d.ts +20 -14
  53. package/dist/features/membership/membership.service.d.ts.map +1 -1
  54. package/dist/features/membership/membership.service.js +62 -29
  55. package/dist/features/membership/posting-version-bump.d.ts +5 -0
  56. package/dist/features/membership/posting-version-bump.d.ts.map +1 -1
  57. package/dist/features/membership/posting.service.d.ts +20 -24
  58. package/dist/features/membership/posting.service.d.ts.map +1 -1
  59. package/dist/features/membership/posting.service.js +101 -64
  60. package/dist/features/org-structure/org-catalog.controller.d.ts.map +1 -1
  61. package/dist/features/org-structure/org-catalog.controller.js +2 -0
  62. package/dist/features/org-structure/org-catalog.service.d.ts.map +1 -1
  63. package/dist/features/org-structure/org-catalog.service.js +4 -1
  64. package/dist/features/policy/policy.controller.d.ts.map +1 -1
  65. package/dist/features/policy/policy.controller.js +2 -0
  66. package/dist/features/role/role.controller.d.ts +3 -2
  67. package/dist/features/role/role.controller.d.ts.map +1 -1
  68. package/dist/features/role/role.controller.js +15 -10
  69. package/dist/features/role/role.service.d.ts +16 -8
  70. package/dist/features/role/role.service.d.ts.map +1 -1
  71. package/dist/features/role/role.service.js +24 -10
  72. package/dist/features/scope/scope-closure.service.d.ts +44 -4
  73. package/dist/features/scope/scope-closure.service.d.ts.map +1 -1
  74. package/dist/features/scope/scope-closure.service.js +215 -82
  75. package/dist/features/service-account/service-account.controller.d.ts +3 -2
  76. package/dist/features/service-account/service-account.controller.d.ts.map +1 -1
  77. package/dist/features/service-account/service-account.controller.js +29 -15
  78. package/dist/features/service-account/service-account.service.d.ts +14 -7
  79. package/dist/features/service-account/service-account.service.d.ts.map +1 -1
  80. package/dist/features/service-account/service-account.service.js +56 -11
  81. package/dist/features/share/share.controller.d.ts.map +1 -1
  82. package/dist/features/share/share.controller.js +2 -0
  83. package/dist/features/share/share.service.d.ts.map +1 -1
  84. package/dist/features/shared/identity-error.interceptor.d.ts +24 -0
  85. package/dist/features/shared/identity-error.interceptor.d.ts.map +1 -0
  86. package/dist/features/shared/identity-error.interceptor.js +82 -0
  87. package/dist/features/shared/identity-errors.d.ts +23 -0
  88. package/dist/features/shared/identity-errors.d.ts.map +1 -0
  89. package/dist/features/shared/identity-errors.js +41 -0
  90. package/dist/features/user/session-revocation-check.d.ts +11 -0
  91. package/dist/features/user/session-revocation-check.d.ts.map +1 -0
  92. package/dist/features/user/session-revocation-check.js +21 -0
  93. package/dist/features/user/user.controller.d.ts +11 -6
  94. package/dist/features/user/user.controller.d.ts.map +1 -1
  95. package/dist/features/user/user.controller.js +35 -17
  96. package/dist/features/user/user.service.d.ts +55 -14
  97. package/dist/features/user/user.service.d.ts.map +1 -1
  98. package/dist/features/user/user.service.js +143 -16
  99. package/dist/membership-posting.object.d.ts.map +1 -1
  100. package/dist/membership-posting.object.js +4 -2
  101. package/dist/objects.d.ts.map +1 -1
  102. package/dist/objects.js +13 -3
  103. package/dist/role.object.d.ts.map +1 -1
  104. package/dist/role.object.js +3 -0
  105. package/dist/user.object.d.ts.map +1 -1
  106. package/dist/user.object.js +3 -0
  107. package/migrations/1710000000019-AddSessionMustReenroll.cjs +35 -0
  108. package/package.json +4 -4
  109. package/dist/auth-facts-keys.js.map +0 -1
  110. package/dist/branch.object.js.map +0 -1
  111. package/dist/company-branch.object.js.map +0 -1
  112. package/dist/company-department.object.js.map +0 -1
  113. package/dist/company-membership.object.js.map +0 -1
  114. package/dist/company.object.js.map +0 -1
  115. package/dist/cross-tenant-grant.object.js.map +0 -1
  116. package/dist/department.object.js.map +0 -1
  117. package/dist/features/access/access-admin.access.js.map +0 -1
  118. package/dist/features/access/company-admin.access.js.map +0 -1
  119. package/dist/features/access/index.js.map +0 -1
  120. package/dist/features/access/user-management.access.js.map +0 -1
  121. package/dist/features/audit/audit-read.service.js.map +0 -1
  122. package/dist/features/audit/audit.controller.js.map +0 -1
  123. package/dist/features/audit/directory-label-resolver.js.map +0 -1
  124. package/dist/features/audit/mariadb-audit-reader.js.map +0 -1
  125. package/dist/features/authz/access-bundle-reconcile.job.js.map +0 -1
  126. package/dist/features/authz/access-bundle-store.js.map +0 -1
  127. package/dist/features/authz/catalog-bootstrap.provider.js.map +0 -1
  128. package/dist/features/authz/catalog-reconcile.job.js.map +0 -1
  129. package/dist/features/authz/db-permission-facts.adapter.js.map +0 -1
  130. package/dist/features/authz/grant-admin.controller.js.map +0 -1
  131. package/dist/features/authz/grant-admin.service.js.map +0 -1
  132. package/dist/features/authz/grant-ceiling.js.map +0 -1
  133. package/dist/features/authz/mariadb-catalog-store.js.map +0 -1
  134. package/dist/features/authz/permission-version.bumper.js.map +0 -1
  135. package/dist/features/authz/record-authorization.js.map +0 -1
  136. package/dist/features/authz/session-facts.reader.js.map +0 -1
  137. package/dist/features/company/company.controller.js.map +0 -1
  138. package/dist/features/company/company.service.js.map +0 -1
  139. package/dist/features/cross-tenant/cross-tenant-grant-read.service.js.map +0 -1
  140. package/dist/features/cross-tenant/cross-tenant-grant.controller.js.map +0 -1
  141. package/dist/features/cross-tenant/grant-authorize.js.map +0 -1
  142. package/dist/features/identity-feature.module.js.map +0 -1
  143. package/dist/features/membership/membership-checker.js.map +0 -1
  144. package/dist/features/membership/membership.controller.js.map +0 -1
  145. package/dist/features/membership/membership.service.js.map +0 -1
  146. package/dist/features/membership/posting-version-bump.js.map +0 -1
  147. package/dist/features/membership/posting.service.js.map +0 -1
  148. package/dist/features/org-structure/org-catalog.controller.js.map +0 -1
  149. package/dist/features/org-structure/org-catalog.service.js.map +0 -1
  150. package/dist/features/policy/policy.controller.js.map +0 -1
  151. package/dist/features/policy/policy.service.js.map +0 -1
  152. package/dist/features/role/role.controller.js.map +0 -1
  153. package/dist/features/role/role.service.js.map +0 -1
  154. package/dist/features/scope/scope-closure.service.js.map +0 -1
  155. package/dist/features/scope/scope-subtree.resolver.js.map +0 -1
  156. package/dist/features/share/share.controller.js.map +0 -1
  157. package/dist/features/share/share.service.js.map +0 -1
  158. package/dist/features/shared/http.js.map +0 -1
  159. package/dist/features/shared/list-query.js.map +0 -1
  160. package/dist/features/shared/named-option.js.map +0 -1
  161. package/dist/features/shared/record-resolver.js.map +0 -1
  162. package/dist/features/user/user.controller.js.map +0 -1
  163. package/dist/features/user/user.service.js.map +0 -1
  164. package/dist/index.js.map +0 -1
  165. package/dist/membership-posting.object.js.map +0 -1
  166. package/dist/migrations-dir.js.map +0 -1
  167. package/dist/objects.js.map +0 -1
  168. package/dist/record-share.object.js.map +0 -1
  169. package/dist/role.object.js.map +0 -1
  170. package/dist/schema.js.map +0 -1
  171. package/dist/shared-resource.object.js.map +0 -1
  172. package/dist/user.object.js.map +0 -1
@@ -11,17 +11,20 @@
11
11
  * `cm_is_active`; `setActive` is the guard.
12
12
  */
13
13
  import { RecordsService, type RecordId, type RecordView } from '@astram/engine';
14
+ import type { Principal } from '@astram/core';
14
15
  import { type PostingVersionBump } from './posting-version-bump.js';
16
+ import { GrantCeiling } from '../authz/grant-ceiling.js';
15
17
  export declare class MembershipService {
16
18
  private readonly records;
19
+ private readonly ceiling;
17
20
  private readonly versionBump?;
18
- constructor(records: RecordsService, versionBump?: (PostingVersionBump | null) | undefined);
21
+ constructor(records: RecordsService, ceiling: GrantCeiling, versionBump?: (PostingVersionBump | null) | undefined);
19
22
  /**
20
- * Read a membership's tenant (its `companyUuid`) through the pipe, or `null` when
21
- * unresolvable (→ no bump; fail-safe, never guesses a tenant). Used to target the
22
- * permission-version bump at the company whose coarse grants a mutation changed.
23
+ * Read a membership through the subtree wall (404 when out of reach) and, unless the actor is
24
+ * acting on their OWN membership, require the actor to dominate its user (security review): a
25
+ * sub-admin must not delete, suspend or re-activate a more privileged peer's membership.
23
26
  */
24
- private tenantOf;
27
+ private reachMember;
25
28
  /**
26
29
  * Bump the permission version for each DISTINCT tenant in `tenantUuids` (best-effort +
27
30
  * fail-safe): the mutation already succeeded, so a bump hiccup must NOT surface as an
@@ -37,17 +40,17 @@ export declare class MembershipService {
37
40
  * hits the DB's UNIQUE constraint and the engine's error surfaces to the caller (NOT
38
41
  * swallowed here). `createdBy` is stamped server-side (actorId-trusted).
39
42
  */
40
- addMembership(userUuid: string, companyUuid: string): Promise<RecordId>;
43
+ addMembership(actor: Principal, userUuid: string, companyUuid: string): Promise<RecordId>;
41
44
  /** List a user's membership cards (tenant-scoped by the pipe). */
42
45
  listMemberships(userUuid: string): Promise<readonly RecordView[]>;
43
46
  /**
44
47
  * Flip which of the user's memberships is active — the ONE-ACTIVE invariant. The service
45
48
  * is the guard because the DB has no partial-unique on `cm_is_active`.
46
49
  *
47
- * ⚠️ ORDER IS LOAD-BEARING (fail-safe against a LOGIN LOCKOUT). The engine's `list` has
48
- * no ORDER BY (PKs are random uuids → arbitrary row order) and each `update` is its OWN
49
- * transaction (no cross-call atomicity). So this MUST activate the TARGET FIRST, then
50
- * deactivate the others:
50
+ * ⚠️ ORDER IS LOAD-BEARING (fail-safe against a LOGIN LOCKOUT). Each `update` is its OWN
51
+ * transaction (no cross-call atomicity), and the engine's `list` order (PK-ordered since the
52
+ * Phase 3 tiebreaker) says nothing about which membership is the target. So this MUST
53
+ * activate the TARGET FIRST, then deactivate the others:
51
54
  * - Worst case of a mid-loop failure is then a transient TWO-active window, which is
52
55
  * RECOVERABLE — the credential resolver reads `is_active=1 LIMIT 1`, so it still
53
56
  * resolves a tenant and login works.
@@ -58,16 +61,19 @@ export declare class MembershipService {
58
61
  * Guard: if `membershipUuid` is NOT one of the user's memberships, throw — otherwise the
59
62
  * loop would deactivate everything and activate nothing (also a zero-active lockout).
60
63
  */
61
- setActive(membershipUuid: string, userUuid: string): Promise<void>;
64
+ setActive(actor: Principal, membershipUuid: string, userUuid: string): Promise<void>;
62
65
  /**
63
66
  * Update a membership's mutable non-activity fields (currently `status`). Activity is
64
67
  * flipped only via `setActive` to preserve the one-active invariant, so `isActive` is
65
68
  * intentionally NOT a patch key here.
66
69
  */
67
- updateMembership(membershipUuid: string, patch: {
70
+ updateMembership(actor: Principal, membershipUuid: string, patch: {
68
71
  status?: string;
69
72
  }): Promise<void>;
70
- /** Remove a membership card (its postings cascade at the DB via FK ON DELETE CASCADE). */
71
- removeMembership(membershipUuid: string): Promise<void>;
73
+ /**
74
+ * Remove a membership card (its postings cascade at the DB via FK ON DELETE CASCADE). A peer's
75
+ * membership needs dominance over the peer (security review); your own does not.
76
+ */
77
+ removeMembership(actor: Principal, membershipUuid: string): Promise<void>;
72
78
  }
73
79
  //# sourceMappingURL=membership.service.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"membership.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/membership.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAChF,OAAO,EAAwB,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAE1F,qBACa,iBAAiB;IAE1B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAQxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;gBARZ,OAAO,EAAE,cAAc,EAQvB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA;IAG1D;;;;OAIG;YACW,QAAQ;IAUtB;;;;;;OAMG;YACW,WAAW;IAYzB;;;;;;OAMG;IACH,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAMvE,kEAAkE;IAC5D,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,UAAU,EAAE,CAAC;IAKvE;;;;;;;;;;;;;;;;;OAiBG;IACG,SAAS,CAAC,cAAc,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAqCxE;;;;OAIG;IACH,gBAAgB,CAAC,cAAc,EAAE,MAAM,EAAE,KAAK,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAWnF,0FAA0F;IACpF,gBAAgB,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAU9D"}
1
+ {"version":3,"file":"membership.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/membership.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAChF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAwB,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAE1F,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAEzD,qBACa,iBAAiB;IAE1B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAGxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAQxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;gBAXZ,OAAO,EAAE,cAAc,EAGvB,OAAO,EAAE,YAAY,EAQrB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA;IAG1D;;;;OAIG;YACW,WAAW;IAQzB;;;;;;OAMG;YACW,WAAW;IAYzB;;;;;;OAMG;IACG,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAkB/F,kEAAkE;IAC5D,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,UAAU,EAAE,CAAC;IAKvE;;;;;;;;;;;;;;;;;OAiBG;IACG,SAAS,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAsC1F;;;;OAIG;IACG,gBAAgB,CACpB,KAAK,EAAE,SAAS,EAChB,cAAc,EAAE,MAAM,EACtB,KAAK,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GACzB,OAAO,CAAC,IAAI,CAAC;IAchB;;;OAGG;IACG,gBAAgB,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAYhF"}
@@ -28,27 +28,33 @@ exports.MembershipService = void 0;
28
28
  const common_1 = require("@nestjs/common");
29
29
  const engine_1 = require("@astram/engine");
30
30
  const posting_version_bump_js_1 = require("./posting-version-bump.js");
31
+ const identity_errors_js_1 = require("../shared/identity-errors.js");
32
+ const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
31
33
  let MembershipService = class MembershipService {
32
34
  records;
35
+ ceiling;
33
36
  versionBump;
34
- constructor(records, versionBump) {
37
+ constructor(records,
38
+ // Dominance over the member for destructive ops (security review): REQUIRED, so a missing
39
+ // ceiling fails DI at boot instead of silently skipping the check.
40
+ ceiling, versionBump) {
35
41
  this.records = records;
42
+ this.ceiling = ceiling;
36
43
  this.versionBump = versionBump;
37
44
  }
38
45
  /**
39
- * Read a membership's tenant (its `companyUuid`) through the pipe, or `null` when
40
- * unresolvable (→ no bump; fail-safe, never guesses a tenant). Used to target the
41
- * permission-version bump at the company whose coarse grants a mutation changed.
46
+ * Read a membership through the subtree wall (404 when out of reach) and, unless the actor is
47
+ * acting on their OWN membership, require the actor to dominate its user (security review): a
48
+ * sub-admin must not delete, suspend or re-activate a more privileged peer's membership.
42
49
  */
43
- async tenantOf(membershipUuid) {
44
- try {
45
- const m = await this.records.get('company_membership', membershipUuid);
46
- const companyUuid = m?.fields?.companyUuid;
47
- return typeof companyUuid === 'string' && companyUuid.length > 0 ? companyUuid : null;
48
- }
49
- catch {
50
- return null;
51
- }
50
+ async reachMember(actor, membershipUuid) {
51
+ const m = await this.records.get('company_membership', membershipUuid);
52
+ if (m === null)
53
+ throw new identity_errors_js_1.IdentityNotFoundError('Membership not found.');
54
+ const userUuid = typeof m.fields?.userUuid === 'string' ? m.fields.userUuid : '';
55
+ if (userUuid !== actor.id)
56
+ await this.ceiling.assertDominates(actor, userUuid);
57
+ return m;
52
58
  }
53
59
  /**
54
60
  * Bump the permission version for each DISTINCT tenant in `tenantUuids` (best-effort +
@@ -77,7 +83,20 @@ let MembershipService = class MembershipService {
77
83
  * hits the DB's UNIQUE constraint and the engine's error surfaces to the caller (NOT
78
84
  * swallowed here). `createdBy` is stamped server-side (actorId-trusted).
79
85
  */
80
- addMembership(userUuid, companyUuid) {
86
+ async addMembership(actor, userUuid, companyUuid) {
87
+ // D4 (security hardening #4): a non-root actor may attach only a user ALREADY inside its
88
+ // subtree — the user's walled read (a user is in scope iff it has a membership in the
89
+ // actor's subtree) must see it. Pulling a foreign tenant's user in would make them
90
+ // "dominated" by the actor (the #4 + #3 email-takeover chain). A brand-new user joins
91
+ // through `POST /identity/users { companyUuid }` (created + attached in one transaction).
92
+ // Root is data-plane-denied on memberships by the engine anyway.
93
+ if (actor.isRoot !== true) {
94
+ const user = await this.records.get('user', userUuid);
95
+ if (user === null)
96
+ throw new identity_errors_js_1.IdentityNotFoundError('User not found.');
97
+ }
98
+ // The company itself is checked by the engine's ADR-0034 create wall: a company outside
99
+ // the actor's subtree ⇒ AuthorizationDeniedError (→ 403) and the INSERT rolls back.
81
100
  return this.records.create('company_membership', {
82
101
  fields: { userUuid, companyUuid, isActive: false, status: 'active' },
83
102
  });
@@ -91,10 +110,10 @@ let MembershipService = class MembershipService {
91
110
  * Flip which of the user's memberships is active — the ONE-ACTIVE invariant. The service
92
111
  * is the guard because the DB has no partial-unique on `cm_is_active`.
93
112
  *
94
- * ⚠️ ORDER IS LOAD-BEARING (fail-safe against a LOGIN LOCKOUT). The engine's `list` has
95
- * no ORDER BY (PKs are random uuids → arbitrary row order) and each `update` is its OWN
96
- * transaction (no cross-call atomicity). So this MUST activate the TARGET FIRST, then
97
- * deactivate the others:
113
+ * ⚠️ ORDER IS LOAD-BEARING (fail-safe against a LOGIN LOCKOUT). Each `update` is its OWN
114
+ * transaction (no cross-call atomicity), and the engine's `list` order (PK-ordered since the
115
+ * Phase 3 tiebreaker) says nothing about which membership is the target. So this MUST
116
+ * activate the TARGET FIRST, then deactivate the others:
98
117
  * - Worst case of a mid-loop failure is then a transient TWO-active window, which is
99
118
  * RECOVERABLE — the credential resolver reads `is_active=1 LIMIT 1`, so it still
100
119
  * resolves a tenant and login works.
@@ -105,13 +124,17 @@ let MembershipService = class MembershipService {
105
124
  * Guard: if `membershipUuid` is NOT one of the user's memberships, throw — otherwise the
106
125
  * loop would deactivate everything and activate nothing (also a zero-active lockout).
107
126
  */
108
- async setActive(membershipUuid, userUuid) {
127
+ async setActive(actor, membershipUuid, userUuid) {
128
+ // Flipping activation de-activates the user's OTHER memberships: for a peer, the actor must
129
+ // dominate them (security review). Your own activation stays allowed.
130
+ if (userUuid !== actor.id)
131
+ await this.ceiling.assertDominates(actor, userUuid);
109
132
  const page = await this.records.list('company_membership', { where: { userUuid } });
110
133
  const target = page.rows.find((m) => m.id === membershipUuid);
111
134
  if (!target) {
112
135
  // Not this user's membership → refuse rather than leave the user with ZERO active
113
- // (a login lockout). Fail-closed.
114
- throw new common_1.NotFoundException(`membership ${membershipUuid} does not belong to user ${userUuid}`);
136
+ // (a login lockout). Fail-closed. No ids in the message (ADR-0012).
137
+ throw new identity_errors_js_1.IdentityNotFoundError('Membership not found.');
115
138
  }
116
139
  // 1) TARGET FIRST — guarantees an active membership exists before any deactivation, so
117
140
  // a mid-failure below leaves TWO active (recoverable), never ZERO (lockout).
@@ -142,7 +165,11 @@ let MembershipService = class MembershipService {
142
165
  * flipped only via `setActive` to preserve the one-active invariant, so `isActive` is
143
166
  * intentionally NOT a patch key here.
144
167
  */
145
- updateMembership(membershipUuid, patch) {
168
+ async updateMembership(actor, membershipUuid, patch) {
169
+ await this.reachMember(actor, membershipUuid); // 404 / dominance over a peer
170
+ const fields = {};
171
+ if (typeof patch?.status === 'string')
172
+ fields.status = patch.status;
146
173
  // NO version bump here (ADR-0011): this patches `status` ONLY — `isActive` is
147
174
  // deliberately not a patch key (activity flips solely via `setActive`, which bumps),
148
175
  // and the controller only ever passes `{ status }`. Coarse-grant resolution gates on
@@ -150,16 +177,21 @@ let MembershipService = class MembershipService {
150
177
  // resolve → the warm cache stays correct → no invalidation needed. If a future patch key
151
178
  // is ever added here that CAN change which roles/postings resolve, it MUST bump the
152
179
  // membership's tenant (mirror `setActive`).
153
- return this.records.update('company_membership', membershipUuid, { fields: patch });
180
+ await this.records.update('company_membership', membershipUuid, { fields });
154
181
  }
155
- /** Remove a membership card (its postings cascade at the DB via FK ON DELETE CASCADE). */
156
- async removeMembership(membershipUuid) {
182
+ /**
183
+ * Remove a membership card (its postings cascade at the DB via FK ON DELETE CASCADE). A peer's
184
+ * membership needs dominance over the peer (security review); your own does not.
185
+ */
186
+ async removeMembership(actor, membershipUuid) {
157
187
  // ADR-0011 STALENESS FIX: resolve the membership's tenant BEFORE the delete (it's
158
188
  // unreadable afterwards). If the membership was active, deleting it (postings cascade)
159
189
  // removes the user's role grants in that company — a coarse-grant change — so bump that
160
190
  // tenant AFTER the delete succeeds. Best-effort/fail-safe (bumpTenants swallows). A
161
191
  // membership belongs to exactly one company → one tenant bump.
162
- const tenant = await this.tenantOf(membershipUuid);
192
+ const m = await this.reachMember(actor, membershipUuid);
193
+ const c = m.fields?.companyUuid;
194
+ const tenant = typeof c === 'string' && c.length > 0 ? c : null;
163
195
  await this.records.delete('company_membership', membershipUuid);
164
196
  await this.bumpTenants([tenant]);
165
197
  }
@@ -167,7 +199,8 @@ let MembershipService = class MembershipService {
167
199
  exports.MembershipService = MembershipService;
168
200
  exports.MembershipService = MembershipService = __decorate([
169
201
  (0, common_1.Injectable)(),
170
- __param(1, (0, common_1.Optional)()),
171
- __param(1, (0, common_1.Inject)(posting_version_bump_js_1.POSTING_VERSION_BUMP)),
172
- __metadata("design:paramtypes", [engine_1.RecordsService, Object])
202
+ __param(2, (0, common_1.Optional)()),
203
+ __param(2, (0, common_1.Inject)(posting_version_bump_js_1.POSTING_VERSION_BUMP)),
204
+ __metadata("design:paramtypes", [engine_1.RecordsService,
205
+ grant_ceiling_js_1.GrantCeiling, Object])
173
206
  ], MembershipService);
@@ -5,6 +5,11 @@
5
5
  */
6
6
  export interface PostingVersionBump {
7
7
  bumpTenant(tenantUuid: string): Promise<void>;
8
+ /**
9
+ * OPTIONAL (security hardening Phase 3): bump every tenant the user has a membership in — after
10
+ * the account itself was disabled or re-enabled. Returns the bumped tenants; throws on failure.
11
+ */
12
+ bumpForUser?(userUuid: string): Promise<string[]>;
8
13
  }
9
14
  export declare const POSTING_VERSION_BUMP: unique symbol;
10
15
  //# sourceMappingURL=posting-version-bump.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"posting-version-bump.d.ts","sourceRoot":"","sources":["../../../src/features/membership/posting-version-bump.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/C;AAED,eAAO,MAAM,oBAAoB,eAAiC,CAAC"}
1
+ {"version":3,"file":"posting-version-bump.d.ts","sourceRoot":"","sources":["../../../src/features/membership/posting-version-bump.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C;;;OAGG;IACH,WAAW,CAAC,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CACnD;AAED,eAAO,MAAM,oBAAoB,eAAiC,CAAC"}
@@ -6,21 +6,24 @@
6
6
  * diffs current-vs-desired and applies the minimal change — delete only what's removed,
7
7
  * create only what's added, keep the rest untouched.
8
8
  *
9
- * ── Atomicity / mid-op safety (why delete-then-create is safe here) ──────────────────
10
- * The engine's feature-facing surface (`RecordsService`) exposes NO cross-call transaction
11
- * — each of its methods (create/update/delete/list/get) runs in its OWN transaction, and
12
- * no `externalQueryRunner` seam is handed to features. So `replacePostings` cannot wrap
13
- * the whole diff in a single DB transaction. Instead it is safe BY CONSTRUCTION:
14
- * - Deletes target ONLY rows absent from `desired` (the "removed" set). A KEPT posting
15
- * is never a delete target, so it is never removed.
16
- * - Creates target ONLY rows absent from `current` (the "added" set).
17
- * Therefore the membership NEVER lands empty mid-op: even if a create fails after the
18
- * delete phase, every kept posting is still present (never deleted). Ordering is
19
- * delete-first, then create; a failure surfaces to the caller (fail-closed) with the
20
- * kept set intact. (Covered by the mid-op-safety tests in posting.service.spec.ts.)
9
+ * ── Authority (security hardening #11) ─────────────────────────────────────────────────
10
+ * A posting IS a role assignment, so before ANY write the actor (the request principal) must:
11
+ * - reach the membership (a walled `records.get` → null ⇒ 404; the engine's ADR-0034 create
12
+ * wall re-checks the posting's membership company inside the transaction);
13
+ * - for every ADDED role: the role is bound within the actor's subtree or not bound at all
14
+ * (`GrantCeiling.assertRoleAssignable` — a role also used elsewhere is never assignable),
15
+ * and the role's keys are all held by the actor (`assertCanAssignRoles`, ADR-0029 applied to
16
+ * assignment). Removals need no ceiling: self-demotion stays allowed.
17
+ *
18
+ * ── Atomicity (KI #16) ────────────────────────────────────────────────────────────────
19
+ * The diff runs inside ONE `records.transaction` (ADR-0034): creates FIRST, then deletes, so a
20
+ * failure at any point rolls back every posting write of the replace (no partial set, no
21
+ * window where a removed role is gone but its replacement was refused).
21
22
  */
22
23
  import { RecordsService, type RecordView } from '@astram/engine';
24
+ import type { Principal } from '@astram/core';
23
25
  import { type PostingVersionBump } from './posting-version-bump.js';
26
+ import { GrantCeiling } from '../authz/grant-ceiling.js';
24
27
  /**
25
28
  * The shape of one desired posting row submitted by the editor. The effective-window
26
29
  * bounds arrive from the client as a bare `YYYY-MM-DD` date string (native `<input
@@ -36,24 +39,17 @@ export interface PostingInput {
36
39
  }
37
40
  export declare class PostingService {
38
41
  private readonly records;
42
+ private readonly ceiling;
39
43
  private readonly versionBump?;
40
- constructor(records: RecordsService, versionBump?: (PostingVersionBump | null) | undefined);
44
+ constructor(records: RecordsService, ceiling: GrantCeiling, versionBump?: (PostingVersionBump | null) | undefined);
41
45
  /** List a membership's postings (tenant-scoped by the pipe). */
42
46
  listPostings(membershipUuid: string): Promise<readonly RecordView[]>;
43
47
  /**
44
48
  * Replace a membership's full posting set with `desired`, applying the minimal diff.
45
49
  * Compares current vs desired by the natural key `roleUuid|departmentUuid|branchUuid`:
46
- * deletes removed rows, creates added rows, leaves kept rows untouched. See the file
47
- * header for the atomicity / mid-op-safety reasoning (delete-then-create is safe because
48
- * kept postings are never delete targets, so the membership never lands empty).
49
- */
50
- replacePostings(membershipUuid: string, desired: readonly PostingInput[]): Promise<void>;
51
- /**
52
- * Resolve the tenant (company uuid) a membership belongs to — the tenant whose permission
53
- * version a posting change must bump. Reads the parent `company_membership` through the
54
- * pipe (tenant-scoped, no raw SQL), returning `companyUuid` or `null` if unresolvable
55
- * (then no bump — fail-safe; never guesses a tenant).
50
+ * creates added rows, deletes removed rows, leaves kept rows untouched. Every authority check
51
+ * runs BEFORE any write; the writes run in one transaction (see the file header).
56
52
  */
57
- private resolveMembershipTenant;
53
+ replacePostings(actor: Principal, membershipUuid: string, desired: readonly PostingInput[]): Promise<void>;
58
54
  }
59
55
  //# sourceMappingURL=posting.service.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"posting.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/posting.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAGH,OAAO,EAAE,cAAc,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjE,OAAO,EAAwB,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAE1F;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACvC,cAAc,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CACzC;AA4DD,qBACa,cAAc;IAEvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAKxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;gBALZ,OAAO,EAAE,cAAc,EAKvB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA;IAG1D,gEAAgE;IAC1D,YAAY,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,UAAU,EAAE,CAAC;IAK1E;;;;;;OAMG;IACG,eAAe,CAAC,cAAc,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,YAAY,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IA4D9F;;;;;OAKG;YACW,uBAAuB;CAStC"}
1
+ {"version":3,"file":"posting.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/posting.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAGH,OAAO,EAAE,cAAc,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAwB,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAC1F,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAGzD;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACvC,cAAc,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CACzC;AA0ED,qBACa,cAAc;IAEvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAGxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAKxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;gBARZ,OAAO,EAAE,cAAc,EAGvB,OAAO,EAAE,YAAY,EAKrB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA;IAG1D,gEAAgE;IAC1D,YAAY,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,UAAU,EAAE,CAAC;IAK1E;;;;;OAKG;IACG,eAAe,CACnB,KAAK,EAAE,SAAS,EAChB,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,SAAS,YAAY,EAAE,GAC/B,OAAO,CAAC,IAAI,CAAC;CAuGjB"}
@@ -7,18 +7,19 @@
7
7
  * diffs current-vs-desired and applies the minimal change — delete only what's removed,
8
8
  * create only what's added, keep the rest untouched.
9
9
  *
10
- * ── Atomicity / mid-op safety (why delete-then-create is safe here) ──────────────────
11
- * The engine's feature-facing surface (`RecordsService`) exposes NO cross-call transaction
12
- * — each of its methods (create/update/delete/list/get) runs in its OWN transaction, and
13
- * no `externalQueryRunner` seam is handed to features. So `replacePostings` cannot wrap
14
- * the whole diff in a single DB transaction. Instead it is safe BY CONSTRUCTION:
15
- * - Deletes target ONLY rows absent from `desired` (the "removed" set). A KEPT posting
16
- * is never a delete target, so it is never removed.
17
- * - Creates target ONLY rows absent from `current` (the "added" set).
18
- * Therefore the membership NEVER lands empty mid-op: even if a create fails after the
19
- * delete phase, every kept posting is still present (never deleted). Ordering is
20
- * delete-first, then create; a failure surfaces to the caller (fail-closed) with the
21
- * kept set intact. (Covered by the mid-op-safety tests in posting.service.spec.ts.)
10
+ * ── Authority (security hardening #11) ─────────────────────────────────────────────────
11
+ * A posting IS a role assignment, so before ANY write the actor (the request principal) must:
12
+ * - reach the membership (a walled `records.get` → null ⇒ 404; the engine's ADR-0034 create
13
+ * wall re-checks the posting's membership company inside the transaction);
14
+ * - for every ADDED role: the role is bound within the actor's subtree or not bound at all
15
+ * (`GrantCeiling.assertRoleAssignable` — a role also used elsewhere is never assignable),
16
+ * and the role's keys are all held by the actor (`assertCanAssignRoles`, ADR-0029 applied to
17
+ * assignment). Removals need no ceiling: self-demotion stays allowed.
18
+ *
19
+ * ── Atomicity (KI #16) ────────────────────────────────────────────────────────────────
20
+ * The diff runs inside ONE `records.transaction` (ADR-0034): creates FIRST, then deletes, so a
21
+ * failure at any point rolls back every posting write of the replace (no partial set, no
22
+ * window where a removed role is gone but its replacement was refused).
22
23
  */
23
24
  var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
24
25
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -37,8 +38,18 @@ exports.PostingService = void 0;
37
38
  const common_1 = require("@nestjs/common");
38
39
  const engine_1 = require("@astram/engine");
39
40
  const posting_version_bump_js_1 = require("./posting-version-bump.js");
41
+ const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
42
+ const identity_errors_js_1 = require("../shared/identity-errors.js");
40
43
  /** The field keys whose values are effective-window epoch-ms bounds (normalized on write). */
41
44
  const WINDOW_KEYS = new Set(['effectiveFrom', 'effectiveUntil']);
45
+ /** The ONLY keys a client posting entry may set (review F4) — never `membershipUuid`. */
46
+ const POSTING_KEYS = [
47
+ 'roleUuid',
48
+ 'departmentUuid',
49
+ 'branchUuid',
50
+ 'effectiveFrom',
51
+ 'effectiveUntil',
52
+ ];
42
53
  /**
43
54
  * Normalize an effective-window bound to epoch-ms (a `number`), or `null` if empty/unparseable.
44
55
  * The membership_posting object declares these fields as `number` (BIGINT epoch-ms) so the
@@ -79,7 +90,13 @@ function naturalKey(p) {
79
90
  */
80
91
  function stripEmpty(input) {
81
92
  const out = {};
82
- for (const [k, v] of Object.entries(input)) {
93
+ // Security review F4: only the posting's own fields are taken from the client entry — a
94
+ // client `membershipUuid` (or `createdBy`, or any unknown key) never reaches the engine; the
95
+ // caller sets `membershipUuid` from the PATH.
96
+ for (const k of POSTING_KEYS) {
97
+ if (!(k in input))
98
+ continue;
99
+ const v = input[k];
83
100
  if (WINDOW_KEYS.has(k)) {
84
101
  const ms = toEpochMs(v);
85
102
  if (ms !== null)
@@ -93,9 +110,14 @@ function stripEmpty(input) {
93
110
  }
94
111
  let PostingService = class PostingService {
95
112
  records;
113
+ ceiling;
96
114
  versionBump;
97
- constructor(records, versionBump) {
115
+ constructor(records,
116
+ // The role-assignment ceiling + binding rule (security hardening #11). REQUIRED: a missing
117
+ // ceiling must fail DI at boot, never silently skip the check.
118
+ ceiling, versionBump) {
98
119
  this.records = records;
120
+ this.ceiling = ceiling;
99
121
  this.versionBump = versionBump;
100
122
  }
101
123
  /** List a membership's postings (tenant-scoped by the pipe). */
@@ -106,45 +128,75 @@ let PostingService = class PostingService {
106
128
  /**
107
129
  * Replace a membership's full posting set with `desired`, applying the minimal diff.
108
130
  * Compares current vs desired by the natural key `roleUuid|departmentUuid|branchUuid`:
109
- * deletes removed rows, creates added rows, leaves kept rows untouched. See the file
110
- * header for the atomicity / mid-op-safety reasoning (delete-then-create is safe because
111
- * kept postings are never delete targets, so the membership never lands empty).
131
+ * creates added rows, deletes removed rows, leaves kept rows untouched. Every authority check
132
+ * runs BEFORE any write; the writes run in one transaction (see the file header).
112
133
  */
113
- async replacePostings(membershipUuid, desired) {
114
- const current = (await this.records.list('membership_posting', { where: { membershipUuid } })).rows;
134
+ async replacePostings(actor, membershipUuid, desired) {
135
+ for (const d of desired) {
136
+ if (typeof d?.roleUuid !== 'string' || d.roleUuid.length === 0) {
137
+ throw new identity_errors_js_1.IdentityBadRequestError('Every assignment needs a role.');
138
+ }
139
+ }
140
+ // 1) Reach: the membership must be visible through the subtree read wall (else 404).
141
+ const membership = await this.records.get('company_membership', membershipUuid);
142
+ if (membership === null)
143
+ throw new identity_errors_js_1.IdentityNotFoundError('Membership not found.');
144
+ const tenantUuid = typeof membership.fields?.companyUuid === 'string' && membership.fields.companyUuid.length > 0
145
+ ? membership.fields.companyUuid
146
+ : null;
147
+ const current = (await this.records.list('membership_posting', { where: { membershipUuid } }))
148
+ .rows;
115
149
  const desiredKeys = new Set(desired.map(naturalKey));
116
150
  const currentKeys = new Set(current.map((r) => naturalKey(r.fields)));
117
- // Track whether the diff actually changed anything — only a real add/remove of a role
118
- // assignment need invalidate cached permission facts (a no-op replace must not bump).
119
- let changed = false;
120
- // Delete removed: rows in current whose key is not in desired. Kept rows are skipped.
121
- for (const r of current) {
122
- const key = naturalKey(r.fields);
123
- if (!desiredKeys.has(key)) {
124
- await this.records.delete('membership_posting', r.id);
125
- changed = true;
126
- }
151
+ const removed = current.filter((r) => !desiredKeys.has(naturalKey(r.fields)));
152
+ const seenAdded = new Set();
153
+ const added = desired.filter((d) => {
154
+ const k = naturalKey(d);
155
+ if (currentKeys.has(k) || seenAdded.has(k))
156
+ return false;
157
+ seenAdded.add(k);
158
+ return true;
159
+ });
160
+ // 2) Authority for every ADDED role, before any write: binding rule, then the ceiling.
161
+ const addedRoles = [...new Set(added.map((d) => d.roleUuid))];
162
+ for (const roleUuid of addedRoles) {
163
+ await this.ceiling.assertRoleAssignable(actor, roleUuid);
127
164
  }
128
- // Create added: desired rows whose key is not already present. Only real fields are
129
- // sent (undefined/null/empty optionals are dropped so the engine gets a clean field map).
130
- for (const d of desired) {
131
- if (!currentKeys.has(naturalKey(d))) {
132
- await this.records.create('membership_posting', {
133
- fields: { membershipUuid, ...stripEmpty(d) },
134
- });
135
- changed = true;
136
- }
165
+ await this.ceiling.assertCanAssignRoles(actor, addedRoles);
166
+ // Removing a PEER's postings strips their authority: the actor must dominate the member
167
+ // (security review). Removing your OWN postings (self-demotion) stays allowed.
168
+ const memberUuid = typeof membership.fields?.userUuid === 'string' ? membership.fields.userUuid : '';
169
+ if (removed.length > 0 && memberUuid !== actor.id) {
170
+ await this.ceiling.assertDominates(actor, memberUuid);
137
171
  }
172
+ if (added.length === 0 && removed.length === 0)
173
+ return; // no-op replace: no write, no bump
174
+ // 3) Write atomically: creates first, then deletes (KI #16). Only whitelisted fields are sent,
175
+ // and `membershipUuid` always comes from the path (review F4).
176
+ await this.records.transaction(async (tx) => {
177
+ // Review F2: serialise against a concurrent grant/posting of the same role. Lock each
178
+ // added role row on THIS transaction (grant administration locks the same row), then
179
+ // re-run the binding rule: a binding another tenant committed while we waited is now
180
+ // visible, and a role that became shared is refused before the INSERT (rolled back).
181
+ for (const roleUuid of addedRoles) {
182
+ await tx.lockForUpdate('role', roleUuid);
183
+ await this.ceiling.assertRoleAssignable(actor, roleUuid);
184
+ }
185
+ for (const d of added) {
186
+ await tx.create('membership_posting', { fields: { ...stripEmpty(d), membershipUuid } });
187
+ }
188
+ for (const r of removed) {
189
+ await tx.delete('membership_posting', r.id);
190
+ }
191
+ });
138
192
  // ADR-0011 STALENESS FIX: a posting add/remove changes the effective roles (→ coarse
139
193
  // grants) of the membership's user in that company, so bump the tenant's permission
140
194
  // version — every live session in that tenant then reloads its cached facts on the next
141
- // request (a role gain/loss lands within the version bound). Only when something actually
142
- // changed, and only when the bump collaborator is wired (absent in unit tests → no-op).
143
- // Best-effort + fail-safe: the posting write already succeeded, so a bump failure must
144
- // NOT surface as a 500 that hides a successful assignment change — the cache is instead
145
- // invalidated by the next legit bump / the facts' natural TTL. We log-swallow it.
146
- if (changed && this.versionBump) {
147
- const tenantUuid = await this.resolveMembershipTenant(membershipUuid);
195
+ // request (a role gain/loss lands within the version bound). Only when the bump collaborator
196
+ // is wired (absent in unit tests → no-op). Best-effort + fail-safe: the posting write already
197
+ // committed, so a bump failure must NOT surface as a 500 that hides a successful assignment
198
+ // change — the cache is instead invalidated by the next legit bump / the facts' natural TTL.
199
+ if (this.versionBump) {
148
200
  if (tenantUuid) {
149
201
  try {
150
202
  await this.versionBump.bumpTenant(tenantUuid);
@@ -155,27 +207,12 @@ let PostingService = class PostingService {
155
207
  }
156
208
  }
157
209
  }
158
- /**
159
- * Resolve the tenant (company uuid) a membership belongs to — the tenant whose permission
160
- * version a posting change must bump. Reads the parent `company_membership` through the
161
- * pipe (tenant-scoped, no raw SQL), returning `companyUuid` or `null` if unresolvable
162
- * (then no bump — fail-safe; never guesses a tenant).
163
- */
164
- async resolveMembershipTenant(membershipUuid) {
165
- try {
166
- const m = await this.records.get('company_membership', membershipUuid);
167
- const companyUuid = m?.fields?.companyUuid;
168
- return typeof companyUuid === 'string' && companyUuid.length > 0 ? companyUuid : null;
169
- }
170
- catch {
171
- return null;
172
- }
173
- }
174
210
  };
175
211
  exports.PostingService = PostingService;
176
212
  exports.PostingService = PostingService = __decorate([
177
213
  (0, common_1.Injectable)(),
178
- __param(1, (0, common_1.Optional)()),
179
- __param(1, (0, common_1.Inject)(posting_version_bump_js_1.POSTING_VERSION_BUMP)),
180
- __metadata("design:paramtypes", [engine_1.RecordsService, Object])
214
+ __param(2, (0, common_1.Optional)()),
215
+ __param(2, (0, common_1.Inject)(posting_version_bump_js_1.POSTING_VERSION_BUMP)),
216
+ __metadata("design:paramtypes", [engine_1.RecordsService,
217
+ grant_ceiling_js_1.GrantCeiling, Object])
181
218
  ], PostingService);
@@ -1 +1 @@
1
- {"version":3,"file":"org-catalog.controller.d.ts","sourceRoot":"","sources":["../../../src/features/org-structure/org-catalog.controller.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAU7D,qBACa,oBAAoB;IACnB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,iBAAiB;IAEvD,kFAAkF;IAG5E,sBAAsB,CAAuB,WAAW,EAAE,MAAM;;;IAKtE,+EAA+E;IAGzE,mBAAmB,CAAuB,WAAW,EAAE,MAAM;;;CAIpE"}
1
+ {"version":3,"file":"org-catalog.controller.d.ts","sourceRoot":"","sources":["../../../src/features/org-structure/org-catalog.controller.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAU7D,qBAEa,oBAAoB;IACnB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,iBAAiB;IAEvD,kFAAkF;IAG5E,sBAAsB,CAAuB,WAAW,EAAE,MAAM;;;IAKtE,+EAA+E;IAGzE,mBAAmB,CAAuB,WAAW,EAAE,MAAM;;;CAIpE"}
@@ -14,6 +14,7 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.OrgCatalogController = void 0;
16
16
  const common_1 = require("@nestjs/common");
17
+ const identity_error_interceptor_js_1 = require("../shared/identity-error.interceptor.js");
17
18
  const guard_1 = require("@astram/guard");
18
19
  const org_catalog_service_js_1 = require("./org-catalog.service.js");
19
20
  // ── Company-scoped catalogs (dept/branch option lists for the posting editor) ──
@@ -58,5 +59,6 @@ __decorate([
58
59
  ], OrgCatalogController.prototype, "listCompanyBranches", null);
59
60
  exports.OrgCatalogController = OrgCatalogController = __decorate([
60
61
  (0, common_1.Controller)('identity'),
62
+ (0, common_1.UseInterceptors)(identity_error_interceptor_js_1.IdentityErrorInterceptor),
61
63
  __metadata("design:paramtypes", [org_catalog_service_js_1.OrgCatalogService])
62
64
  ], OrgCatalogController);
@@ -1 +1 @@
1
- {"version":3,"file":"org-catalog.service.d.ts","sourceRoot":"","sources":["../../../src/features/org-structure/org-catalog.service.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,KAAK,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAE7D,qBACa,iBAAiB;IAChB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,cAAc;IAEpD,kFAAkF;IAClF,sBAAsB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;IAInE,+EAA+E;IAC/E,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;IAIhE,qFAAqF;YACvE,kBAAkB;CAiBjC"}
1
+ {"version":3,"file":"org-catalog.service.d.ts","sourceRoot":"","sources":["../../../src/features/org-structure/org-catalog.service.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,KAAK,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAE7D,qBACa,iBAAiB;IAChB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,cAAc;IAEpD,kFAAkF;IAClF,sBAAsB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;IASnE,+EAA+E;IAC/E,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;IAIhE,qFAAqF;YACvE,kBAAkB;CAoBjC"}
@@ -41,7 +41,10 @@ let OrgCatalogService = class OrgCatalogService {
41
41
  continue;
42
42
  const view = await this.records.get(catalogTypeKey, refUuid);
43
43
  const name = view?.fields?.name;
44
- options.push({ uuid: refUuid, name: typeof name === 'string' && name.length > 0 ? name : '(unknown)' });
44
+ options.push({
45
+ uuid: refUuid,
46
+ name: typeof name === 'string' && name.length > 0 ? name : '(unknown)',
47
+ });
45
48
  }
46
49
  return options;
47
50
  }
@@ -1 +1 @@
1
- {"version":3,"file":"policy.controller.d.ts","sourceRoot":"","sources":["../../../src/features/policy/policy.controller.ts"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC5D,OAAO,EACL,aAAa,EAId,MAAM,qBAAqB,CAAC;AAE7B,qFAAqF;AACrF,UAAU,aAAa;IACrB,SAAS,EAAE,SAAS,CAAC;CACtB;AAED,qBACa,gBAAgB;IACf,OAAO,CAAC,QAAQ,CAAC,QAAQ;gBAAR,QAAQ,EAAE,aAAa;IAEpD;;;;;;;;OAQG;IAGG,MAAM,CAAmB,OAAO,CAAC,EAAE,MAAM;IAY/C;;;OAGG;IAGG,IAAI,CAAQ,GAAG,EAAE,aAAa,EAAoB,OAAO,CAAC,EAAE,MAAM;;;IAQxE;;;;;OAKG;IAGG,MAAM,CAAQ,GAAG,EAAE,aAAa,EAAU,IAAI,EAAE,YAAY;;;IAWlE;;;;OAIG;IAGG,MAAM,CAAQ,GAAG,EAAE,aAAa,EAAe,EAAE,EAAE,MAAM;;;CAShE"}
1
+ {"version":3,"file":"policy.controller.d.ts","sourceRoot":"","sources":["../../../src/features/policy/policy.controller.ts"],"names":[],"mappings":"AAkCA,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC5D,OAAO,EACL,aAAa,EAId,MAAM,qBAAqB,CAAC;AAE7B,qFAAqF;AACrF,UAAU,aAAa;IACrB,SAAS,EAAE,SAAS,CAAC;CACtB;AAED,qBAEa,gBAAgB;IACf,OAAO,CAAC,QAAQ,CAAC,QAAQ;gBAAR,QAAQ,EAAE,aAAa;IAEpD;;;;;;;;OAQG;IAGG,MAAM,CAAmB,OAAO,CAAC,EAAE,MAAM;IAY/C;;;OAGG;IAGG,IAAI,CAAQ,GAAG,EAAE,aAAa,EAAoB,OAAO,CAAC,EAAE,MAAM;;;IAQxE;;;;;OAKG;IAGG,MAAM,CAAQ,GAAG,EAAE,aAAa,EAAU,IAAI,EAAE,YAAY;;;IAWlE;;;;OAIG;IAGG,MAAM,CAAQ,GAAG,EAAE,aAAa,EAAe,EAAE,EAAE,MAAM;;;CAShE"}