@astram/identity 0.5.0 → 0.7.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 (61) hide show
  1. package/README.md +15 -0
  2. package/dist/bin/sa-keys-report.d.ts +3 -0
  3. package/dist/bin/sa-keys-report.d.ts.map +1 -0
  4. package/dist/bin/sa-keys-report.js +18 -0
  5. package/dist/features/authz/grant-admin.service.d.ts +33 -1
  6. package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
  7. package/dist/features/authz/grant-admin.service.js +105 -1
  8. package/dist/features/authz/grant-ceiling.d.ts +62 -1
  9. package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
  10. package/dist/features/authz/grant-ceiling.js +145 -10
  11. package/dist/features/authz/permission-version.bumper.d.ts +7 -0
  12. package/dist/features/authz/permission-version.bumper.d.ts.map +1 -1
  13. package/dist/features/authz/permission-version.bumper.js +19 -10
  14. package/dist/features/authz/record-authorization.d.ts +16 -21
  15. package/dist/features/authz/record-authorization.d.ts.map +1 -1
  16. package/dist/features/authz/record-authorization.js +17 -22
  17. package/dist/features/company/company-hierarchy.service.d.ts +71 -4
  18. package/dist/features/company/company-hierarchy.service.d.ts.map +1 -1
  19. package/dist/features/company/company-hierarchy.service.js +197 -8
  20. package/dist/features/company/company.controller.d.ts +1 -1
  21. package/dist/features/company/company.controller.d.ts.map +1 -1
  22. package/dist/features/company/company.controller.js +9 -5
  23. package/dist/features/company/company.service.d.ts +9 -2
  24. package/dist/features/company/company.service.d.ts.map +1 -1
  25. package/dist/features/company/company.service.js +17 -2
  26. package/dist/features/identity-feature.module.d.ts +15 -6
  27. package/dist/features/identity-feature.module.d.ts.map +1 -1
  28. package/dist/features/identity-feature.module.js +79 -22
  29. package/dist/features/membership/posting-version-bump.d.ts +7 -0
  30. package/dist/features/membership/posting-version-bump.d.ts.map +1 -1
  31. package/dist/features/membership/posting.service.d.ts +2 -0
  32. package/dist/features/membership/posting.service.d.ts.map +1 -1
  33. package/dist/features/membership/posting.service.js +9 -0
  34. package/dist/features/role/role.service.d.ts +32 -2
  35. package/dist/features/role/role.service.d.ts.map +1 -1
  36. package/dist/features/role/role.service.js +64 -4
  37. package/dist/features/scope/scope-closure.service.d.ts +11 -1
  38. package/dist/features/scope/scope-closure.service.d.ts.map +1 -1
  39. package/dist/features/scope/scope-closure.service.js +23 -4
  40. package/dist/features/service-account/lock-service-accounts.d.ts +29 -0
  41. package/dist/features/service-account/lock-service-accounts.d.ts.map +1 -0
  42. package/dist/features/service-account/lock-service-accounts.js +35 -0
  43. package/dist/features/service-account/no-expiry-key-report.d.ts +39 -0
  44. package/dist/features/service-account/no-expiry-key-report.d.ts.map +1 -0
  45. package/dist/features/service-account/no-expiry-key-report.js +91 -0
  46. package/dist/features/service-account/service-account.service.d.ts +15 -0
  47. package/dist/features/service-account/service-account.service.d.ts.map +1 -1
  48. package/dist/features/service-account/service-account.service.js +17 -1
  49. package/dist/features/service-account/service-account.store.d.ts +15 -0
  50. package/dist/features/service-account/service-account.store.d.ts.map +1 -1
  51. package/dist/features/service-account/service-account.store.js +29 -0
  52. package/dist/features/user/user.service.d.ts +4 -4
  53. package/dist/features/user/user.service.d.ts.map +1 -1
  54. package/dist/features/user/user.service.js +9 -6
  55. package/dist/index.d.ts +1 -9
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +8 -11
  58. package/dist/internal.d.ts +11 -0
  59. package/dist/internal.d.ts.map +1 -0
  60. package/dist/internal.js +14 -0
  61. package/package.json +18 -4
@@ -1,5 +1,13 @@
1
1
  import { type DynamicModule } from '@nestjs/common';
2
2
  import { type ObjectTypeInput } from '@astram/engine';
3
+ /**
4
+ * The boot check behind {@link IDENTITY_DECISION_AUTHORITY_CHECK}: with this module wired, the
5
+ * request path must decide with the authority guard built from this module's inputs. A missing
6
+ * authority (no `ShieldModule`) or the deny-everything stub (a guard that does not read
7
+ * `SHIELD_RECORD_AUTHORIZATION`) fails the boot instead of silently denying, or deciding with a
8
+ * different registry. (Another source next to this one is refused by `ShieldModule` itself.)
9
+ */
10
+ export declare function assertRequestPathUsesIdentityAuthorization(authority: unknown): true;
3
11
  /**
4
12
  * IdentityFeatureModule — the framework-owned identity FEATURE surface (company/user/role CRUD;
5
13
  * more in later phases) as thin controllers over the engine's RecordsService. The CONSUMER imports
@@ -19,12 +27,13 @@ export declare class IdentityFeatureModule {
19
27
  * by the writer's own `decide()` (a route-allows/writer-denies split). Omitted ⇒
20
28
  * `isRoot` is always false (a non-root consumer works; only a root offering
21
29
  * cross-tenant grants needs it). See ADR-0026 + the template wiring.
22
- * @param extraObjects the consumer's OWN registered object types — pass the SAME array
23
- * given to `buildIdentityRecordAuthorization(pool, extraObjects)`. Without them, the
24
- * guided rule-builder SCHEMA (Phase 5) and the OWNER-SHARE resolver (Phase 6) only know
25
- * the framework identity types, so a consumer's own OWNED type (e.g. `note`) is NOT
26
- * policy-authorable and NOT shareable ("type '…' is not registered — cannot share").
27
- * Passing them here lets an owner share their own records + author policies over them.
30
+ * @param extraObjects the consumer's OWN registered object types (the ones it registers with
31
+ * `EngineModule.forFeature`). The ONE place they are given (Phase 9 #7): the module builds
32
+ * the record authorization from `[...identityObjects, ...extraObjects]` and provides it to
33
+ * `ShieldModule` (request path, gate-5 key projection), and uses the same registry for its
34
+ * cross-tenant / policy authority, grant ceiling, guided rule-builder schema and owner-share
35
+ * resolver. Without them a consumer type is unregistered everywhere (denied), not policy-
36
+ * authorable and not shareable ("type '…' is not registered — cannot share").
28
37
  */
