@astram/identity 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/features/authz/grant-admin.service.d.ts +24 -1
  2. package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
  3. package/dist/features/authz/grant-admin.service.js +88 -1
  4. package/dist/features/authz/grant-ceiling.d.ts +49 -1
  5. package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
  6. package/dist/features/authz/grant-ceiling.js +118 -10
  7. package/dist/features/authz/permission-version.bumper.d.ts +7 -0
  8. package/dist/features/authz/permission-version.bumper.d.ts.map +1 -1
  9. package/dist/features/authz/permission-version.bumper.js +19 -10
  10. package/dist/features/authz/record-authorization.d.ts +12 -0
  11. package/dist/features/authz/record-authorization.d.ts.map +1 -1
  12. package/dist/features/authz/record-authorization.js +49 -0
  13. package/dist/features/company/company-hierarchy.service.d.ts +65 -3
  14. package/dist/features/company/company-hierarchy.service.d.ts.map +1 -1
  15. package/dist/features/company/company-hierarchy.service.js +183 -6
  16. package/dist/features/identity-feature.module.d.ts +2 -0
  17. package/dist/features/identity-feature.module.d.ts.map +1 -1
  18. package/dist/features/identity-feature.module.js +19 -9
  19. package/dist/features/membership/posting-version-bump.d.ts +7 -0
  20. package/dist/features/membership/posting-version-bump.d.ts.map +1 -1
  21. package/dist/features/role/role.service.d.ts +25 -2
  22. package/dist/features/role/role.service.d.ts.map +1 -1
  23. package/dist/features/role/role.service.js +57 -4
  24. package/dist/features/scope/scope-closure.service.d.ts +11 -1
  25. package/dist/features/scope/scope-closure.service.d.ts.map +1 -1
  26. package/dist/features/scope/scope-closure.service.js +23 -4
  27. package/dist/features/service-account/service-account.service.d.ts +15 -0
  28. package/dist/features/service-account/service-account.service.d.ts.map +1 -1
  29. package/dist/features/service-account/service-account.service.js +17 -1
  30. package/dist/features/service-account/service-account.store.d.ts +15 -0
  31. package/dist/features/service-account/service-account.store.d.ts.map +1 -1
  32. package/dist/features/service-account/service-account.store.js +29 -0
  33. package/dist/index.d.ts +7 -5
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +7 -6
  36. package/dist/internal.d.ts +10 -0
  37. package/dist/internal.d.ts.map +1 -0
  38. package/dist/internal.js +13 -0
  39. package/package.json +14 -3
@@ -114,9 +114,32 @@ export declare class GrantAdminService {
114
114
  * Revoke a key from a role (no-op if absent) + bump + audit, atomically. Security hardening #1:
115
115
  * the actor must be able to administer the role (binding rule) and must hold the key itself —
116
116
  * "only revoke what you hold" — so a delegated admin can no longer strip a role of authority it
117
- * never had. Root bypasses.
117
+ * never had. Phase 8 #2: it must also dominate every user holding the role (checked under the
118
+ * role lock in `writeGrants`). Root bypasses.
118
119
  */
119
120
  revoke(roleUuid: string, key: string, actor: Principal): Promise<void>;
121
+ /**
122
+ * Remove a role that is being DELETED from authorization (Phase 8 #2 follow-up): every
123
+ * `role_permission` row of the role, and the role from every service account's `sa_role_ids`
124
+ * (guard's API-key store reads that column live, so a stale entry would keep the deleted role's
125
+ * keys on the agent). ONE transaction on the shared authz pool — the same pool, bump, audit and
126
+ * error mapping as {@link revoke}:
127
+ * 1. lock the holding accounts (`FOR UPDATE`), then check the actor may strip the role from
128
+ * each (`GrantCeiling.assertMayStripRoleFromServiceAccounts`, root bypasses);
129
+ * 2. bump every tenant using the role — postings AND the accounts' tenants — before the strip,
130
+ * so the account tenants are still found;
131
+ * 3. delete the role's grants; rewrite each account's role list without it (edit stamp moved
132
+ * forward, so a concurrent preserve-merge edit conflicts instead of re-adding it);
133
+ * 4. audit as `role.permission_revoked` (`roleDeleted: true`), commit, publish the versions.
134
+ * Returns the bumped tenants.
135
+ *
136
+ * The CALLER (`RoleService.deleteRole`) holds the role row lock on its own engine connection and
137
+ * has already run the binding rule and user-holder dominance; this method therefore does NOT
138
+ * lock the role row (that would wait on the caller). It commits BEFORE the caller deletes the
139
+ * role, so a failure in between leaves a role with no grants and no agents: fewer permissions,
140
+ * never live grants for a deleted role (fail closed).
141
+ */
142
+ purgeRole(roleUuid: string, actor: Principal): Promise<string[]>;
120
143
  /** All active bundles + their resolved atomic keys, for the Access UI. */
