@astram/identity 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) 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 +72 -16
  22. package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
  23. package/dist/features/authz/grant-admin.service.js +232 -86
  24. package/dist/features/authz/grant-ceiling.d.ts +100 -1
  25. package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
  26. package/dist/features/authz/grant-ceiling.js +264 -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 +52 -1
  30. package/dist/features/authz/permission-version.bumper.d.ts.map +1 -1
  31. package/dist/features/authz/permission-version.bumper.js +119 -20
  32. package/dist/features/authz/record-authorization.d.ts +12 -0
  33. package/dist/features/authz/record-authorization.d.ts.map +1 -1
  34. package/dist/features/authz/record-authorization.js +49 -0
  35. package/dist/features/authz/role-binding.d.ts +36 -0
  36. package/dist/features/authz/role-binding.d.ts.map +1 -0
  37. package/dist/features/authz/role-binding.js +53 -0
  38. package/dist/features/company/company-hierarchy.service.d.ts +108 -0
  39. package/dist/features/company/company-hierarchy.service.d.ts.map +1 -0
  40. package/dist/features/company/company-hierarchy.service.js +277 -0
  41. package/dist/features/company/company.controller.d.ts +23 -5
  42. package/dist/features/company/company.controller.d.ts.map +1 -1
  43. package/dist/features/company/company.controller.js +47 -12
  44. package/dist/features/company/company.service.d.ts +28 -12
  45. package/dist/features/company/company.service.d.ts.map +1 -1
  46. package/dist/features/company/company.service.js +61 -14
  47. package/dist/features/cross-tenant/cross-tenant-grant.controller.d.ts.map +1 -1
  48. package/dist/features/cross-tenant/cross-tenant-grant.controller.js +2 -0
  49. package/dist/features/identity-feature.module.d.ts +2 -0
  50. package/dist/features/identity-feature.module.d.ts.map +1 -1
  51. package/dist/features/identity-feature.module.js +72 -5
  52. package/dist/features/membership/membership.controller.d.ts +6 -5
  53. package/dist/features/membership/membership.controller.d.ts.map +1 -1
  54. package/dist/features/membership/membership.controller.js +34 -22
  55. package/dist/features/membership/membership.service.d.ts +20 -14
  56. package/dist/features/membership/membership.service.d.ts.map +1 -1
  57. package/dist/features/membership/membership.service.js +62 -29
  58. package/dist/features/membership/posting-version-bump.d.ts +12 -0
  59. package/dist/features/membership/posting-version-bump.d.ts.map +1 -1
  60. package/dist/features/membership/posting.service.d.ts +20 -24
  61. package/dist/features/membership/posting.service.d.ts.map +1 -1
  62. package/dist/features/membership/posting.service.js +101 -64
  63. package/dist/features/org-structure/org-catalog.controller.d.ts.map +1 -1
  64. package/dist/features/org-structure/org-catalog.controller.js +2 -0
  65. package/dist/features/org-structure/org-catalog.service.d.ts.map +1 -1
  66. package/dist/features/org-structure/org-catalog.service.js +4 -1
  67. package/dist/features/policy/policy.controller.d.ts.map +1 -1
  68. package/dist/features/policy/policy.controller.js +2 -0
  69. package/dist/features/role/role.controller.d.ts +3 -2
  70. package/dist/features/role/role.controller.d.ts.map +1 -1
  71. package/dist/features/role/role.controller.js +15 -10
  72. package/dist/features/role/role.service.d.ts +39 -8
  73. package/dist/features/role/role.service.d.ts.map +1 -1
  74. package/dist/features/role/role.service.js +77 -10
  75. package/dist/features/scope/scope-closure.service.d.ts +54 -4
  76. package/dist/features/scope/scope-closure.service.d.ts.map +1 -1
  77. package/dist/features/scope/scope-closure.service.js +237 -85
  78. package/dist/features/service-account/service-account.controller.d.ts +3 -2
  79. package/dist/features/service-account/service-account.controller.d.ts.map +1 -1
  80. package/dist/features/service-account/service-account.controller.js +29 -15
  81. package/dist/features/service-account/service-account.service.d.ts +29 -7
  82. package/dist/features/service-account/service-account.service.d.ts.map +1 -1
  83. package/dist/features/service-account/service-account.service.js +73 -12
  84. package/dist/features/service-account/service-account.store.d.ts +15 -0
  85. package/dist/features/service-account/service-account.store.d.ts.map +1 -1
  86. package/dist/features/service-account/service-account.store.js +29 -0
  87. package/dist/features/share/share.controller.d.ts.map +1 -1
  88. package/dist/features/share/share.controller.js +2 -0
  89. package/dist/features/share/share.service.d.ts.map +1 -1
  90. package/dist/features/shared/identity-error.interceptor.d.ts +24 -0
  91. package/dist/features/shared/identity-error.interceptor.d.ts.map +1 -0
  92. package/dist/features/shared/identity-error.interceptor.js +82 -0
  93. package/dist/features/shared/identity-errors.d.ts +23 -0
  94. package/dist/features/shared/identity-errors.d.ts.map +1 -0
  95. package/dist/features/shared/identity-errors.js +41 -0
  96. package/dist/features/user/session-revocation-check.d.ts +11 -0
  97. package/dist/features/user/session-revocation-check.d.ts.map +1 -0
  98. package/dist/features/user/session-revocation-check.js +21 -0
  99. package/dist/features/user/user.controller.d.ts +11 -6
  100. package/dist/features/user/user.controller.d.ts.map +1 -1
  101. package/dist/features/user/user.controller.js +35 -17
  102. package/dist/features/user/user.service.d.ts +55 -14
  103. package/dist/features/user/user.service.d.ts.map +1 -1
  104. package/dist/features/user/user.service.js +143 -16
  105. package/dist/index.d.ts +7 -5
  106. package/dist/index.d.ts.map +1 -1
  107. package/dist/index.js +7 -6
  108. package/dist/internal.d.ts +10 -0
  109. package/dist/internal.d.ts.map +1 -0
  110. package/dist/internal.js +13 -0
  111. package/dist/membership-posting.object.d.ts.map +1 -1
  112. package/dist/membership-posting.object.js +4 -2
  113. package/dist/objects.d.ts.map +1 -1
  114. package/dist/objects.js +13 -3
  115. package/dist/role.object.d.ts.map +1 -1
  116. package/dist/role.object.js +3 -0
  117. package/dist/user.object.d.ts.map +1 -1
  118. package/dist/user.object.js +3 -0
  119. package/migrations/1710000000019-AddSessionMustReenroll.cjs +35 -0
  120. package/package.json +15 -4
  121. package/dist/auth-facts-keys.js.map +0 -1
  122. package/dist/branch.object.js.map +0 -1
  123. package/dist/company-branch.object.js.map +0 -1
  124. package/dist/company-department.object.js.map +0 -1
  125. package/dist/company-membership.object.js.map +0 -1
  126. package/dist/company.object.js.map +0 -1
  127. package/dist/cross-tenant-grant.object.js.map +0 -1
  128. package/dist/department.object.js.map +0 -1
  129. package/dist/features/access/access-admin.access.js.map +0 -1
  130. package/dist/features/access/company-admin.access.js.map +0 -1
  131. package/dist/features/access/index.js.map +0 -1
  132. package/dist/features/access/user-management.access.js.map +0 -1
  133. package/dist/features/audit/audit-read.service.js.map +0 -1
  134. package/dist/features/audit/audit.controller.js.map +0 -1
  135. package/dist/features/audit/directory-label-resolver.js.map +0 -1
  136. package/dist/features/audit/mariadb-audit-reader.js.map +0 -1
  137. package/dist/features/authz/access-bundle-reconcile.job.js.map +0 -1
  138. package/dist/features/authz/access-bundle-store.js.map +0 -1
  139. package/dist/features/authz/catalog-bootstrap.provider.js.map +0 -1
  140. package/dist/features/authz/catalog-reconcile.job.js.map +0 -1
  141. package/dist/features/authz/db-permission-facts.adapter.js.map +0 -1
  142. package/dist/features/authz/grant-admin.controller.js.map +0 -1
  143. package/dist/features/authz/grant-admin.service.js.map +0 -1
  144. package/dist/features/authz/grant-ceiling.js.map +0 -1
  145. package/dist/features/authz/mariadb-catalog-store.js.map +0 -1
  146. package/dist/features/authz/permission-version.bumper.js.map +0 -1
  147. package/dist/features/authz/record-authorization.js.map +0 -1
  148. package/dist/features/authz/session-facts.reader.js.map +0 -1
  149. package/dist/features/company/company.controller.js.map +0 -1
  150. package/dist/features/company/company.service.js.map +0 -1
  151. package/dist/features/cross-tenant/cross-tenant-grant-read.service.js.map +0 -1
  152. package/dist/features/cross-tenant/cross-tenant-grant.controller.js.map +0 -1
  153. package/dist/features/cross-tenant/grant-authorize.js.map +0 -1
  154. package/dist/features/identity-feature.module.js.map +0 -1
  155. package/dist/features/membership/membership-checker.js.map +0 -1
  156. package/dist/features/membership/membership.controller.js.map +0 -1
  157. package/dist/features/membership/membership.service.js.map +0 -1
  158. package/dist/features/membership/posting-version-bump.js.map +0 -1
  159. package/dist/features/membership/posting.service.js.map +0 -1
  160. package/dist/features/org-structure/org-catalog.controller.js.map +0 -1
  161. package/dist/features/org-structure/org-catalog.service.js.map +0 -1
  162. package/dist/features/policy/policy.controller.js.map +0 -1
  163. package/dist/features/policy/policy.service.js.map +0 -1
  164. package/dist/features/role/role.controller.js.map +0 -1
  165. package/dist/features/role/role.service.js.map +0 -1
  166. package/dist/features/scope/scope-closure.service.js.map +0 -1
  167. package/dist/features/scope/scope-subtree.resolver.js.map +0 -1
  168. package/dist/features/share/share.controller.js.map +0 -1
  169. package/dist/features/share/share.service.js.map +0 -1
  170. package/dist/features/shared/http.js.map +0 -1
  171. package/dist/features/shared/list-query.js.map +0 -1
  172. package/dist/features/shared/named-option.js.map +0 -1
  173. package/dist/features/shared/record-resolver.js.map +0 -1
  174. package/dist/features/user/user.controller.js.map +0 -1
  175. package/dist/features/user/user.service.js.map +0 -1
  176. package/dist/index.js.map +0 -1
  177. package/dist/membership-posting.object.js.map +0 -1
  178. package/dist/migrations-dir.js.map +0 -1
  179. package/dist/objects.js.map +0 -1
  180. package/dist/record-share.object.js.map +0 -1
  181. package/dist/role.object.js.map +0 -1
  182. package/dist/schema.js.map +0 -1
  183. package/dist/shared-resource.object.js.map +0 -1
  184. package/dist/user.object.js.map +0 -1