29
38
  static forRoot({ rootIds, extraObjects, }?: {
30
39
  rootIds?: readonly string[];
@@ -1 +1 @@
1
- {"version":3,"file":"identity-feature.module.d.ts","sourceRoot":"","sources":["../../src/features/identity-feature.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpE,OAAO,EAYL,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AA6FxB;;;;;;;;;;GAUG;AACH,qBACa,qBAAqB;IAChC;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,OAAO,CAAC,EACb,OAAO,EACP,YAAiB,GAClB,GAAE;QACD,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;QAC5B,YAAY,CAAC,EAAE,SAAS,eAAe,EAAE,CAAC;KACtC,GAAG,aAAa;CAscvB"}
1
+ {"version":3,"file":"identity-feature.module.d.ts","sourceRoot":"","sources":["../../src/features/identity-feature.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpE,OAAO,EAYL,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AA6FxB;;;;;;GAMG;AACH,wBAAgB,0CAA0C,CAAC,SAAS,EAAE,OAAO,GAAG,IAAI,CAUnF;AAiBD;;;;;;;;;;GAUG;AACH,qBACa,qBAAqB;IAChC;;;;;;;;;;;;;;OAcG;IACH,MAAM,CAAC,OAAO,CAAC,EACb,OAAO,EACP,YAAiB,GAClB,GAAE;QACD,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;QAC5B,YAAY,CAAC,EAAE,SAAS,eAAe,EAAE,CAAC;KACtC,GAAG,aAAa;CA8dvB"}
@@ -8,6 +8,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
8
8
  var IdentityFeatureModule_1;
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
10
  exports.IdentityFeatureModule = void 0;
11
+ exports.assertRequestPathUsesIdentityAuthorization = assertRequestPathUsesIdentityAuthorization;
11
12
  const common_1 = require("@nestjs/common");
12
13
  const core_1 = require("@nestjs/core");
13
14
  const engine_1 = require("@astram/engine");
@@ -58,6 +59,35 @@ const grant_authorize_js_1 = require("./cross-tenant/grant-authorize.js");
58
59
  const IDENTITY_VERSION_PUBLISHER_LINK = Symbol('IDENTITY_VERSION_PUBLISHER_LINK');
59
60
  /** DI token of the boot check that warns when a user disable cannot revoke sessions. */
60
61
  const IDENTITY_SESSION_REVOCATION_CHECK = Symbol('IDENTITY_SESSION_REVOCATION_CHECK');
62
+ /** DI token of the boot check that the request path decides with this module's authorization. */
63
+ const IDENTITY_DECISION_AUTHORITY_CHECK = Symbol('IDENTITY_DECISION_AUTHORITY_CHECK');
64
+ /**
65
+ * Holds the record-authorization inputs `IdentityFeatureModule.forRoot` builds, provided GLOBALLY
66
+ * under guard's `SHIELD_RECORD_AUTHORIZATION` so `ShieldModule` (a sibling, which never imports
67
+ * identity) builds the request-path decision authority from them (Phase 9 #7). Only this token is
68
+ * global; the feature module's own providers stay module-scoped.
69
+ */
70
+ let IdentityRecordAuthorizationModule = class IdentityRecordAuthorizationModule {
71
+ };
72
+ IdentityRecordAuthorizationModule = __decorate([
73
+ (0, common_1.Module)({})
74
+ ], IdentityRecordAuthorizationModule);
75
+ /**
76
+ * The boot check behind {@link IDENTITY_DECISION_AUTHORITY_CHECK}: with this module wired, the
77
+ * request path must decide with the authority guard built from this module's inputs. A missing
78
+ * authority (no `ShieldModule`) or the deny-everything stub (a guard that does not read
79
+ * `SHIELD_RECORD_AUTHORIZATION`) fails the boot instead of silently denying, or deciding with a
80
+ * different registry. (Another source next to this one is refused by `ShieldModule` itself.)
81
+ */
82
+ function assertRequestPathUsesIdentityAuthorization(authority) {
83
+ if (authority === undefined || authority === null || authority === guard_1.stubDecisionAuthority) {
84
+ throw new Error('IdentityFeatureModule: the request path has no decision authority built from the identity ' +
85
+ 'record authorization. Import ShieldModule.forRoot(...) from @astram/guard >= 0.10 ' +
86
+ 'without `recordAuthorization`/`decisionAuthority`: it builds the authority from the ' +
87
+ 'inputs IdentityFeatureModule provides (SHIELD_RECORD_AUTHORIZATION).');
88
+ }
89
+ return true;
90
+ }
61
91
  /** Map guard's env DbConfig → the engine's EngineDbConfig (adds the pool cap the engine needs). */
62
92
  function engineDbFromEnv() {
63
93
  const db = (0, guard_1.loadDbConfig)();
@@ -91,28 +121,39 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
91
121
  * by the writer's own `decide()` (a route-allows/writer-denies split). Omitted ⇒
92
122
  * `isRoot` is always false (a non-root consumer works; only a root offering
93
123
  * cross-tenant grants needs it). See ADR-0026 + the template wiring.
94
- * @param extraObjects the consumer's OWN registered object types — pass the SAME array
95
- * given to `buildIdentityRecordAuthorization(pool, extraObjects)`. Without them, the
96
- * guided rule-builder SCHEMA (Phase 5) and the OWNER-SHARE resolver (Phase 6) only know
97
- * the framework identity types, so a consumer's own OWNED type (e.g. `note`) is NOT
98
- * policy-authorable and NOT shareable ("type '…' is not registered — cannot share").
99
- * Passing them here lets an owner share their own records + author policies over them.
124
+ * @param extraObjects the consumer's OWN registered object types (the ones it registers with
125
+ * `EngineModule.forFeature`). The ONE place they are given (Phase 9 #7): the module builds
126
+ * the record authorization from `[...identityObjects, ...extraObjects]` and provides it to
127
+ * `ShieldModule` (request path, gate-5 key projection), and uses the same registry for its
128
+ * cross-tenant / policy authority, grant ceiling, guided rule-builder schema and owner-share
129
+ * resolver. Without them a consumer type is unregistered everywhere (denied), not policy-
130
+ * authorable and not shareable ("type '…' is not registered — cannot share").
100
131
  */
101
132
  static forRoot({ rootIds, extraObjects = [], } = {}) {
102
133
  // ONE shared shield_authz pool for the whole grant-admin stack (ADR-0011): the
103
134
  // permission-version bumper, the AccessBundleStore, the GrantAdminService, and the two
104
135
  // reconcile jobs all run over this single pool (don't open several). Opened once at
105
- // forRoot() (not import time) — mirrors the pre-existing bump-pool pattern.
136
+ // forRoot() (not import time) — mirrors the pre-existing bump-pool pattern. Since Phase 9 #7
137
+ // it also backs the request path's decision authority (provided to ShieldModule below).
106
138
  const authzPool = (0, record_authorization_js_1.createIdentityAuthzPool)();
139
+ // The ONE record authorization (Phase 9 #7, closes KI #55/#57): built here from this module's
140
+ // own `extraObjects`, provided to ShieldModule under SHIELD_RECORD_AUTHORIZATION, and reused for
141
+ // every internal authority below. Its registry is the ONE key universe: the grant ceiling sees
142
+ // every key the request path grants (review M5 / Phase 8 #4), and a consumer type is
143
+ // registered for the cross-tenant writer and the policy author too.
144
+ const recordAuthorization = (0, record_authorization_js_1.buildRecordAuthorizationInputs)(authzPool, extraObjects);
145
+ const ceilingRegistry = recordAuthorization.registry;
107
146
  const bumper = new permission_version_bumper_js_1.PermissionVersionBumper();
108
147
  const postingVersionBump = {
109
148
  bumpTenant: (tenantUuid) => bumper.bumpForTenant(tenantUuid, authzPool),
110
149
  // A user disabled/re-enabled: every membership tenant (autocommit pool → published at once).
111
150
  bumpForUser: (userUuid) => bumper.bumpForUser(userUuid, authzPool),
151
+ // A role delete (Phase 8 #2): its holder tenants, read before the delete cascades postings.
152
+ tenantsForRole: (roleUuid) => bumper.tenantsForRole(roleUuid, authzPool),
112
153
  };
113
154
  // The core ModuleRegistry the catalog reconcile job needs, built from the identity
114
- // object registrations (guard's bridge reflects decorated classes → registry). Built BEFORE
115
- // the grant-admin service so the GrantCeiling (ADR-0029) can share it as the key universe.
155
+ // object registrations only (guard's bridge reflects decorated classes → registry): it
156
+ // catalogs the identity permissions. Authorities use the shared `recordAuthorization.registry`.
116
157
  const identityRegistry = (0, guard_1.buildIdentityModuleRegistry)([...objects_js_1.identityObjects]);
117
158
  // The grant-admin collaborators built at forRoot() over the shared pool (mirrors the
118
159
  // postingVersionBump closure pattern above). GrantCeiling (ADR-0029) enforces the
@@ -120,12 +161,8 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
120
161
  // resolved over the SAME authz pool + registry the object-type gate uses (one source of
121
162
  // truth for "what a principal holds"). ALWAYS wired here, so production always enforces.
122
163
  const accessBundleStore = new access_bundle_store_js_1.AccessBundleStore(authzPool);
123
- // The ceiling's key universe includes the consumer's `extraObjects` (the SAME registry shape
124
- // `buildIdentityRecordAuthorization(pool, extraObjects)` gives the decision path): otherwise a
125
- // role holding a consumer-type key could never be assigned or granted by a non-root admin who
126
- // holds that key (review M5) — the security-hardening role-assignment ceiling makes that
127
- // reachable from every posting edit. `rootIds` marks platform roots as never dominated.
128
- const ceilingRegistry = (0, guard_1.buildIdentityModuleRegistry)([...objects_js_1.identityObjects, ...extraObjects]);
164
+ // (`ceilingRegistry` is the shared record-authorization registry built at the top of forRoot.)
165
+ // `rootIds` marks platform roots as never dominated.
129
166
  // rootIds is passed EXPLICITLY (review F5): `null` states "not configured". The dominance
130
167
  // check still refuses any user flagged `u_is_platform_superuser`, so an unconfigured list
131
168
  // never lets a sub-admin take over a platform administrator.
@@ -146,7 +183,8 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
146
183
  const scopeClosureService = new scope_closure_service_js_1.ScopeClosureService(authzPool, bumper);
147
184
  // Company hierarchy (security hardening #2): the ONLY path that moves/deletes/attaches a company
148
185
  // node — through the closure service above — audited on the move/delete transaction.
149
- const companyHierarchyService = new company_hierarchy_service_js_1.CompanyHierarchyService(authzPool, scopeClosureService, new engine_1.MariaDbAuditSink({}));
186
+ const companyHierarchyService = new company_hierarchy_service_js_1.CompanyHierarchyService(authzPool, scopeClosureService, grantCeiling, // Phase 8 #3: a company delete must dominate every member
187
+ new engine_1.MariaDbAuditSink({}));
150
188
  // Audit viewer (Phase 5): the AuditReader + LabelResolver over the SAME shared authzPool
151
189
  // (the reader queries shield_audit + the resolver/actor-lookup query shield_app, both
152
190
  // reachable by that pool). `auditNameToId` is the actor-name -> uuid reverse lookup for the
@@ -165,7 +203,8 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
165
203
  // authz.module, the anti-pattern this replaces). A SECOND DecisionAuthority is built over the
166
204
  // same record-authorization inputs (matches astram3): the route guard's own decide() is the
167
205
  // first; this one backs the writer's server-side re-verification.
168
- const crossTenantAuthority = (0, guard_1.buildRecordAuthorization)((0, record_authorization_js_1.buildIdentityRecordAuthorization)(authzPool));
206
+ // Same inputs as the request path (KI #57): a consumer type is registered here too.
207
+ const crossTenantAuthority = (0, guard_1.buildRecordAuthorization)(recordAuthorization);
169
208
  // Record-level LIST SCOPE (Task 3.1, Invariant #8): the engine-facing fn the engine's
170
209
  // `list()` ANDs into BOTH the data SELECT and the COUNT — the in-query visible-set filter.
171
210
  // Built HERE, over the SAME shared authzPool (guard constructs the un-overridable scope
@@ -173,7 +212,7 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
173
212
  // out-of-the-box into `EngineModule.forRoot({ listScope })` below (ADR-0003 injection seam —
174
213
  // the engine never imports guard). Fail-closed: a store outage propagates and `list()` fails
175
214
  // rather than returning unscoped rows.
176
- const listScope = (0, guard_1.buildListScopeFn)((0, record_authorization_js_1.buildIdentityRecordAuthorization)(authzPool));
215
+ const listScope = (0, guard_1.buildListScopeFn)(recordAuthorization);
177
216
  // The writer's `GrantAuthorizeFn`: decide()-backed, fail-closed (no try/catch — a throw
178
217
  // propagates and the writer maps it to a retryable 503). isRoot ← the SAME rootIds the
179
218
  // consumer passes to ShieldModule.forRoot (root-correctness for control-plane grants).
@@ -227,11 +266,12 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
227
266
  audit: grantAudit,
228
267
  });
229
268
  // The read side (inbox/mine/incoming/offerable-types) over the same pool + the merged object
230
- // set (offerable = tenantScoped types only) + the per-type action vocabulary.
231
- const crossTenantGrantReadService = new cross_tenant_grant_read_service_js_1.CrossTenantGrantReadService(authzPool, [...objects_js_1.identityObjects], guard_1.actionsFor);
269
+ // set (offerable = tenantScoped types only, the consumer's included, KI #57) + the per-type
270
+ // action vocabulary.
271
+ const crossTenantGrantReadService = new cross_tenant_grant_read_service_js_1.CrossTenantGrantReadService(authzPool, [...objects_js_1.identityObjects, ...extraObjects], guard_1.actionsFor);
232
272
  // ── GUIDED RULE-BUILDER (list-scoping engine Phase 5, §9.1) ──────────────────────────────
233
273
  // The policy-authoring surface over the guard `MariaDbPolicyStore` + core `makeAuthorPolicy`.
234
- // Reuses the SAME `identityRegistry` + `crossTenantAuthority` (a DecisionAuthority over the
274
+ // Reuses the SAME shared registry + `crossTenantAuthority` (a DecisionAuthority over the
235
275
  // shared authzPool — the delegated-admin ceiling's decide()) as the cross-tenant writer. The
236
276
  // core AuditSink is one-arg (`record(event)`); the engine `MariaDbAuditSink` is two-arg
237
277
  // (`record(event, query)`) — so bind the query to the shared authzPool (mirrors the
@@ -265,7 +305,9 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
265
305
  };
266
306
  });
267
307
  const policyService = new policy_service_js_1.PolicyService({
268
- registry: identityRegistry,
308
+ // The shared registry (KI #57): the schema below already offers the consumer's types, so
309
+ // the author must know their actions too.
310
+ registry: recordAuthorization.registry,
269
311
  policyStore,
270
312
  decide: crossTenantAuthority,
271
313
  audit: policyAudit,
@@ -391,6 +433,14 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
391
433
  // NestJS DiscoveryService/MetadataScanner — the boot bootstrap provider reflects the
392
434
  // live controllers through this to build the OperationInventory.
393
435
  core_1.DiscoveryModule,
436
+ // Phase 9 #7: the ONE record authorization, global so ShieldModule's decision-authority
437
+ // factory reads it (guard never imports identity; the token is the seam).
438
+ {
439
+ module: IdentityRecordAuthorizationModule,
440
+ global: true,
441
+ providers: [{ provide: guard_1.SHIELD_RECORD_AUTHORIZATION, useValue: recordAuthorization }],
442
+ exports: [guard_1.SHIELD_RECORD_AUTHORIZATION],
443
+ },
394
444
  ],
395
445
  // Route order: RoleController stays AHEAD of GrantAdminController (documented Nest
396
446
  // route-precedence constraint — the summary route `access/role-grants` dodges
@@ -439,6 +489,13 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
439
489
  { token: guard_1.SHIELD_SESSION_SWEEPER, optional: true },
440
490
  ],
441
491
  },