121
144
  listBundles(): Promise<{
122
145
  bundles: BundleView[];
@@ -1 +1 @@
1
- {"version":3,"file":"grant-admin.service.d.ts","sourceRoot":"","sources":["../../../src/features/authz/grant-admin.service.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"grant-admin.service.d.ts","sourceRoot":"","sources":["../../../src/features/authz/grant-admin.service.ts"],"names":[],"mappings":"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"}
@@ -9,6 +9,24 @@ const role_binding_js_1 = require("./role-binding.js");
9
9
  const identity_errors_js_1 = require("../shared/identity-errors.js");
10
10
  const AUTHZ = 'shield_authz';
11
11
  const APP = 'shield_app';
12
+ /** The grant-admin actions that REMOVE authority from a role's holders (Phase 8 #2). */
13
+ const REVOKE_ACTIONS = new Set([
14
+ 'role.permission_revoked',
15
+ 'role.bundle_revoked',
16
+ ]);
17
+ /** A JSON-array column (the driver returns an array OR a string) → its string items; else []. */
18
+ function jsonIdArray(v) {
19
+ let arr = v;
20
+ if (typeof v === 'string') {
21
+ try {
22
+ arr = JSON.parse(v);
23
+ }
24
+ catch {
25
+ arr = [];
26
+ }
27
+ }
28
+ return Array.isArray(arr) ? arr.filter((x) => typeof x === 'string') : [];
29
+ }
12
30
  /** A granted key was not in the catalog and is not a recognized wildcard. The
13
31
  * controller maps this to HTTP 400 (distinct from a store outage → 503). */
14
32
  class BadKeyError extends Error {
@@ -133,6 +151,11 @@ class GrantAdminService {
133
151
  ]);
134
152
  if (this.ceiling && actor.isRoot !== true) {
135
153
  grant_ceiling_js_1.GrantCeiling.checkAdministrable(await (0, role_binding_js_1.readRoleBinding)(conn, roleUuid, (0, grant_ceiling_js_1.subtreeOf)(actor)));
154
+ // Phase 8 #2: a revocation strips authority from every holder of the role, so the actor
155
+ // must dominate each of them — checked under the role lock (no holder can appear unseen).
156
+ if (REVOKE_ACTIONS.has(action)) {
157
+ await this.ceiling.assertDominatesRoleHolders(actor, roleUuid);
158
+ }
136
159
  }
137
160
  for (const key of keys)
138
161
  await statement(conn, key);
@@ -230,7 +253,8 @@ class GrantAdminService {
230
253
  * Revoke a key from a role (no-op if absent) + bump + audit, atomically. Security hardening #1:
231
254
  * the actor must be able to administer the role (binding rule) and must hold the key itself —
232
255
  * "only revoke what you hold" — so a delegated admin can no longer strip a role of authority it
233
- * never had. Root bypasses.
256
+ * never had. Phase 8 #2: it must also dominate every user holding the role (checked under the
257
+ * role lock in `writeGrants`). Root bypasses.
234
258
  */
235
259
  async revoke(roleUuid, key, actor) {
236
260
  await this.assertMayChangeGrants(roleUuid, [key], actor);
@@ -238,6 +262,69 @@ class GrantAdminService {
238
262
  await conn.query(`DELETE FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid = ? AND rp_perm_key = ?`, [roleUuid, k]);
239
263
  });
240
264
  }
265
+ /**
266
+ * Remove a role that is being DELETED from authorization (Phase 8 #2 follow-up): every
267
+ * `role_permission` row of the role, and the role from every service account's `sa_role_ids`
268
+ * (guard's API-key store reads that column live, so a stale entry would keep the deleted role's
269
+ * keys on the agent). ONE transaction on the shared authz pool — the same pool, bump, audit and
270
+ * error mapping as {@link revoke}:
271
+ * 1. lock the holding accounts (`FOR UPDATE`), then check the actor may strip the role from
272
+ * each (`GrantCeiling.assertMayStripRoleFromServiceAccounts`, root bypasses);
273
+ * 2. bump every tenant using the role — postings AND the accounts' tenants — before the strip,
274
+ * so the account tenants are still found;
275
+ * 3. delete the role's grants; rewrite each account's role list without it (edit stamp moved
276
+ * forward, so a concurrent preserve-merge edit conflicts instead of re-adding it);
277
+ * 4. audit as `role.permission_revoked` (`roleDeleted: true`), commit, publish the versions.
278
+ * Returns the bumped tenants.
279
+ *
280
+ * The CALLER (`RoleService.deleteRole`) holds the role row lock on its own engine connection and
281
+ * has already run the binding rule and user-holder dominance; this method therefore does NOT
282
+ * lock the role row (that would wait on the caller). It commits BEFORE the caller deletes the
283
+ * role, so a failure in between leaves a role with no grants and no agents: fewer permissions,
284
+ * never live grants for a deleted role (fail closed).
285
+ */
286
+ async purgeRole(roleUuid, actor) {
287
+ const conn = await this.pool.getConnection();
288
+ try {
289
+ await conn.beginTransaction();
290
+ const accounts = (await conn.query(`SELECT sa_id, sa_tenant_id, sa_role_ids, sa_updated_at_ms
291
+ FROM \`${APP}\`.\`service_account\`
292
+ WHERE JSON_CONTAINS(sa_role_ids, JSON_QUOTE(?))
293
+ FOR UPDATE`, [roleUuid]));
294
+ if (this.ceiling && actor.isRoot !== true) {
295
+ await this.ceiling.assertMayStripRoleFromServiceAccounts(actor, roleUuid, accounts.map((a) => a.sa_tenant_id));
296
+ }
297
+ const keys = (await conn.query(`SELECT rp_perm_key FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid = ? ORDER BY rp_perm_key`, [roleUuid])).map((r) => r.rp_perm_key);
298
+ const bumped = await this.bumper.bumpForRole(roleUuid, conn);
299
+ await conn.query(`DELETE FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid = ?`, [
300
+ roleUuid,
301
+ ]);
302
+ const nowMs = Date.now();
303
+ for (const a of accounts) {
304
+ const remaining = jsonIdArray(a.sa_role_ids).filter((id) => id !== roleUuid);
305
+ const previous = a.sa_updated_at_ms === null ? 0 : Number(a.sa_updated_at_ms);
306
+ await conn.query(`UPDATE \`${APP}\`.\`service_account\` SET sa_role_ids = ?, sa_updated_at_ms = ? WHERE sa_id = ?`, [JSON.stringify(remaining), Math.max(nowMs, previous + 1), a.sa_id]);
307
+ }
308
+ await this.auditGrantChange(conn, 'role.permission_revoked', roleUuid, keys, actor, bumped, {
309
+ roleDeleted: true,
310
+ serviceAccountsUpdated: accounts.length,
311
+ });
312
+ await conn.commit();
313
+ await this.bumper.publishCommitted(conn);
314
+ return bumped;
315
+ }
316
+ catch (cause) {
317
+ await conn.rollback();
318
+ this.bumper.discardPending(conn);
319
+ if (cause instanceof identity_errors_js_1.IdentityForbiddenError || cause instanceof identity_errors_js_1.IdentityNotFoundError) {
320
+ throw cause;
321
+ }
322
+ throw new core_1.AuthorizationUnavailableError('role grant purge failed', cause);
323
+ }
324
+ finally {
325
+ await conn.release();
326
+ }
327
+ }
241
328
  // ── Access bundles ────────────────────────────────────────────────────────
242
329
  /** All active bundles + their resolved atomic keys, for the Access UI. */
243
330
  async listBundles() {
@@ -81,9 +81,18 @@ export declare class GrantCeiling {
81
81
  * Assert the actor may ASSIGN every role in `roleIds` (post someone to it): the roles' effective
82
82
  * keys (their `role_permission` rows, wildcards expanded against the registry) must all be held
83
83
  * by the actor — the same "only grant what you hold" ceiling, applied to role assignment (#11).
84
- * A role holding nothing assigns nothing and passes. Root bypasses. Store outage → 503.
84
+ * A role holding nothing assigns nothing and passes; a role key the registry does not know
85
+ * counts as not held (Phase 8 #4). Root bypasses. Store outage → 503.
85
86
  */
86
87
  assertCanAssignRoles(actor: Principal, roleIds: readonly string[]): Promise<void>;
88
+ /**
89
+ * The first of a TARGET's raw keys (a role's `role_permission` rows) the actor does not cover,
90
+ * or `undefined` when all are covered. Phase 8 #4: judged per RAW key with `coversKey`, so a key
91
+ * the registry does not know — an exact key that is not registered, or a wildcard expanding to
92
+ * nothing — counts as NOT held and denies. Expanding first would silently drop it and could
93
+ * pass vacuously (fail open) when this registry is narrower than the decision path's.
94
+ */
95
+ private firstUncovered;
87
96
  /**
88
97
  * Assert the actor DOMINATES the target user (#3, D2): every membership the target holds (in any
89
98
  * tenant, active or not) is inside the actor's subtree, AND every key the target could hold
@@ -92,6 +101,45 @@ export declare class GrantCeiling {
92
101
  * Root actors bypass. Store outage → 503.
93
102
  */
94
103
  assertDominates(actor: Principal, targetUserId: string): Promise<void>;
104
+ /**
105
+ * Assert the actor DOMINATES every user who holds the role (Phase 8 #2): deleting a role, or
106
+ * revoking a key from it, strips authority from each holder, so it needs the same
107
+ * {@link assertDominates} the actor would need to change any one of them directly. Holders are
108
+ * the users with a posting of the role in any membership and any window (potential authority).
109
+ * The actor itself is skipped: giving up your own authority stays allowed (self-demotion).
110
+ * Service-account holders: a revoke is covered by the binding rule (`assertRoleAdministrable`:
111
+ * no binding outside the actor's subtree) plus "only revoke what you hold"; a role DELETE also
112
+ * strips the role from each account and checks {@link assertMayStripRoleFromServiceAccounts}.
113
+ * Root bypasses. Store outage → 503.
114
+ *
115
+ * Callers serialise against a concurrent posting by locking the role row first (the posting
116
+ * path locks the same row before it inserts), then call this — so no holder appears unseen.
117
+ */
118
+ assertDominatesRoleHolders(actor: Principal, roleUuid: string): Promise<void>;
119
+ /**
120
+ * Assert the actor may REMOVE `roleUuid` from every service account holding it (Phase 8 #2
121
+ * follow-up: a role delete strips the role from each holding account). Judged by the SAME rules
122
+ * as editing the account directly (`ServiceAccountService.update`, ADR-0029 ceiling via
123
+ * {@link ceilingOf}): each account's tenant is inside the actor's subtree, and the role is one
124
+ * the actor holds itself — a non-root admin's edit preserves (cannot strip) a role it does not
125
+ * hold. `accountTenantIds` are the holding accounts' tenants, read by the caller under a row lock.
126
+ * Root bypasses. Refusals name no account. Store outage → 503.
127
+ */
128
+ assertMayStripRoleFromServiceAccounts(actor: Principal, roleUuid: string, accountTenantIds: readonly string[]): Promise<void>;
129
+ /**
130
+ * Assert the actor may RETIRE (disable + revoke the keys of) each of these service accounts
131
+ * (Phase 8 #3: a company delete retires the deleted company's own agents). Judged by the SAME
132
+ * ADR-0029 ceiling an admin needs to have made the agent ({@link ceilingOf}): the account's
133
+ * tenant and every node of its scope are inside the actor's subtree, and every role it holds is
134
+ * one the actor holds itself — so a sub-admin can never switch off an agent more powerful than
135
+ * itself by deleting its company. `accounts` are read by the caller under a row lock. Root
136
+ * bypasses. Refusals name no account. Store outage → 503.
137
+ */
138
+ assertMayRetireServiceAccounts(actor: Principal, accounts: ReadonlyArray<{
139
+ readonly tenantId: string;
140
+ readonly scopeSubtreeNodeIds: readonly string[];
141
+ readonly roleIds: readonly string[];
142
+ }>): Promise<void>;
95
143
  /** Where `roleUuid` is bound relative to the actor's subtree (postings + service accounts). */
96
144
  roleBinding(actor: Principal, roleUuid: string): Promise<RoleBinding>;
97
145
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"grant-ceiling.d.ts","sourceRoot":"","sources":["../../../src/features/authz/grant-ceiling.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAG9D,OAAO,EAAmB,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAMtE,6FAA6F;AAC7F,qBAAa,iBAAkB,SAAQ,KAAK;gBAC9B,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM;CAI1C;AAED,sGAAsG;AACtG,wBAAgB,SAAS,CAAC,KAAK,EAAE,SAAS,GAAG,SAAS,MAAM,EAAE,CAE7D;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CACvB,SAAS,EAAE,WAAW,CAAC,MAAM,CAAC,EAC9B,cAAc,EAAE,SAAS,MAAM,EAAE,EACjC,OAAO,EAAE,MAAM,GACd,OAAO,CAST;AAED;;;GAGG;AACH,qBAAa,YAAY;IAIrB,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAQzB,+FAA+F;IAC/F,OAAO,CAAC,QAAQ,CAAC,GAAG;IAbtB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAoB;gBAGzB,IAAI,EAAE,IAAI,EACV,QAAQ,EAAE,cAAc;IACzC;;;;;OAKG;IACH,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI;IACjC,+FAA+F;IAC9E,GAAG,GAAE,MAAM,MAAiB;IAU/C,+FAA+F;IAC/F,MAAM,CAAC,kBAAkB,CAAC,CAAC,EAAE,WAAW,GAAG,IAAI;IAS/C,qFAAqF;IACrF,MAAM,CAAC,eAAe,CAAC,CAAC,EAAE,WAAW,GAAG,IAAI;IAQ5C,oEAAoE;IACpE,OAAO,CAAC,cAAc;IAItB,+FAA+F;YACjF,cAAc;IAU5B,+FAA+F;YACjF,KAAK;IASnB;;;;;OAKG;IACG,aAAa,CAAC,OAAO,EAAE,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IAkB7D;;;;OAIG;IACG,cAAc,CAAC,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAahF;;;;;OAKG;IACG,oBAAoB,CAAC,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAkBvF;;;;;;OAMG;IACG,eAAe,CAAC,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAwD5E,+FAA+F;IACzF,WAAW,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;IAI3E;;;;;OAKG;IACG,uBAAuB,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKhF;;;;OAIG;IACG,oBAAoB,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAI9E"}
1
+ {"version":3,"file":"grant-ceiling.d.ts","sourceRoot":"","sources":["../../../src/features/authz/grant-ceiling.ts"],"names":[],"mappings":"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"}
@@ -7,6 +7,7 @@ const core_1 = require("@astram/core");
7
7
  const db_permission_facts_adapter_js_1 = require("./db-permission-facts.adapter.js");
8
8
  const role_binding_js_1 = require("./role-binding.js");
9
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");
10
11
  const AUTHZ = 'shield_authz';
11
12
  const APP = 'shield_app';
12
13
  /** The grantor exceeds their own authority — cannot grant a key they do not hold (→ 403). */
@@ -152,7 +153,8 @@ class GrantCeiling {
152
153
  * Assert the actor may ASSIGN every role in `roleIds` (post someone to it): the roles' effective
153
154
  * keys (their `role_permission` rows, wildcards expanded against the registry) must all be held
154
155
  * by the actor — the same "only grant what you hold" ceiling, applied to role assignment (#11).
155
- * A role holding nothing assigns nothing and passes. Root bypasses. Store outage → 503.
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.
156
158
  */
157
159
  async assertCanAssignRoles(actor, roleIds) {
158
160
  if (actor.isRoot === true)
@@ -161,16 +163,25 @@ class GrantCeiling {
161
163
  if (ids.length === 0)
162
164
  return;
163
165
  const raw = await this.facts('role grants', () => this.rawKeysOfRoles(ids));
164
- const roleKeys = (0, db_permission_facts_adapter_js_1.expandGrantKeys)(raw, this.registeredKeys());
165
- if (roleKeys.length === 0)
166
+ if (raw.length === 0)
166
167
  return;
167
168
  const effective = await this.effectiveKeys(actor);
168
- for (const key of roleKeys) {
169
- if (!effective.has(key)) {
170
- throw new GrantCeilingError(key, `You cannot assign this role — it grants '${key}', which exceeds the permissions you hold.`);
171
- }
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
172
  }
173
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
+ }
174
185
  /**
175
186
  * Assert the actor DOMINATES the target user (#3, D2): every membership the target holds (in any
176
187
  * tenant, active or not) is inside the actor's subtree, AND every key the target could hold
@@ -211,14 +222,111 @@ class GrantCeiling {
211
222
  throw new identity_errors_js_1.IdentityForbiddenError('You cannot change this user: they also belong to an organisation outside the ones you manage.');
212
223
  }
213
224
  const raw = await this.facts('target grants', () => this.rawKeysOfRoles(roleIds));
214
- const targetKeys = (0, db_permission_facts_adapter_js_1.expandGrantKeys)(raw, this.registeredKeys());
215
- if (targetKeys.length === 0)
225
+ if (raw.length === 0)
216
226
  return;
217
227
  const effective = await this.effectiveKeys(actor);
218
- if (targetKeys.some((k) => !effective.has(k))) {
228
+ // Phase 8 #4: an unknown target key counts as not held (see `firstUncovered`).
229
+ if (this.firstUncovered(effective, raw) !== undefined) {
219
230
  throw new identity_errors_js_1.IdentityForbiddenError('You cannot change this user: they hold permissions you do not hold.');
220
231
  }
221
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
+ }
222
330
  /** Where `roleUuid` is bound relative to the actor's subtree (postings + service accounts). */
223
331
  async roleBinding(actor, roleUuid) {
224
332
  return this.facts('role binding', () => (0, role_binding_js_1.readRoleBinding)(this.pool, roleUuid, subtreeOf(actor)));
@@ -71,6 +71,13 @@ export declare class PermissionVersionBumper {
71
71
  * so both roll back together).
72
72
  */
73
73
  bumpForRole(roleUuid: string, conn: Queryable): Promise<string[]>;
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[]>;
74
81
  /**
75
82
  * Increment the permission version for EVERY tenant where the user has a membership (any status)
76
83
  * — for a change to the USER itself that alters its effective grants everywhere (security
@@ -1 +1 @@
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;IA2BvE;;;;;;OAMG;IACG,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAgBvE;;;;;;OAMG;YACW,UAAU;CA4BzB"}
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"}
@@ -82,16 +82,7 @@ class PermissionVersionBumper {
82
82
  */
83
83
  async bumpForRole(roleUuid, conn) {
84
84
  try {
85
- const rows = (await conn.query(`SELECT DISTINCT cm.cm_company_uuid AS tenant
86
- FROM \`${APP}\`.\`company_membership\` cm
87
- JOIN \`${APP}\`.\`membership_posting\` mp ON mp.mp_membership_uuid = cm.cm_uuid
88
- WHERE mp.mp_role_uuid = ? AND cm.cm_is_active = 1`, [roleUuid]));
89
- // Service accounts (ADR-0033) hold roles on `sa_role_ids`, not postings: a role held only by
90
- // machine principals must still invalidate their tenants' cached grants (security hardening).
91
- const saRows = (await conn.query(`SELECT DISTINCT sa.sa_tenant_id AS tenant
92
- FROM \`${APP}\`.\`service_account\` sa
93
- WHERE JSON_CONTAINS(sa.sa_role_ids, JSON_QUOTE(?))`, [roleUuid]));
94
- const tenants = [...new Set([...rows, ...saRows].map((r) => r.tenant))];
85
+ const tenants = await this.tenantsForRole(roleUuid, conn);
95
86
  for (const tenant of tenants) {
96
87
  await this.bumpTenant(tenant, conn);
97
88
  }
@@ -101,6 +92,24 @@ class PermissionVersionBumper {
101
92
  throw new core_1.AuthorizationUnavailableError('permission version bump failed', cause);
102
93
  }
103
94
  }
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
+ }
104
113
  /**
105
114
  * Increment the permission version for EVERY tenant where the user has a membership (any status)
106
115
  * — for a change to the USER itself that alters its effective grants everywhere (security
@@ -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,EAGL,KAAK,yBAAyB,EAC/B,MAAM,eAAe,CAAC;AACvB,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"}
@@ -3,8 +3,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.IdentityRegistryMismatchError = void 0;
6
7
  exports.createIdentityAuthzPool = createIdentityAuthzPool;
7
8
  exports.buildIdentityRecordAuthorization = buildIdentityRecordAuthorization;
9
+ exports.buildRecordAuthorizationInputs = buildRecordAuthorizationInputs;
10
+ exports.noteIdentityCeilingRegistry = noteIdentityCeilingRegistry;
8
11
  /**
9
12
  * Assemble the record-tier authorization wiring for `@astram/identity`: the lean grant-read
10
13
  * pool factory + the `recordAuthorization` inputs the consumer passes to
@@ -56,6 +59,15 @@ function createIdentityAuthzPool() {
56
59
  * those keys now project, and the framework still owns the four record-tier adapters + scope fn.
57
60
  */
58
61
  function buildIdentityRecordAuthorization(pool, extraObjects = []) {
62
+ const inputs = buildRecordAuthorizationInputs(pool, extraObjects);
63
+ noteRegistry('decision', inputs.registry);
64
+ return inputs;
65
+ }
66
+ /**
67
+ * The inputs builder WITHOUT the boot parity record — for the identity module's own internal
68
+ * authorities (they are not the decision path the consumer hands `ShieldModule`).
69
+ */
70
+ function buildRecordAuthorizationInputs(pool, extraObjects = []) {
59
71
  const registry = (0, guard_1.buildIdentityModuleRegistry)([...objects_js_1.identityObjects, ...extraObjects]);
60
72
  return {
61
73
  pool,
@@ -65,3 +77,40 @@ function buildIdentityRecordAuthorization(pool, extraObjects = []) {
65
77
  operationManifest: [],
66
78
  };
67
79
  }
80
+ const recordedKeySets = {
81
+ decision: new Map(),
82
+ ceiling: new Map(),
83
+ };
84
+ /** The boot error: the two registries' action-key sets differ (names the first differing keys). */
85
+ class IdentityRegistryMismatchError extends Error {
86
+ constructor(onlyDecision, onlyCeiling) {
87
+ const sample = (keys) => keys.length === 0 ? 'none' : keys.slice(0, 10).join(', ') + (keys.length > 10 ? ', …' : '');
88
+ super('IdentityFeatureModule.forRoot({ extraObjects }) and buildIdentityRecordAuthorization(pool, ' +
89
+ 'extraObjects) were given different object types. Pass the SAME array to both. ' +
90
+ `Only in the decision path: ${sample(onlyDecision)}. ` +
91
+ `Only in IdentityFeatureModule: ${sample(onlyCeiling)}.`);
92
+ this.name = 'IdentityRegistryMismatchError';
93
+ }
94
+ }
95
+ exports.IdentityRegistryMismatchError = IdentityRegistryMismatchError;
96
+ /** Record one side's registry and throw if it disagrees with any recorded registry of the other. */
97
+ function noteRegistry(role, registry) {
98
+ const keys = [...new Set(registry.listActions().map((a) => a.permissionKey))].sort();
99
+ const other = recordedKeySets[role === 'decision' ? 'ceiling' : 'decision'];
100
+ for (const otherKeys of other.values()) {
101
+ const mine = new Set(keys);
102
+ const theirs = new Set(otherKeys);
103
+ const onlyMine = keys.filter((k) => !theirs.has(k));
104
+ const onlyTheirs = otherKeys.filter((k) => !mine.has(k));
105
+ if (onlyMine.length > 0 || onlyTheirs.length > 0) {
106
+ throw role === 'decision'
107
+ ? new IdentityRegistryMismatchError(onlyMine, onlyTheirs)
108
+ : new IdentityRegistryMismatchError(onlyTheirs, onlyMine);
109
+ }
110
+ }
111
+ recordedKeySets[role].set(keys.join('\n'), keys);
112
+ }
113
+ /** Called by `IdentityFeatureModule.forRoot` with the grant ceiling's registry (Phase 8 #4). */
114
+ function noteIdentityCeilingRegistry(registry) {
115
+ noteRegistry('ceiling', registry);
116
+ }