@@ -1,18 +1,27 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GrantCeiling = exports.GrantCeilingError = void 0;
4
+ exports.subtreeOf = subtreeOf;
4
5
  exports.coversKey = coversKey;
5
6
  const core_1 = require("@astram/core");
6
7
  const db_permission_facts_adapter_js_1 = require("./db-permission-facts.adapter.js");
8
+ const role_binding_js_1 = require("./role-binding.js");
9
+ const identity_errors_js_1 = require("../shared/identity-errors.js");
10
+ const service_account_service_js_1 = require("../service-account/service-account.service.js");
7
11
  const AUTHZ = 'shield_authz';
12
+ const APP = 'shield_app';
8
13
  /** The grantor exceeds their own authority — cannot grant a key they do not hold (→ 403). */
9
14
  class GrantCeilingError extends Error {
10
- constructor(key) {
11
- super(`You cannot grant '${key}' — it exceeds the permissions you hold.`);
15
+ constructor(key, message) {
16
+ super(message ?? `You cannot grant '${key}' — it exceeds the permissions you hold.`);
12
17
  this.name = 'GrantCeilingError';
13
18
  }
14
19
  }
15
20
  exports.GrantCeilingError = GrantCeilingError;
21
+ /** The actor's reachable subtree: the session's cached closure, else the self node (non-widening). */
22
+ function subtreeOf(actor) {
23
+ return actor.scopeSubtreeNodeIds?.length ? actor.scopeSubtreeNodeIds : [actor.tenantId];
24
+ }
16
25
  /**
17
26
  * Does `effective` (the grantor's held EXACT keys) cover `granted`?
18
27
  *
@@ -44,12 +53,61 @@ class GrantCeiling {
44
53
  pool;
45
54
  registry;
46
55
  now;
56
+ rootIds;
47
57
  constructor(pool, registry,
58
+ /**
59
+ * The platform root allowlist (the SAME `rootIds` given to `ShieldModule.forRoot`) — REQUIRED,
60
+ * no default (security review F5): pass the array, or `null` to state explicitly that it is
61
+ * not configured. A root user is never dominated by a non-root actor; independently of this
62
+ * list, a user flagged `u_is_platform_superuser` in the database is never dominated either.
63
+ */
64
+ rootIds,
48
65
  /** Injectable clock (epoch-ms) for the posting effective-window read; defaults to Date.now. */
49
66
  now = Date.now) {
50
67
  this.pool = pool;
51
68
  this.registry = registry;
52
69
  this.now = now;
70
+ if (rootIds !== null && !Array.isArray(rootIds)) {
71
+ throw new TypeError('GrantCeiling: rootIds is required (the root allowlist, or null when none is configured)');
72
+ }
73
+ this.rootIds = rootIds ?? [];
74
+ }
75
+ /** The D1 administer rule over a binding read: used inside and nowhere outside (404 / 403). */
76
+ static checkAdministrable(b) {
77
+ if (!b.inside)
78
+ throw new identity_errors_js_1.IdentityNotFoundError('Role not found.');
79
+ if (b.outside) {
80
+ throw new identity_errors_js_1.IdentityForbiddenError('This role is also used outside the organisations you manage; only a platform administrator can change it.');
81
+ }
82
+ }
83
+ /** The #11 assign rule over a binding read: never a role also used outside (403). */
84
+ static checkAssignable(b) {
85
+ if (b.outside) {
86
+ throw new identity_errors_js_1.IdentityForbiddenError('This role is also used outside the organisations you manage; you cannot assign it.');
87
+ }
88
+ }
89
+ /** The registry's exact-key universe (wildcard expansion bound). */
90
+ registeredKeys() {
91
+ return this.registry.listActions().map((a) => a.permissionKey);
92
+ }
93
+ /** The raw `role_permission` keys of `roleIds` (exact or wildcard). Store errors propagate. */
94
+ async rawKeysOfRoles(roleIds) {
95
+ if (roleIds.length === 0)
96
+ return [];
97
+ const ph = roleIds.map(() => '?').join(', ');
98
+ const rows = (await this.pool.query(`SELECT rp_perm_key FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid IN (${ph})`, [...roleIds]));
99
+ return rows.map((r) => r.rp_perm_key);
100
+ }
101
+ /** Run a store read, mapping any non-domain failure to AuthorizationUnavailableError (503). */
102
+ async facts(what, read) {
103
+ try {
104
+ return await read();
105
+ }
106
+ catch (cause) {
107
+ if (cause instanceof core_1.AuthorizationUnavailableError)
108
+ throw cause;
109
+ throw new core_1.AuthorizationUnavailableError(`${what} unavailable`, cause);
110
+ }
53
111
  }