492
+ // Phase 9 #7: fail the boot unless the request path decides with the authority guard
493
+ // built from this module's record authorization (never the silent deny-everything stub).
494
+ {
495
+ provide: IDENTITY_DECISION_AUTHORITY_CHECK,
496
+ useFactory: assertRequestPathUsesIdentityAuthorization,
497
+ inject: [{ token: guard_1.SHIELD_DECISION_AUTHORITY, optional: true }],
498
+ },
442
499
  {
443
500
  provide: IDENTITY_VERSION_PUBLISHER_LINK,
444
501
  useFactory: (cache) => {
@@ -10,6 +10,13 @@ export interface PostingVersionBump {
10
10
  * the account itself was disabled or re-enabled. Returns the bumped tenants; throws on failure.
11
11
  */
12
12
  bumpForUser?(userUuid: string): Promise<string[]>;
13
+ /**
14
+ * OPTIONAL (Phase 8 #2): the tenants whose cached grants change when the role goes away — every
15
+ * tenant where it is actively posted, plus every service account's tenant holding it. Read
16
+ * BEFORE a role delete (the delete cascades the postings); bump each with `bumpTenant` after the
17
+ * commit. Throws on failure.
18
+ */
19
+ tenantsForRole?(roleUuid: string): Promise<string[]>;
13
20
  }
14
21
  export declare const POSTING_VERSION_BUMP: unique symbol;
15
22
  //# 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;IAC9C;;;OAGG;IACH,WAAW,CAAC,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CACnD;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;IAClD;;;;;OAKG;IACH,cAAc,CAAC,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CACtD;AAED,eAAO,MAAM,oBAAoB,eAAiC,CAAC"}
@@ -14,6 +14,8 @@
14
14
  * (`GrantCeiling.assertRoleAssignable` — a role also used elsewhere is never assignable),
15
15
  * and the role's keys are all held by the actor (`assertCanAssignRoles`, ADR-0029 applied to
16
16
  * assignment). Removals need no ceiling: self-demotion stays allowed.
17
+ * - for every ADDED posting: its department and branch belong to the membership's company
18
+ * (`assertOrgUnitsInCompany`, Phase 9 #13b) — else 400.
17
19
  *
18
20
  * ── Atomicity (KI #16) ────────────────────────────────────────────────────────────────
19
21
  * The diff runs inside ONE `records.transaction` (ADR-0034): creates FIRST, then deletes, so a
@@ -1 +1 @@
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"}
1
+ {"version":3,"file":"posting.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/posting.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;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;CA8GjB"}
@@ -15,6 +15,8 @@
15
15
  * (`GrantCeiling.assertRoleAssignable` — a role also used elsewhere is never assignable),
16
16
  * and the role's keys are all held by the actor (`assertCanAssignRoles`, ADR-0029 applied to
17
17
  * assignment). Removals need no ceiling: self-demotion stays allowed.
18
+ * - for every ADDED posting: its department and branch belong to the membership's company
19
+ * (`assertOrgUnitsInCompany`, Phase 9 #13b) — else 400.
18
20
  *
19
21
  * ── Atomicity (KI #16) ────────────────────────────────────────────────────────────────
20
22
  * The diff runs inside ONE `records.transaction` (ADR-0034): creates FIRST, then deletes, so a
@@ -163,6 +165,13 @@ let PostingService = class PostingService {
163
165
  await this.ceiling.assertRoleAssignable(actor, roleUuid);
164
166
  }
165
167
  await this.ceiling.assertCanAssignRoles(actor, addedRoles);
168
+ // Phase 9 #13b (review F4): an added posting's department/branch must be the membership
169
+ // company's own (junction rows), never another tenant's (400).
170
+ const present = (v) => typeof v === 'string' && v.length > 0;
171
+ await this.ceiling.assertOrgUnitsInCompany(tenantUuid, {
172
+ departmentUuids: added.map((d) => d.departmentUuid).filter(present),
173
+ branchUuids: added.map((d) => d.branchUuid).filter(present),
174
+ });
166
175
  // Removing a PEER's postings strips their authority: the actor must dominate the member
167
176
  // (security review). Removing your OWN postings (self-demotion) stays allowed.
168
177
  const memberUuid = typeof membership.fields?.userUuid === 'string' ? membership.fields.userUuid : '';
@@ -2,6 +2,8 @@ import { RecordsService, type RecordId } from '@astram/engine';
2
2
  import type { Principal } from '@astram/core';
3
3
  import { type ListParams } from '../shared/list-query.js';
4
4
  import { GrantCeiling } from '../authz/grant-ceiling.js';
5
+ import { GrantAdminService } from '../authz/grant-admin.service.js';
6
+ import { type PostingVersionBump } from '../membership/posting-version-bump.js';
5
7
  /**
6
8
  * RoleService — role CRUD on the paved road, via the framework's RecordsService
7
9
  * facade. The facade supplies the acting identity (the request principal) for the pipe;
@@ -15,7 +17,9 @@ import { GrantCeiling } from '../authz/grant-ceiling.js';
15
17
  export declare class RoleService {
16
18
  private readonly records;
17
19
  private readonly ceiling;
18
- constructor(records: RecordsService, ceiling: GrantCeiling);
20
+ private readonly versionBump?;
21
+ private readonly grants?;
22
+ constructor(records: RecordsService, ceiling: GrantCeiling, versionBump?: (PostingVersionBump | null) | undefined, grants?: (GrantAdminService | null) | undefined);
19
23
  /** Create a role. */
20
24
  createRole(name: string): Promise<RecordId>;
21
25
  /** List roles (tenant-scoped by the pipe), with paging/search/sort. */
@@ -26,7 +30,33 @@ export declare class RoleService {
26
30
  updateRole(actor: Principal, uuid: string, patch: {
27
31
  name?: unknown;
28
32
  }): Promise<void>;
29
- /** Delete a role — only a role the actor may administer (binding rule). */
33
+ /**
34
+ * Delete a role — only a role the actor may administer (binding rule) AND whose every holder the
35
+ * actor dominates (Phase 8 #2: the delete cascades the holders' postings, stripping their
36
+ * authority). In ONE transaction: lock the role row (the posting and grant paths lock the same
37
+ * row first), re-check the binding rule and holder dominance against what is committed now, read
38
+ * the holder tenants (before the cascade removes the postings), then delete. After the commit,
39
+ * every holder tenant's permission version is bumped so cached roles drop at the next request.
40
+ * The bump runs after the commit, never before: an earlier bump would let a session reload the
41
+ * pre-delete roles under the new version. A bump failure throws (503) rather than leave holders'
42
+ * cached grants silently stale.
43
+ *
44
+ * The role's `role_permission` rows (shield_authz, no FK) and its place in every service
45
+ * account's `sa_role_ids` (read live by the API-key store) do not cascade, so
46
+ * `GrantAdminService.purgeRole` removes both in its own committed authz transaction — checking
47
+ * first that the actor may change each holding account, and bumping every tenant that used the
48
+ * role (account tenants included).
49
+ *
50
+ * Phase 9 #5: the engine's delete (its full gate: the coarse `role:delete` decision, then the
51
+ * record-level one, through the single decision authority) runs BEFORE the purge, on this
52
+ * transaction. A refused delete throws before anything is purged, so nothing changes. The purge
53
+ * then commits on its own connection while this transaction still holds the role row; it reads
54
+ * only committed state (the postings this delete removes are still visible to it, so their
55
+ * tenants are bumped). A purge refusal or failure rolls the delete back. A failure after the
56
+ * purge committed (this commit) leaves a role with no grants and no agents: fewer permissions,
57
+ * never live grants or an agent carrying a deleted role. A re-created role gets a fresh uuid
58
+ * (the engine mints it), so it can never pick up anything left behind.
59
+ */
30
60
  deleteRole(actor: Principal, uuid: string): Promise<void>;
31
61
  }
32
62
  //# sourceMappingURL=role.service.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"role.service.d.ts","sourceRoot":"","sources":["../../../src/features/role/role.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAkB,KAAK,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1E,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAKzD;;;;;;;;;GASG;AACH,qBACa,WAAW;IAEpB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBADP,OAAO,EAAE,cAAc,EACvB,OAAO,EAAE,YAAY;IAGxC,qBAAqB;IACrB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAI3C,uEAAuE;IACvE,SAAS,CAAC,MAAM,CAAC,EAAE,UAAU;IAI7B,0BAA0B;IAC1B,OAAO,CAAC,IAAI,EAAE,MAAM;IAIpB,8FAA8F;IACxF,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE;QAAE,IAAI,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAO1F,2EAA2E;IACrE,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAIhE"}
1
+ {"version":3,"file":"role.service.d.ts","sourceRoot":"","sources":["../../../src/features/role/role.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,OAAO,EAAkB,KAAK,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1E,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iCAAiC,CAAC;AACpE,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,uCAAuC,CAAC;AAK/C;;;;;;;;;GASG;AACH,qBACa,WAAW;IAEpB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAKxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAK7B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;gBAXP,OAAO,EAAE,cAAc,EACvB,OAAO,EAAE,YAAY,EAKrB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA,EAKvC,MAAM,CAAC,GAAE,iBAAiB,GAAG,IAAI,aAAA;IAGpD,qBAAqB;IACrB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAI3C,uEAAuE;IACvE,SAAS,CAAC,MAAM,CAAC,EAAE,UAAU;IAI7B,0BAA0B;IAC1B,OAAO,CAAC,IAAI,EAAE,MAAM;IAIpB,8FAA8F;IACxF,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE;QAAE,IAAI,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAO1F;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACG,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAoBhE"}
@@ -8,12 +8,18 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
8
8
  var __metadata = (this && this.__metadata) || function (k, v) {
9
9
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
10
  };
11
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
12
+ return function (target, key) { decorator(target, key, paramIndex); }
13
+ };
11
14
  Object.defineProperty(exports, "__esModule", { value: true });
12
15
  exports.RoleService = void 0;
13
16
  const common_1 = require("@nestjs/common");
14
17
  const engine_1 = require("@astram/engine");
18
+ const core_1 = require("@astram/core");
15
19
  const list_query_js_1 = require("../shared/list-query.js");
16
20
  const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
21
+ const grant_admin_service_js_1 = require("../authz/grant-admin.service.js");
22
+ const posting_version_bump_js_1 = require("../membership/posting-version-bump.js");
17
23
  const ROLE_SEARCH_FIELDS = ['name'];
18
24
  const ROLE_SORT_FIELDS = ['name', 'createdAt'];
19
25
  /**
@@ -29,9 +35,13 @@ const ROLE_SORT_FIELDS = ['name', 'createdAt'];
29
35
  let RoleService = class RoleService {
30
36
  records;
31
37
  ceiling;
32
- constructor(records, ceiling) {
38
+ versionBump;
39
+ grants;
40
+ constructor(records, ceiling, versionBump, grants) {
33
41
  this.records = records;
34
42
  this.ceiling = ceiling;
43
+ this.versionBump = versionBump;
44
+ this.grants = grants;
35
45
  }
36
46
  /** Create a role. */
37
47
  createRole(name) {
@@ -53,15 +63,65 @@ let RoleService = class RoleService {
53
63
  fields.name = patch.name;
54
64
  await this.records.update('role', uuid, { fields });
55
65
  }
56
- /** Delete a role — only a role the actor may administer (binding rule). */
66
+ /**
67
+ * Delete a role — only a role the actor may administer (binding rule) AND whose every holder the
68
+ * actor dominates (Phase 8 #2: the delete cascades the holders' postings, stripping their
69
+ * authority). In ONE transaction: lock the role row (the posting and grant paths lock the same
70
+ * row first), re-check the binding rule and holder dominance against what is committed now, read
71
+ * the holder tenants (before the cascade removes the postings), then delete. After the commit,
72
+ * every holder tenant's permission version is bumped so cached roles drop at the next request.
73
+ * The bump runs after the commit, never before: an earlier bump would let a session reload the
74
+ * pre-delete roles under the new version. A bump failure throws (503) rather than leave holders'
75
+ * cached grants silently stale.
76
+ *
77
+ * The role's `role_permission` rows (shield_authz, no FK) and its place in every service
78
+ * account's `sa_role_ids` (read live by the API-key store) do not cascade, so
79
+ * `GrantAdminService.purgeRole` removes both in its own committed authz transaction — checking
80
+ * first that the actor may change each holding account, and bumping every tenant that used the
81
+ * role (account tenants included).
82
+ *
83
+ * Phase 9 #5: the engine's delete (its full gate: the coarse `role:delete` decision, then the
84
+ * record-level one, through the single decision authority) runs BEFORE the purge, on this
85
+ * transaction. A refused delete throws before anything is purged, so nothing changes. The purge
86
+ * then commits on its own connection while this transaction still holds the role row; it reads
87
+ * only committed state (the postings this delete removes are still visible to it, so their
88
+ * tenants are bumped). A purge refusal or failure rolls the delete back. A failure after the
89
+ * purge committed (this commit) leaves a role with no grants and no agents: fewer permissions,
90
+ * never live grants or an agent carrying a deleted role. A re-created role gets a fresh uuid
91
+ * (the engine mints it), so it can never pick up anything left behind.
92
+ */
57
93
  async deleteRole(actor, uuid) {
58
94
  await this.ceiling.assertRoleAdministrable(actor, uuid);
59
- await this.records.delete('role', uuid);
95
+ const grants = this.grants;
96
+ if (!grants)
97
+ throw new core_1.AuthorizationUnavailableError('role grant purge is not configured');
98
+ const tenants = await this.records.transaction(async (tx) => {
99
+ await tx.lockForUpdate('role', uuid);
100
+ await this.ceiling.assertRoleAdministrable(actor, uuid);
101
+ await this.ceiling.assertDominatesRoleHolders(actor, uuid);
102
+ const affected = (await this.versionBump?.tenantsForRole?.(uuid)) ?? [];
103
+ await tx.delete('role', uuid); // the engine's delete gate decides first (Phase 9 #5)
104
+ await grants.purgeRole(uuid, actor);
105
+ return affected;
106
+ });
107
+ if (!this.versionBump)
108
+ return;
109
+ try {
110
+ for (const tenant of tenants)
111
+ await this.versionBump.bumpTenant(tenant);
112
+ }
113
+ catch (cause) {
114
+ throw new core_1.AuthorizationUnavailableError('permission version bump failed', cause);
115
+ }
60
116
  }
61
117
  };
62
118
  exports.RoleService = RoleService;
63
119
  exports.RoleService = RoleService = __decorate([
64
120
  (0, common_1.Injectable)(),
121
+ __param(2, (0, common_1.Optional)()),
122
+ __param(2, (0, common_1.Inject)(posting_version_bump_js_1.POSTING_VERSION_BUMP)),
123
+ __param(3, (0, common_1.Optional)()),
124
+ __param(3, (0, common_1.Inject)(grant_admin_service_js_1.GrantAdminService)),
65
125
  __metadata("design:paramtypes", [engine_1.RecordsService,
66
- grant_ceiling_js_1.GrantCeiling])
126
+ grant_ceiling_js_1.GrantCeiling, Object, Object])
67
127
  ], RoleService);
@@ -88,6 +88,10 @@ export declare class ScopeClosureService {
88
88
  * subtree, so those admins' cached session subtrees are stale (they'd keep the deleted node in
89
89
  * their set) — bump them so they re-derive at the next refresh (same discipline as moveNode's
90
90
  * former-parent bump; without it, a parent admin's list/decide would reference a gone node).
91
+ * Also bumps the deleted node's own tenant (Phase 8 #3). `beforeDelete` runs first, on the same
92
+ * transaction, so a caller can lock the node and refuse (rollback) before anything changes; it
93
+ * may also retire rows that depend on the node and return the tenants those changes touched,
94
+ * which are bumped with the rest.
91
95
  *
92
96
  * v1 SCOPE NOTE: this refuses a node with CHILDREN. Broader resource-orphan cleanup (rows in
93
97
  * the many tenant-scoped app tables that reference the deleted node) is out of scope for v1 and
@@ -95,7 +99,13 @@ export declare class ScopeClosureService {
95
99
  */
96
100
  deleteNode(nodeUuid: string,
97
101
  /** Runs on the delete's transaction after the bump (e.g. the audit row) — a throw rolls back. */
98
- inTransaction?: (conn: ScopeConn) => Promise<void>): Promise<void>;
102
+ inTransaction?: (conn: ScopeConn) => Promise<void>,
103
+ /**
104
+ * Runs on the delete's transaction BEFORE anything is deleted (Phase 8 #3: lock the node, check
105
+ * authority over its members and agents, retire its agents and cross-tenant grants) — a throw
106
+ * rolls back and nothing is deleted or bumped. Returned tenant ids are bumped too.
107
+ */
108
+ beforeDelete?: (conn: ScopeConn) => Promise<void | readonly string[]>): Promise<void>;
99
109
  /** Read the node forest's parent edges (`c_uuid` → `c_parent_uuid`) as `ScopeEdge[]`. */
100
110
  private readEdges;
101
111
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"scope-closure.service.d.ts","sourceRoot":"","sources":["../../../src/features/scope/scope-closure.service.ts"],"names":[],"mappings":"AAqCA,OAAO,EAAqC,KAAK,SAAS,EAAE,MAAM,eAAe,CAAC;AAOlF;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAED,iGAAiG;AACjG,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAClE,aAAa,IAAI,OAAO,CACtB,SAAS,GAAG;QACV,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QAClC,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QACxB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QAC1B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QACzB,2FAA2F;QAC3F,OAAO,CAAC,IAAI,IAAI,CAAC;KAClB,CACF,CAAC;CACH;AAOD,wFAAwF;AACxF,MAAM,WAAW,kBAAkB;IACjC,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE,+FAA+F;IAC/F,gBAAgB,CAAC,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClD,qFAAqF;IACrF,cAAc,CAAC,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAAC;CACxC;AAED,qBAAa,mBAAmB;IAE5B,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,MAAM;gBADN,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,kBAAkB;IAG7C;;;;;;OAMG;IACG,gBAAgB,IAAI,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IAsBvF;;;;;;OAMG;IACG,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAcjD;;;;;;;OAOG;YACW,UAAU;IAwCxB;;;;;;;;;;;;;;;;OAgBG;IACG,QAAQ,CACZ,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,MAAM,GAAG,IAAI;IAC5B,+FAA+F;IAC/F,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC,GACjD,OAAO,CAAC,IAAI,CAAC;IAiFhB;;;;;;;;;;;;OAYG;IACG,UAAU,CACd,QAAQ,EAAE,MAAM;IAChB,iGAAiG;IACjG,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC,GACjD,OAAO,CAAC,IAAI,CAAC;IAoChB,yFAAyF;YAC3E,SAAS;IAOvB;;;;OAIG;YACW,cAAc;CAiB7B;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,SAAS,EAAE,GAAG;IAC7D,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB,CA8BA;AAED,kGAAkG;AAClG,qBAAa,oBAAqB,SAAQ,KAAK;gBACjC,QAAQ,EAAE,MAAM;CAQ7B"}
1
+ {"version":3,"file":"scope-closure.service.d.ts","sourceRoot":"","sources":["../../../src/features/scope/scope-closure.service.ts"],"names":[],"mappings":"AAqCA,OAAO,EAAqC,KAAK,SAAS,EAAE,MAAM,eAAe,CAAC;AAOlF;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAED,iGAAiG;AACjG,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAClE,aAAa,IAAI,OAAO,CACtB,SAAS,GAAG;QACV,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QAClC,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QACxB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QAC1B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QACzB,2FAA2F;QAC3F,OAAO,CAAC,IAAI,IAAI,CAAC;KAClB,CACF,CAAC;CACH;AAOD,wFAAwF;AACxF,MAAM,WAAW,kBAAkB;IACjC,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE,+FAA+F;IAC/F,gBAAgB,CAAC,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClD,qFAAqF;IACrF,cAAc,CAAC,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAAC;CACxC;AAED,qBAAa,mBAAmB;IAE5B,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,MAAM;gBADN,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,kBAAkB;IAG7C;;;;;;OAMG;IACG,gBAAgB,IAAI,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IAsBvF;;;;;;OAMG;IACG,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAcjD;;;;;;;OAOG;YACW,UAAU;IAwCxB;;;;;;;;;;;;;;;;OAgBG;IACG,QAAQ,CACZ,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,MAAM,GAAG,IAAI;IAC5B,+FAA+F;IAC/F,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC,GACjD,OAAO,CAAC,IAAI,CAAC;IAiFhB;;;;;;;;;;;;;;;;OAgBG;IACG,UAAU,CACd,QAAQ,EAAE,MAAM;IAChB,iGAAiG;IACjG,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC;IAClD;;;;OAIG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,GAAG,SAAS,MAAM,EAAE,CAAC,GACpE,OAAO,CAAC,IAAI,CAAC;IA6ChB,yFAAyF;YAC3E,SAAS;IAOvB;;;;OAIG;YACW,cAAc;CAiB7B;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,SAAS,EAAE,GAAG;IAC7D,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB,CA8BA;AAED,kGAAkG;AAClG,qBAAa,oBAAqB,SAAQ,KAAK;gBACjC,QAAQ,EAAE,MAAM;CAQ7B"}
@@ -246,6 +246,10 @@ class ScopeClosureService {
246
246
  * subtree, so those admins' cached session subtrees are stale (they'd keep the deleted node in
247
247
  * their set) — bump them so they re-derive at the next refresh (same discipline as moveNode's
248
248
  * former-parent bump; without it, a parent admin's list/decide would reference a gone node).
249
+ * Also bumps the deleted node's own tenant (Phase 8 #3). `beforeDelete` runs first, on the same
250
+ * transaction, so a caller can lock the node and refuse (rollback) before anything changes; it
251
+ * may also retire rows that depend on the node and return the tenants those changes touched,
252
+ * which are bumped with the rest.
249
253
  *
250
254
  * v1 SCOPE NOTE: this refuses a node with CHILDREN. Broader resource-orphan cleanup (rows in
251
255
  * the many tenant-scoped app tables that reference the deleted node) is out of scope for v1 and
@@ -253,7 +257,13 @@ class ScopeClosureService {
253
257
  */
254
258
  async deleteNode(nodeUuid,
255
259
  /** Runs on the delete's transaction after the bump (e.g. the audit row) — a throw rolls back. */
256
- inTransaction) {
260
+ inTransaction,
261
+ /**
262
+ * Runs on the delete's transaction BEFORE anything is deleted (Phase 8 #3: lock the node, check
263
+ * authority over its members and agents, retire its agents and cross-tenant grants) — a throw
264
+ * rolls back and nothing is deleted or bumped. Returned tenant ids are bumped too.
265
+ */
266
+ beforeDelete) {
257
267
  // Under the tree lock (review F3): a concurrent move cannot hang a child under this node
258
268
  // between the children check and the delete.
259
269
  await this.inTreeLock(async (conn) => {
@@ -261,6 +271,7 @@ class ScopeClosureService {
261
271
  if (children.length > 0) {
262
272
  throw new NodeHasChildrenError(nodeUuid);
263
273
  }
274
+ const touched = (beforeDelete ? await beforeDelete(conn) : undefined) ?? [];
264
275
  // The former-parent ancestry — the deleted node's ancestors (excluding self). They lose the
265
276
  // node from their reach → bump so their sessions re-derive. Read BEFORE the delete.
266
277
  const formerAncestryRows = (await conn.query(`SELECT ancestor_uuid FROM \`${AUTHZ}\`.\`scope_closure\`
@@ -270,9 +281,17 @@ class ScopeClosureService {
270
281
  WHERE ancestor_uuid = ? OR descendant_uuid = ?`, [nodeUuid, nodeUuid]);
271
282
  // Remove the company row itself.
272
283
  await conn.query(`DELETE FROM \`${APP}\`.\`companies\` WHERE c_uuid = ?`, [nodeUuid]);
273
- // Bump the former-parent ancestry so their cached subtrees re-derive at next refresh.
274
- for (const { ancestor_uuid } of formerAncestryRows) {
275
- await this.bumper.bumpForTenant(ancestor_uuid, conn);
284
+ // Bump the former-parent ancestry so their cached subtrees re-derive at next refresh, and the
285
+ // deleted node's OWN tenant (Phase 8 #3): its memberships and postings cascade away, so
286
+ // sessions cached in it must reload (and find nothing). A leaf has no descendants to bump.
287
+ // Plus the tenants `beforeDelete` touched (e.g. other tenants' agents that lost the node).
288
+ const bumpSet = new Set([
289
+ ...formerAncestryRows.map((r) => r.ancestor_uuid),
290
+ nodeUuid,
291
+ ...touched,
292
+ ]);
293
+ for (const tenant of bumpSet) {
294
+ await this.bumper.bumpForTenant(tenant, conn);
276
295
  }
277
296
  if (inTransaction)
278
297
  await inTransaction(conn);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Lock the service accounts matching a predicate, without locking the table (Phase 9 #11).
3
+ *
4
+ * `service_account` has no index a role or scope predicate can use (`JSON_CONTAINS` on a JSON
5
+ * column, or an `OR` with one), so `SELECT … WHERE <predicate> FOR UPDATE` scans the whole table
6
+ * and InnoDB (REPEATABLE READ) keeps a next-key lock on every row it scanned: every other agent's
7
+ * edit, and every new agent, waits until the transaction ends. Instead:
8
+ * 1. a NON-locking read finds the candidate ids (a consistent read, no locks);
9
+ * 2. `FOR UPDATE` by primary key locks only those rows, sorted by id (a stable lock order), and
10
+ * re-evaluates the predicate on the latest committed version of each, so a candidate that
11
+ * stopped matching in between is dropped.
12
+ *
13
+ * A row that STARTS matching after step 1 (an agent that gains the role, or the company in its
14
+ * scope) is not locked or returned. Callers accept that (see their docs): the outcome equals the
15
+ * serial order "this transaction, then that edit", which the table-wide lock also allowed (the
16
+ * edit only waited, then applied).
17
+ *
18
+ * `columns` and `where.sql` are fixed SQL written by the caller (never client input); every value
19
+ * is `?`-bound.
20
+ */
21
+ /** The connection surface: one transaction's `query`. */
22
+ export interface LockConn {
23
+ query(sql: string, params?: readonly unknown[]): Promise<unknown>;
24
+ }
25
+ export declare function lockServiceAccountsWhere<T>(conn: LockConn, columns: string, where: {
26
+ readonly sql: string;
27
+ readonly params: readonly unknown[];
28
+ }): Promise<T[]>;
29
+ //# sourceMappingURL=lock-service-accounts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lock-service-accounts.d.ts","sourceRoot":"","sources":["../../../src/features/service-account/lock-service-accounts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,yDAAyD;AACzD,MAAM,WAAW,QAAQ;IACvB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAED,wBAAsB,wBAAwB,CAAC,CAAC,EAC9C,IAAI,EAAE,QAAQ,EACd,OAAO,EAAE,MAAM,EACf,KAAK,EAAE;IAAE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,SAAS,OAAO,EAAE,CAAA;CAAE,GACnE,OAAO,CAAC,CAAC,EAAE,CAAC,CAed"}