54
112
  /**
55
113
  * The grantor's EFFECTIVE exact keys: the keys granted to the grantor's roles (active
@@ -90,5 +148,209 @@ class GrantCeiling {
90
148
  }
91
149
  }
92
150
  }
151
+ // ── Block A (security hardening Phase 1) ──────────────────────────────────────────────────
152
+ /**
153
+ * Assert the actor may ASSIGN every role in `roleIds` (post someone to it): the roles' effective
154
+ * keys (their `role_permission` rows, wildcards expanded against the registry) must all be held
155
+ * by the actor — the same "only grant what you hold" ceiling, applied to role assignment (#11).
156
+ * A role holding nothing assigns nothing and passes; a role key the registry does not know
157
+ * counts as not held (Phase 8 #4). Root bypasses. Store outage → 503.
158
+ */
159
+ async assertCanAssignRoles(actor, roleIds) {
160
+ if (actor.isRoot === true)
161
+ return;
162
+ const ids = [...new Set(roleIds)];
163
+ if (ids.length === 0)
164
+ return;
165
+ const raw = await this.facts('role grants', () => this.rawKeysOfRoles(ids));
166
+ if (raw.length === 0)
167
+ return;
168
+ const effective = await this.effectiveKeys(actor);
169
+ const key = this.firstUncovered(effective, raw);
170
+ if (key !== undefined) {
171
+ throw new GrantCeilingError(key, `You cannot assign this role — it grants '${key}', which exceeds the permissions you hold.`);
172
+ }
173
+ }
174
+ /**
175
+ * The first of a TARGET's raw keys (a role's `role_permission` rows) the actor does not cover,
176
+ * or `undefined` when all are covered. Phase 8 #4: judged per RAW key with `coversKey`, so a key
177
+ * the registry does not know — an exact key that is not registered, or a wildcard expanding to
178
+ * nothing — counts as NOT held and denies. Expanding first would silently drop it and could
179
+ * pass vacuously (fail open) when this registry is narrower than the decision path's.
180
+ */
181
+ firstUncovered(effective, raw) {
182
+ const registered = this.registeredKeys();
183
+ return [...new Set(raw)].sort().find((k) => !coversKey(effective, registered, k));
184
+ }
185
+ /**
186
+ * Assert the actor DOMINATES the target user (#3, D2): every membership the target holds (in any
187
+ * tenant, active or not) is inside the actor's subtree, AND every key the target could hold
188
+ * through any of its postings (any membership, any window — potential authority, not just what is
189
+ * in effect now) is held by the actor. A platform root target is never dominated by a non-root.
190
+ * Root actors bypass. Store outage → 503.
191
+ */
192
+ async assertDominates(actor, targetUserId) {
193
+ if (actor.isRoot === true)
194
+ return;
195
+ if (this.rootIds.includes(targetUserId)) {
196
+ throw new identity_errors_js_1.IdentityForbiddenError('You cannot change this user: they are a platform administrator.');
197
+ }
198
+ const subtree = new Set(subtreeOf(actor));
199
+ const { companies, roleIds, superuser } = await this.facts('target memberships', async () => {
200
+ const su = (await this.pool.query(`SELECT u_is_platform_superuser AS su FROM \`${APP}\`.\`users\` WHERE u_uuid = ? LIMIT 1`, [targetUserId]));
201
+ const memberships = (await this.pool.query(`SELECT DISTINCT cm_company_uuid AS company
202
+ FROM \`${APP}\`.\`company_membership\`
203
+ WHERE cm_user_uuid = ?`, [targetUserId]));
204
+ const postings = (await this.pool.query(`SELECT DISTINCT mp.mp_role_uuid AS role_uuid
205
+ FROM \`${APP}\`.\`membership_posting\` mp
206
+ JOIN \`${APP}\`.\`company_membership\` cm ON cm.cm_uuid = mp.mp_membership_uuid
207
+ WHERE cm.cm_user_uuid = ?`, [targetUserId]));
208
+ return {
209
+ companies: memberships.map((m) => m.company),
210
+ roleIds: postings.map((p) => p.role_uuid),
211
+ superuser: Number(su[0]?.su ?? 0) === 1,
212
+ };
213
+ });
214
+ if (superuser) {
215
+ throw new identity_errors_js_1.IdentityForbiddenError('You cannot change this user: they are a platform administrator.');
216
+ }
217
+ // Security review F5: a user with NO membership is in nobody's subtree — refuse like the
218
+ // walled read does (404), never "vacuously dominated".
219
+ if (companies.length === 0)
220
+ throw new identity_errors_js_1.IdentityNotFoundError('User not found.');
221
+ if (companies.some((c) => !subtree.has(c))) {
222
+ throw new identity_errors_js_1.IdentityForbiddenError('You cannot change this user: they also belong to an organisation outside the ones you manage.');
223
+ }
224
+ const raw = await this.facts('target grants', () => this.rawKeysOfRoles(roleIds));
225
+ if (raw.length === 0)
226
+ return;
227
+ const effective = await this.effectiveKeys(actor);
228
+ // Phase 8 #4: an unknown target key counts as not held (see `firstUncovered`).
229
+ if (this.firstUncovered(effective, raw) !== undefined) {
230
+ throw new identity_errors_js_1.IdentityForbiddenError('You cannot change this user: they hold permissions you do not hold.');
231
+ }
232
+ }
233
+ /**
234
+ * Assert the actor DOMINATES every user who holds the role (Phase 8 #2): deleting a role, or
235
+ * revoking a key from it, strips authority from each holder, so it needs the same
236
+ * {@link assertDominates} the actor would need to change any one of them directly. Holders are
237
+ * the users with a posting of the role in any membership and any window (potential authority).
238
+ * The actor itself is skipped: giving up your own authority stays allowed (self-demotion).
239
+ * Service-account holders: a revoke is covered by the binding rule (`assertRoleAdministrable`:
240
+ * no binding outside the actor's subtree) plus "only revoke what you hold"; a role DELETE also
241
+ * strips the role from each account and checks {@link assertMayStripRoleFromServiceAccounts}.
242
+ * Root bypasses. Store outage → 503.
243
+ *
244
+ * Callers serialise against a concurrent posting by locking the role row first (the posting
245
+ * path locks the same row before it inserts), then call this — so no holder appears unseen.
246
+ */
247
+ async assertDominatesRoleHolders(actor, roleUuid) {
248
+ if (actor.isRoot === true)
249
+ return;
250
+ const holders = await this.facts('role holders', async () => {
251
+ const rows = (await this.pool.query(`SELECT DISTINCT cm.cm_user_uuid AS holder
252
+ FROM \`${APP}\`.\`membership_posting\` mp
253
+ JOIN \`${APP}\`.\`company_membership\` cm ON cm.cm_uuid = mp.mp_membership_uuid
254
+ WHERE mp.mp_role_uuid = ?`, [roleUuid]));
255
+ return rows.map((r) => r.holder);
256
+ });
257
+ for (const holder of [...new Set(holders)].sort()) {
258
+ if (holder === actor.id)
259
+ continue;
260
+ try {
261
+ await this.assertDominates(actor, holder);
262
+ }
263
+ catch (cause) {
264
+ if (cause instanceof identity_errors_js_1.IdentityForbiddenError || cause instanceof identity_errors_js_1.IdentityNotFoundError) {
265
+ // Names no holder (who holds a role is not the caller's to learn from a refusal).
266
+ throw new identity_errors_js_1.IdentityForbiddenError('You cannot change this role: it is held by someone outside your authority.');
267
+ }
268
+ throw cause;
269
+ }
270
+ }
271
+ }
272
+ /**
273
+ * Assert the actor may REMOVE `roleUuid` from every service account holding it (Phase 8 #2
274
+ * follow-up: a role delete strips the role from each holding account). Judged by the SAME rules
275
+ * as editing the account directly (`ServiceAccountService.update`, ADR-0029 ceiling via
276
+ * {@link ceilingOf}): each account's tenant is inside the actor's subtree, and the role is one
277
+ * the actor holds itself — a non-root admin's edit preserves (cannot strip) a role it does not
278
+ * hold. `accountTenantIds` are the holding accounts' tenants, read by the caller under a row lock.
279
+ * Root bypasses. Refusals name no account. Store outage → 503.
280
+ */
281
+ async assertMayStripRoleFromServiceAccounts(actor, roleUuid, accountTenantIds) {
282
+ if (actor.isRoot === true || accountTenantIds.length === 0)
283
+ return;
284
+ const admin = {
285
+ id: actor.id,
286
+ tenantId: actor.tenantId,
287
+ scopeSubtreeNodeIds: subtreeOf(actor),
288
+ isRoot: false,
289
+ };
290
+ const refuse = () => new identity_errors_js_1.IdentityForbiddenError('You cannot delete this role: it is assigned to an agent you cannot change.');
291
+ const nodes = (0, service_account_service_js_1.ceilingOf)(admin, []);
292
+ if (accountTenantIds.some((t) => !(0, service_account_service_js_1.ceilingAllowsNode)(nodes, t)))
293
+ throw refuse();
294
+ const held = await this.facts('held roles', () => (0, db_permission_facts_adapter_js_1.readRoleIdsForTenant)(this.pool, actor.id, actor.tenantId, this.now));
295
+ if (!(0, service_account_service_js_1.ceilingAllowsRole)((0, service_account_service_js_1.ceilingOf)(admin, held), roleUuid))
296
+ throw refuse();
297
+ }
298
+ /**
299
+ * Assert the actor may RETIRE (disable + revoke the keys of) each of these service accounts
300
+ * (Phase 8 #3: a company delete retires the deleted company's own agents). Judged by the SAME
301
+ * ADR-0029 ceiling an admin needs to have made the agent ({@link ceilingOf}): the account's
302
+ * tenant and every node of its scope are inside the actor's subtree, and every role it holds is
303
+ * one the actor holds itself — so a sub-admin can never switch off an agent more powerful than
304
+ * itself by deleting its company. `accounts` are read by the caller under a row lock. Root
305
+ * bypasses. Refusals name no account. Store outage → 503.
306
+ */
307
+ async assertMayRetireServiceAccounts(actor, accounts) {
308
+ if (actor.isRoot === true || accounts.length === 0)
309
+ return;
310
+ const admin = {
311
+ id: actor.id,
312
+ tenantId: actor.tenantId,
313
+ scopeSubtreeNodeIds: subtreeOf(actor),
314
+ isRoot: false,
315
+ };
316
+ const refuse = () => new identity_errors_js_1.IdentityForbiddenError('You cannot delete this company: one of its agents is outside your authority.');
317
+ const nodes = (0, service_account_service_js_1.ceilingOf)(admin, []);
318
+ const outsideNodes = accounts.some((a) => !(0, service_account_service_js_1.ceilingAllowsNode)(nodes, a.tenantId) ||
319
+ a.scopeSubtreeNodeIds.some((n) => !(0, service_account_service_js_1.ceilingAllowsNode)(nodes, n)));
320
+ if (outsideNodes)
321
+ throw refuse();
322
+ if (accounts.every((a) => a.roleIds.length === 0))
323
+ return;
324
+ const held = await this.facts('held roles', () => (0, db_permission_facts_adapter_js_1.readRoleIdsForTenant)(this.pool, actor.id, actor.tenantId, this.now));
325
+ const ceiling = (0, service_account_service_js_1.ceilingOf)(admin, held);
326
+ if (accounts.some((a) => a.roleIds.some((r) => !(0, service_account_service_js_1.ceilingAllowsRole)(ceiling, r)))) {
327
+ throw refuse();
328
+ }
329
+ }
330
+ /** Where `roleUuid` is bound relative to the actor's subtree (postings + service accounts). */
331
+ async roleBinding(actor, roleUuid) {
332
+ return this.facts('role binding', () => (0, role_binding_js_1.readRoleBinding)(this.pool, roleUuid, subtreeOf(actor)));
333
+ }
334
+ /**
335
+ * Assert the actor may ADMINISTER the role — edit its grants, rename or delete it (#1, D1
336
+ * interim rule): the role is bound inside the actor's subtree AND bound nowhere outside it. A
337
+ * role not bound in the subtree is "not found" for the actor (no existence leak); a role also
338
+ * used outside (a shared role) is root-only. Root bypasses.
339
+ */
340
+ async assertRoleAdministrable(actor, roleUuid) {
341
+ if (actor.isRoot === true)
342
+ return;
343
+ GrantCeiling.checkAdministrable(await this.roleBinding(actor, roleUuid));
344
+ }
345
+ /**
346
+ * Assert the actor may POST someone to the role (#11): the role is bound within the actor's
347
+ * subtree or not bound at all — never a role also used outside it (a shared role's assignment
348
+ * would widen another tenant's role). Pair with {@link assertCanAssignRoles}. Root bypasses.
349
+ */
350
+ async assertRoleAssignable(actor, roleUuid) {
351
+ if (actor.isRoot === true)
352
+ return;
353
+ GrantCeiling.checkAssignable(await this.roleBinding(actor, roleUuid));
354
+ }
93
355
  }
94
356
  exports.GrantCeiling = GrantCeiling;
@@ -1 +1 @@
1
- {"version":3,"file":"mariadb-catalog-store.d.ts","sourceRoot":"","sources":["../../../src/features/authz/mariadb-catalog-store.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAE,YAAY,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAIxE;;;;GAIG;AACH,qBAAa,mBAAoB,YAAW,YAAY;IAC1C,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,IAAI;IAEjC,eAAe,IAAI,OAAO,CAAC,oBAAoB,EAAE,CAAC;IAelD,gBAAgB,CAAC,GAAG,EAAE,IAAI,CAAC,oBAAoB,EAAE,QAAQ,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC;IAW7E,YAAY,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IASpD,UAAU,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;CAQzD"}
1
+ {"version":3,"file":"mariadb-catalog-store.d.ts","sourceRoot":"","sources":["../../../src/features/authz/mariadb-catalog-store.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAE,YAAY,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAIxE;;;;GAIG;AACH,qBAAa,mBAAoB,YAAW,YAAY;IAC1C,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,IAAI;IAEjC,eAAe,IAAI,OAAO,CAAC,oBAAoB,EAAE,CAAC;IAwBlD,gBAAgB,CAAC,GAAG,EAAE,IAAI,CAAC,oBAAoB,EAAE,QAAQ,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC;IAmB7E,YAAY,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IASpD,UAAU,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;CAQzD"}
@@ -16,8 +16,12 @@ class MariaDbCatalogStore {
16
16
  const rows = (await this.pool.query(`SELECT perm_key, perm_module, perm_type_key, perm_action, perm_label, perm_tier, perm_status
17
17
  FROM \`${AUTHZ}\`.\`permission\``));
18
18
  return rows.map((r) => ({
19
- permissionKey: r.perm_key, moduleKey: r.perm_module, typeKey: r.perm_type_key,
20
- action: r.perm_action, label: r.perm_label, tier: r.perm_tier,
19
+ permissionKey: r.perm_key,
20
+ moduleKey: r.perm_module,
21
+ typeKey: r.perm_type_key,
22
+ action: r.perm_action,
23
+ label: r.perm_label,
24
+ tier: r.perm_tier,
21
25
  status: r.perm_status === 'orphaned' ? 'orphaned' : 'active',
22
26
  }));
23
27
  }
@@ -25,7 +29,15 @@ class MariaDbCatalogStore {
25
29
  const res = (await this.pool.query(`INSERT INTO \`${AUTHZ}\`.\`permission\`
26
30
  (perm_uuid, perm_key, perm_module, perm_type_key, perm_action, perm_label, perm_tier, perm_status)
27
31
  SELECT UUID(), ?, ?, ?, ?, ?, ?, 'active'
28
- WHERE NOT EXISTS (SELECT 1 FROM \`${AUTHZ}\`.\`permission\` WHERE perm_key = ?)`, [row.permissionKey, row.moduleKey, row.typeKey, row.action, row.label, row.tier, row.permissionKey]));
32
+ WHERE NOT EXISTS (SELECT 1 FROM \`${AUTHZ}\`.\`permission\` WHERE perm_key = ?)`, [
33
+ row.permissionKey,
34
+ row.moduleKey,
35
+ row.typeKey,
36
+ row.action,
37
+ row.label,
38
+ row.tier,
39
+ row.permissionKey,
40
+ ]));
29
41
  return Number(res?.affectedRows ?? 0) > 0;
30
42
  }
31
43
  async markOrphaned(keys) {
@@ -20,7 +20,37 @@ export interface Queryable {
20
20
  * - `bumpForRole` wraps a failure in `AuthorizationUnavailableError` and THROWS (see its own
21
21
  * doc comment below) — do not assume it is also fail-safe.
22
22
  */
23
+ /**
24
+ * Where a committed version is published (ADR-0035, GAPS #6) — `@astram/guard`'s
25
+ * `RedisPermissionVersionCache` satisfies it structurally (monotonic, TTL'd, never throws).
26
+ */
27
+ export interface VersionPublisher {
28
+ publish(tenantUuid: string, version: number): Promise<void>;
29
+ }
23
30
  export declare class PermissionVersionBumper {
31
+ private publisher?;
32
+ /**
33
+ * Bumped versions waiting for their transaction to commit, per connection (ADR-0035). A
34
+ * transaction connection is a pooled object reused across requests, so callers either publish
35
+ * (`publishCommitted`, after `commit()`) or drop (`discardPending`, after `rollback()`) them.
36
+ */
37
+ private readonly pending;
38
+ private readonly logger;
39
+ /**
40
+ * @param publisher OPTIONAL Redis publisher (ADR-0035). Absent → DB-only bumps, exactly as
41
+ * before (no read-back). `IdentityFeatureModule` attaches the guard's cache through DI.
42
+ */
43
+ constructor(publisher?: VersionPublisher | undefined);
44
+ /** Attach the Redis publisher once it is resolved through DI (first attach wins). */
45
+ attachVersionPublisher(publisher: VersionPublisher): void;
46
+ /**
47
+ * Publish the versions this transaction connection bumped — call right AFTER `commit()`.
48
+ * Best-effort: a failure is logged (no tenant id) and swallowed; the key TTL bounds the delay.
49
+ */
50
+ publishCommitted(conn: Queryable): Promise<void>;
51
+ /** Drop the versions a ROLLED-BACK transaction bumped (nothing was committed). */
52
+ discardPending(conn: Queryable): void;
53
+ private safePublish;
24
54
  /**
25
55
  * Increment the permission version for a SINGLE tenant — for events that change a
26
56
  * principal's EFFECTIVE grants without touching `role_permission` directly (a
@@ -41,7 +71,28 @@ export declare class PermissionVersionBumper {
41
71
  * so both roll back together).
42
72
  */
43
73
  bumpForRole(roleUuid: string, conn: Queryable): Promise<string[]>;
44
- /** The raw per-tenant increment (INSERT-or-increment). */
74
+ /**
75
+ * The tenants whose cached grants depend on the role: every tenant where it is ACTIVELY posted
76
+ * (active membership + posting) plus every service account's tenant holding it. Read-only; the
77
+ * tenant set `bumpForRole` bumps. A role delete reads it BEFORE the delete (Phase 8 #2), because
78
+ * the delete cascades the postings away. Errors propagate raw.
79
+ */
80
+ tenantsForRole(roleUuid: string, conn: Queryable): Promise<string[]>;
81
+ /**
82
+ * Increment the permission version for EVERY tenant where the user has a membership (any status)
83
+ * — for a change to the USER itself that alters its effective grants everywhere (security
84
+ * hardening Phase 3: disabling or re-enabling an account). Returns the bumped tenants. Like
85
+ * `bumpForRole`, a failure is wrapped in `AuthorizationUnavailableError` and THROWS: a silently
86
+ * missed bump would leave a disabled user's cached grants honoured.
87
+ */
88
+ bumpForUser(userUuid: string, conn: Queryable): Promise<string[]>;
89
+ /**
90
+ * The raw per-tenant increment (INSERT-or-increment). With a publisher attached (ADR-0035) the
91
+ * new version is read back on the SAME connection (inside a transaction that is the
92
+ * transaction's own value) and published to Redis AFTER the commit: at once over the autocommit
93
+ * pool, at `publishCommitted(conn)` for a transaction. A read-back failure skips the publish
94
+ * (the TTL bounds it) and never fails the bump.
95
+ */
45
96
  private bumpTenant;
46
97
  }
47
98
  //# sourceMappingURL=permission-version.bumper.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"permission-version.bumper.d.ts","sourceRoot":"","sources":["../../../src/features/authz/permission-version.bumper.ts"],"names":[],"mappings":"AAQA;;;;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;;;;;;;;;;;;;GAaG;AACH,qBAAa,uBAAuB;IAClC;;;;OAIG;IACG,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvE;;;;;;;;;;;;OAYG;IACG,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAmBvE,0DAA0D;YAC5C,UAAU;CAQzB"}
1
+ {"version":3,"file":"permission-version.bumper.d.ts","sourceRoot":"","sources":["../../../src/features/authz/permission-version.bumper.ts"],"names":[],"mappings":"AASA;;;;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;;;;;;;;;;;;;GAaG;AACH;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,OAAO,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7D;AAOD,qBAAa,uBAAuB;IAatB,OAAO,CAAC,SAAS,CAAC;IAZ9B;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA8C;IACtE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAyC;IAEhE;;;OAGG;gBACiB,SAAS,CAAC,EAAE,gBAAgB,YAAA;IAEhD,qFAAqF;IACrF,sBAAsB,CAAC,SAAS,EAAE,gBAAgB,GAAG,IAAI;IAIzD;;;OAGG;IACG,gBAAgB,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAOtD,kFAAkF;IAClF,cAAc,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI;YAIvB,WAAW;IAUzB;;;;OAIG;IACG,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvE;;;;;;;;;;;;OAYG;IACG,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAYvE;;;;;OAKG;IACG,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAmB1E;;;;;;OAMG;IACG,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAgBvE;;;;;;OAMG;YACW,UAAU;CA4BzB"}
@@ -1,26 +1,64 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PermissionVersionBumper = void 0;
4
+ const common_1 = require("@nestjs/common");
4
5
  const core_1 = require("@astram/core");
5
6
  /** Schema the `authz_permission_version` table lives in (ADR-0011). */
6
7
  const AUTHZ = 'shield_authz';
7
8
  /** Schema the membership/posting tables live in — the join `bumpForRole` walks. */
8
9
  const APP = 'shield_app';
9
- /**
10
- * PermissionVersionBumper — the WRITER side of ADR-0011: increments a tenant's
11
- * `shield_authz.authz_permission_version` so `loadCoarseGrants` marks cached facts stale →
12
- * gate 5 reloads → a role gain/loss (from a posting change) lands WITHOUT re-login. Runs the
13
- * INSERT-or-increment over a supplied connection (the sealed authz pool, or a transaction
14
- * connection so the bump joins the caller's write). Fail-safe: the caller wraps a bump failure
15
- * so it never fails the membership/posting write. Never logs the tenant or the query.
16
- *
17
- * NOTE — the two bump methods do NOT share an error contract:
18
- * - `bumpForTenant` propagates the raw underlying error unwrapped; it is up to ITS caller
19
- * (the Phase 3 membership/posting write) to swallow it fail-safe, per the paragraph above.
20
- * - `bumpForRole` wraps a failure in `AuthorizationUnavailableError` and THROWS (see its own
21
- * doc comment below) — do not assume it is also fail-safe.
22
- */
10
+ /** An autocommit pool (it hands out connections) rather than one transaction's connection. */
11
+ function isAutocommitPool(conn) {
12
+ return typeof conn.getConnection === 'function';
13
+ }
23
14
  class PermissionVersionBumper {
15
+ publisher;
16
+ /**
17
+ * Bumped versions waiting for their transaction to commit, per connection (ADR-0035). A
18
+ * transaction connection is a pooled object reused across requests, so callers either publish
19
+ * (`publishCommitted`, after `commit()`) or drop (`discardPending`, after `rollback()`) them.
20
+ */
21
+ pending = new WeakMap();
22
+ logger = new common_1.Logger('PermissionVersionBumper');
23
+ /**
24
+ * @param publisher OPTIONAL Redis publisher (ADR-0035). Absent → DB-only bumps, exactly as
25
+ * before (no read-back). `IdentityFeatureModule` attaches the guard's cache through DI.
26
+ */
27
+ constructor(publisher) {
28
+ this.publisher = publisher;
29
+ }
30
+ /** Attach the Redis publisher once it is resolved through DI (first attach wins). */
31
+ attachVersionPublisher(publisher) {
32
+ if (this.publisher === undefined)
33
+ this.publisher = publisher;
34
+ }
35
+ /**
36
+ * Publish the versions this transaction connection bumped — call right AFTER `commit()`.
37
+ * Best-effort: a failure is logged (no tenant id) and swallowed; the key TTL bounds the delay.
38
+ */
39
+ async publishCommitted(conn) {
40
+ const versions = this.pending.get(conn);
41
+ if (versions === undefined)
42
+ return;
43
+ this.pending.delete(conn);
44
+ for (const [tenantUuid, version] of versions)
45
+ await this.safePublish(tenantUuid, version);
46
+ }
47
+ /** Drop the versions a ROLLED-BACK transaction bumped (nothing was committed). */
48
+ discardPending(conn) {
49
+ this.pending.delete(conn);
50
+ }
51
+ async safePublish(tenantUuid, version) {
52
+ const publisher = this.publisher;
53
+ if (publisher === undefined)
54
+ return;
55
+ try {
56
+ await publisher.publish(tenantUuid, version);
57
+ }
58
+ catch {
59
+ this.logger.warn('permission version publish failed; cached sessions refresh within the TTL');
60
+ }
61
+ }
24
62
  /**
25
63
  * Increment the permission version for a SINGLE tenant — for events that change a
26
64
  * principal's EFFECTIVE grants without touching `role_permission` directly (a
@@ -44,11 +82,7 @@ class PermissionVersionBumper {
44
82
  */
45
83
  async bumpForRole(roleUuid, conn) {
46
84
  try {
47
- const rows = (await conn.query(`SELECT DISTINCT cm.cm_company_uuid AS tenant
48
- FROM \`${APP}\`.\`company_membership\` cm
49
- JOIN \`${APP}\`.\`membership_posting\` mp ON mp.mp_membership_uuid = cm.cm_uuid
50
- WHERE mp.mp_role_uuid = ? AND cm.cm_is_active = 1`, [roleUuid]));
51
- const tenants = rows.map((r) => r.tenant);
85
+ const tenants = await this.tenantsForRole(roleUuid, conn);
52
86
  for (const tenant of tenants) {
53
87
  await this.bumpTenant(tenant, conn);
54
88
  }
@@ -58,11 +92,76 @@ class PermissionVersionBumper {
58
92
  throw new core_1.AuthorizationUnavailableError('permission version bump failed', cause);
59
93
  }
60
94
  }
61
- /** The raw per-tenant increment (INSERT-or-increment). */
95
+ /**
96
+ * The tenants whose cached grants depend on the role: every tenant where it is ACTIVELY posted
97
+ * (active membership + posting) plus every service account's tenant holding it. Read-only; the
98
+ * tenant set `bumpForRole` bumps. A role delete reads it BEFORE the delete (Phase 8 #2), because
99
+ * the delete cascades the postings away. Errors propagate raw.
100
+ */
101
+ async tenantsForRole(roleUuid, conn) {
102
+ const rows = (await conn.query(`SELECT DISTINCT cm.cm_company_uuid AS tenant
103
+ FROM \`${APP}\`.\`company_membership\` cm
104
+ JOIN \`${APP}\`.\`membership_posting\` mp ON mp.mp_membership_uuid = cm.cm_uuid
105
+ WHERE mp.mp_role_uuid = ? AND cm.cm_is_active = 1`, [roleUuid]));
106
+ // Service accounts (ADR-0033) hold roles on `sa_role_ids`, not postings: a role held only by
107
+ // machine principals must still invalidate their tenants' cached grants (security hardening).
108
+ const saRows = (await conn.query(`SELECT DISTINCT sa.sa_tenant_id AS tenant
109
+ FROM \`${APP}\`.\`service_account\` sa
110
+ WHERE JSON_CONTAINS(sa.sa_role_ids, JSON_QUOTE(?))`, [roleUuid]));
111
+ return [...new Set([...rows, ...saRows].map((r) => r.tenant))];
112
+ }
113
+ /**
114
+ * Increment the permission version for EVERY tenant where the user has a membership (any status)
115
+ * — for a change to the USER itself that alters its effective grants everywhere (security
116
+ * hardening Phase 3: disabling or re-enabling an account). Returns the bumped tenants. Like
117
+ * `bumpForRole`, a failure is wrapped in `AuthorizationUnavailableError` and THROWS: a silently
118
+ * missed bump would leave a disabled user's cached grants honoured.
119
+ */
120
+ async bumpForUser(userUuid, conn) {
121
+ try {
122
+ const rows = (await conn.query(`SELECT DISTINCT cm.cm_company_uuid AS tenant
123
+ FROM \`${APP}\`.\`company_membership\` cm
124
+ WHERE cm.cm_user_uuid = ?`, [userUuid]));
125
+ const tenants = [...new Set(rows.map((r) => r.tenant))];
126
+ for (const tenant of tenants)
127
+ await this.bumpTenant(tenant, conn);
128
+ return tenants;
129
+ }
130
+ catch (cause) {
131
+ throw new core_1.AuthorizationUnavailableError('permission version bump failed', cause);
132
+ }
133
+ }
134
+ /**
135
+ * The raw per-tenant increment (INSERT-or-increment). With a publisher attached (ADR-0035) the
136
+ * new version is read back on the SAME connection (inside a transaction that is the
137
+ * transaction's own value) and published to Redis AFTER the commit: at once over the autocommit
138
+ * pool, at `publishCommitted(conn)` for a transaction. A read-back failure skips the publish
139
+ * (the TTL bounds it) and never fails the bump.
140
+ */
62
141
  async bumpTenant(tenantUuid, conn) {
63
142
  await conn.query(`INSERT INTO \`${AUTHZ}\`.\`authz_permission_version\` (apv_tenant_uuid, apv_version)
64
143
  VALUES (?, 1)
65
144
  ON DUPLICATE KEY UPDATE apv_version = apv_version + 1`, [tenantUuid]);
145
+ if (this.publisher === undefined)
146
+ return;
147
+ let version;
148
+ try {
149
+ const rows = (await conn.query(`SELECT apv_version FROM \`${AUTHZ}\`.\`authz_permission_version\`
150
+ WHERE apv_tenant_uuid = ? LIMIT 1`, [tenantUuid]));
151
+ version = Number(rows[0]?.apv_version);
152
+ }
153
+ catch {
154
+ return;
155
+ }
156
+ if (!Number.isSafeInteger(version) || version < 0)
157
+ return;
158
+ if (isAutocommitPool(conn)) {
159
+ await this.safePublish(tenantUuid, version);
160
+ return;
161
+ }
162
+ const versions = this.pending.get(conn) ?? new Map();
163
+ versions.set(tenantUuid, Math.max(version, versions.get(tenantUuid) ?? 0));
164
+ this.pending.set(conn, versions);
66
165
  }
67
166
  }
68
167
  exports.PermissionVersionBumper = PermissionVersionBumper;
@@ -12,6 +12,7 @@
12
12
  import { type Pool } from 'mariadb';
13
13
  import { type RecordAuthorizationInputs } from '@astram/guard';
14
14
  import type { ObjectTypeInput } from '@astram/engine';
15
+ import type { ModuleRegistry } from '@astram/core';
15
16
  /**
16
17
  * A lean `mariadb` pool for the authz grant-reads (built from `APP_DB_*` env via guard's
17
18
  * `loadDbConfig`). Kept small — these are pre-gate coarse-grant reads, not the main CRUD pool.
@@ -37,4 +38,15 @@ export declare function createIdentityAuthzPool(): Pool;
37
38
  * those keys now project, and the framework still owns the four record-tier adapters + scope fn.
38
39
  */
39
40
  export declare function buildIdentityRecordAuthorization(pool: Pool, extraObjects?: readonly ObjectTypeInput[]): RecordAuthorizationInputs;
41
+ /**
42
+ * The inputs builder WITHOUT the boot parity record — for the identity module's own internal
43
+ * authorities (they are not the decision path the consumer hands `ShieldModule`).
44
+ */
45
+ export declare function buildRecordAuthorizationInputs(pool: Pool, extraObjects?: readonly ObjectTypeInput[]): RecordAuthorizationInputs;
46
+ /** The boot error: the two registries' action-key sets differ (names the first differing keys). */
47
+ export declare class IdentityRegistryMismatchError extends Error {
48
+ constructor(onlyDecision: readonly string[], onlyCeiling: readonly string[]);
49
+ }
50
+ /** Called by `IdentityFeatureModule.forRoot` with the grant ceiling's registry (Phase 8 #4). */
51
+ export declare function noteIdentityCeilingRegistry(registry: ModuleRegistry): void;
40
52
  //# sourceMappingURL=record-authorization.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"record-authorization.d.ts","sourceRoot":"","sources":["../../../src/features/authz/record-authorization.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAgB,EAAE,KAAK,IAAI,EAAE,MAAM,SAAS,CAAC;AAC7C,OAAO,EAA6C,KAAK,yBAAyB,EAAE,MAAM,eAAe,CAAC;AAC1G,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAKtD;;;;GAIG;AACH,wBAAgB,uBAAuB,IAAI,IAAI,CAU9C;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,gCAAgC,CAC9C,IAAI,EAAE,IAAI,EACV,YAAY,GAAE,SAAS,eAAe,EAAO,GAC5C,yBAAyB,CAS3B"}
1
+ {"version":3,"file":"record-authorization.d.ts","sourceRoot":"","sources":["../../../src/features/authz/record-authorization.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAgB,EAAE,KAAK,IAAI,EAAE,MAAM,SAAS,CAAC;AAC7C,OAAO,EAGL,KAAK,yBAAyB,EAC/B,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACtD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAKnD;;;;GAIG;AACH,wBAAgB,uBAAuB,IAAI,IAAI,CAU9C;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,gCAAgC,CAC9C,IAAI,EAAE,IAAI,EACV,YAAY,GAAE,SAAS,eAAe,EAAO,GAC5C,yBAAyB,CAI3B;AAED;;;GAGG;AACH,wBAAgB,8BAA8B,CAC5C,IAAI,EAAE,IAAI,EACV,YAAY,GAAE,SAAS,eAAe,EAAO,GAC5C,yBAAyB,CAS3B;AAmBD,mGAAmG;AACnG,qBAAa,6BAA8B,SAAQ,KAAK;gBAC1C,YAAY,EAAE,SAAS,MAAM,EAAE,EAAE,WAAW,EAAE,SAAS,MAAM,EAAE;CAW5E;AAoBD,gGAAgG;AAChG,wBAAgB,2BAA2B,CAAC,QAAQ,EAAE,cAAc,GAAG,IAAI,CAE1E"}