@astram/identity 0.6.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 (47) 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 +14 -5
  6. package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
  7. package/dist/features/authz/grant-admin.service.js +27 -10
  8. package/dist/features/authz/grant-ceiling.d.ts +19 -6
  9. package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
  10. package/dist/features/authz/grant-ceiling.js +34 -7
  11. package/dist/features/authz/record-authorization.d.ts +15 -32
  12. package/dist/features/authz/record-authorization.d.ts.map +1 -1
  13. package/dist/features/authz/record-authorization.js +15 -69
  14. package/dist/features/company/company-hierarchy.service.d.ts +6 -1
  15. package/dist/features/company/company-hierarchy.service.d.ts.map +1 -1
  16. package/dist/features/company/company-hierarchy.service.js +19 -7
  17. package/dist/features/company/company.controller.d.ts +1 -1
  18. package/dist/features/company/company.controller.d.ts.map +1 -1
  19. package/dist/features/company/company.controller.js +9 -5
  20. package/dist/features/company/company.service.d.ts +9 -2
  21. package/dist/features/company/company.service.d.ts.map +1 -1
  22. package/dist/features/company/company.service.js +17 -2
  23. package/dist/features/identity-feature.module.d.ts +15 -8
  24. package/dist/features/identity-feature.module.d.ts.map +1 -1
  25. package/dist/features/identity-feature.module.js +75 -28
  26. package/dist/features/membership/posting.service.d.ts +2 -0
  27. package/dist/features/membership/posting.service.d.ts.map +1 -1
  28. package/dist/features/membership/posting.service.js +9 -0
  29. package/dist/features/role/role.service.d.ts +14 -7
  30. package/dist/features/role/role.service.d.ts.map +1 -1
  31. package/dist/features/role/role.service.js +15 -8
  32. package/dist/features/service-account/lock-service-accounts.d.ts +29 -0
  33. package/dist/features/service-account/lock-service-accounts.d.ts.map +1 -0
  34. package/dist/features/service-account/lock-service-accounts.js +35 -0
  35. package/dist/features/service-account/no-expiry-key-report.d.ts +39 -0
  36. package/dist/features/service-account/no-expiry-key-report.d.ts.map +1 -0
  37. package/dist/features/service-account/no-expiry-key-report.js +91 -0
  38. package/dist/features/user/user.service.d.ts +4 -4
  39. package/dist/features/user/user.service.d.ts.map +1 -1
  40. package/dist/features/user/user.service.js +9 -6
  41. package/dist/index.d.ts +1 -11
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +8 -12
  44. package/dist/internal.d.ts +3 -2
  45. package/dist/internal.d.ts.map +1 -1
  46. package/dist/internal.js +3 -2
  47. package/package.json +7 -4
@@ -64,8 +64,11 @@ let CompanyController = class CompanyController {
64
64
  * Update a company's name/status — gated on `identity:company:update`. Any other key
65
65
  * (parentUuid, type, …) is ignored: use `PUT companies/:uuid/parent` to move a company.
66
66
  */
67
- async updateCompany(uuid, body) {
68
- await this.companies.updateCompany(uuid, { name: body?.name, status: body?.status });
67
+ async updateCompany(req, uuid, body) {
68
+ await this.companies.updateCompany(req.principal, uuid, {
69
+ name: body?.name,
70
+ status: body?.status,
71
+ });
69
72
  return { success: true };
70
73
  }
71
74
  /** Delete a company — gated on `identity:company:delete`. A company with sub-companies → 409. */
@@ -113,10 +116,11 @@ __decorate([
113
116
  __decorate([
114
117
  (0, common_1.Put)('companies/:uuid'),
115
118
  (0, guard_1.Classified)({ typeKey: 'company', action: 'update' }),
116
- __param(0, (0, common_1.Param)('uuid')),
117
- __param(1, (0, common_1.Body)()),
119
+ __param(0, (0, common_1.Req)()),
120
+ __param(1, (0, common_1.Param)('uuid')),
121
+ __param(2, (0, common_1.Body)()),
118
122
  __metadata("design:type", Function),
119
- __metadata("design:paramtypes", [String, Object]),
123
+ __metadata("design:paramtypes", [Object, String, Object]),
120
124
  __metadata("design:returntype", Promise)
121
125
  ], CompanyController.prototype, "updateCompany", null);
122
126
  __decorate([
@@ -26,8 +26,15 @@ export declare class CompanyService {
26
26
  listCompanies(params?: ListParams): Promise<import("@astram/engine").RecordPage>;
27
27
  /** Read a single company. */
28
28
  getCompany(uuid: string): Promise<import("@astram/engine").RecordView | null>;
29
- /** Update a company's name/status. Any other key (parentUuid, type, …) is dropped. */
30
- updateCompany(uuid: string, patch: Readonly<Record<string, unknown>>): Promise<void>;
29
+ /**
30
+ * Update a company's name/status. Any other key (parentUuid, type, …) is dropped.
31
+ *
32
+ * Phase 9 #13c: a non-root actor may not change the STATUS of the organisation its access is
33
+ * rooted at (the same own-root guard as move and delete, F6): deactivating it would act on the
34
+ * whole tenant above the actor's authority. Re-sending the current status (the company form
35
+ * always sends both fields) stays allowed, so the own root can still be renamed.
36
+ */
37
+ updateCompany(actor: Principal, uuid: string, patch: Readonly<Record<string, unknown>>): Promise<void>;
31
38
  /** Move a company under a new parent (hierarchy rules + cycle check, see the hierarchy service). */
32
39
  moveCompany(actor: Principal, uuid: string, parentUuid: string | null): Promise<void>;
33
40
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"company.service.d.ts","sourceRoot":"","sources":["../../../src/features/company/company.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,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AASzE;;;;;;;GAOG;AACH,qBACa,cAAc;IAEvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,SAAS;gBADT,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,uBAAuB;IAGrD;;;;;OAKG;IACG,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAetE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAmC;IAE1D,2EAA2E;IAC3E,aAAa,CAAC,MAAM,CAAC,EAAE,UAAU;IAOjC,6BAA6B;IAC7B,UAAU,CAAC,IAAI,EAAE,MAAM;IAIvB,sFAAsF;IACtF,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAQpF,oGAAoG;IACpG,WAAW,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAIrF;;;;;OAKG;IACG,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAKnE"}
1
+ {"version":3,"file":"company.service.d.ts","sourceRoot":"","sources":["../../../src/features/company/company.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,uBAAuB,EAAa,MAAM,gCAAgC,CAAC;AASpF;;;;;;;GAOG;AACH,qBACa,cAAc;IAEvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,SAAS;gBADT,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,uBAAuB;IAGrD;;;;;OAKG;IACG,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAetE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAmC;IAE1D,2EAA2E;IAC3E,aAAa,CAAC,MAAM,CAAC,EAAE,UAAU;IAOjC,6BAA6B;IAC7B,UAAU,CAAC,IAAI,EAAE,MAAM;IAIvB;;;;;;;OAOG;IACG,aAAa,CACjB,KAAK,EAAE,SAAS,EAChB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GACvC,OAAO,CAAC,IAAI,CAAC;IAiBhB,oGAAoG;IACpG,WAAW,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAIrF;;;;;OAKG;IACG,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAKnE"}
@@ -66,13 +66,28 @@ let CompanyService = CompanyService_1 = class CompanyService {
66
66
  getCompany(uuid) {
67
67
  return this.records.get('company', uuid);
68
68
  }
69
- /** Update a company's name/status. Any other key (parentUuid, type, …) is dropped. */
70
- updateCompany(uuid, patch) {
69
+ /**
70
+ * Update a company's name/status. Any other key (parentUuid, type, …) is dropped.
71
+ *
72
+ * Phase 9 #13c: a non-root actor may not change the STATUS of the organisation its access is
73
+ * rooted at (the same own-root guard as move and delete, F6): deactivating it would act on the
74
+ * whole tenant above the actor's authority. Re-sending the current status (the company form
75
+ * always sends both fields) stays allowed, so the own root can still be renamed.
76
+ */
77
+ async updateCompany(actor, uuid, patch) {
71
78
  const fields = {};
72
79
  for (const key of COMPANY_PATCH_KEYS) {
73
80
  if (typeof patch?.[key] === 'string')
74
81
  fields[key] = patch[key];
75
82
  }
83
+ if (actor.isRoot !== true && fields.status !== undefined && uuid === (0, company_hierarchy_service_js_1.ownRootOf)(actor)) {
84
+ const current = await this.records.get('company', uuid);
85
+ if (current === null)
86
+ throw new identity_errors_js_1.IdentityNotFoundError('Company not found.');
87
+ if (current.fields.status !== fields.status) {
88
+ throw new identity_errors_js_1.IdentityForbiddenError('You cannot change the status of the organisation your access is rooted at.');
89
+ }
90
+ }
76
91
  return this.records.update('company', uuid, { fields });
77
92
  }
78
93
  /** Move a company under a new parent (hierarchy rules + cycle check, see the hierarchy service). */
@@ -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,14 +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.
28
- * Phase 8 #4: if the action keys of these objects differ from the ones given to
29
- * `buildIdentityRecordAuthorization`, the boot fails with `IdentityRegistryMismatchError`.
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").
30
37
  */
31
38
  static forRoot({ rootIds, extraObjects, }?: {
32
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;AA8FxB;;;;;;;;;;GAUG;AACH,qBACa,qBAAqB;IAChC;;;;;;;;;;;;;;;OAeG;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;CA8cvB"}
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,29 +121,28 @@ 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.
100
- * Phase 8 #4: if the action keys of these objects differ from the ones given to
101
- * `buildIdentityRecordAuthorization`, the boot fails with `IdentityRegistryMismatchError`.
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").
102
131
  */
103
132
  static forRoot({ rootIds, extraObjects = [], } = {}) {
104
- // The ceiling's key universe includes the consumer's `extraObjects` (the SAME registry shape
105
- // `buildIdentityRecordAuthorization(pool, extraObjects)` gives the decision path): otherwise a
106
- // role holding a consumer-type key could never be assigned or granted by a non-root admin who
107
- // holds that key (review M5) — the security-hardening role-assignment ceiling makes that
108
- // reachable from every posting edit. Phase 8 #4: the two registries must have the SAME action
109
- // keys — checked here, before anything is opened; a mismatch fails the boot.
110
- const ceilingRegistry = (0, guard_1.buildIdentityModuleRegistry)([...objects_js_1.identityObjects, ...extraObjects]);
111
- (0, record_authorization_js_1.noteIdentityCeilingRegistry)(ceilingRegistry);
112
133
  // ONE shared shield_authz pool for the whole grant-admin stack (ADR-0011): the
113
134
  // permission-version bumper, the AccessBundleStore, the GrantAdminService, and the two
114
135
  // reconcile jobs all run over this single pool (don't open several). Opened once at
115
- // 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).
116
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;
117
146
  const bumper = new permission_version_bumper_js_1.PermissionVersionBumper();
118
147
  const postingVersionBump = {
119
148
  bumpTenant: (tenantUuid) => bumper.bumpForTenant(tenantUuid, authzPool),
@@ -123,8 +152,8 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
123
152
  tenantsForRole: (roleUuid) => bumper.tenantsForRole(roleUuid, authzPool),
124
153
  };
125
154
  // The core ModuleRegistry the catalog reconcile job needs, built from the identity
126
- // object registrations (guard's bridge reflects decorated classes → registry). Built BEFORE
127
- // 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`.
128
157
  const identityRegistry = (0, guard_1.buildIdentityModuleRegistry)([...objects_js_1.identityObjects]);
129
158
  // The grant-admin collaborators built at forRoot() over the shared pool (mirrors the
130
159
  // postingVersionBump closure pattern above). GrantCeiling (ADR-0029) enforces the
@@ -132,8 +161,8 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
132
161
  // resolved over the SAME authz pool + registry the object-type gate uses (one source of
133
162
  // truth for "what a principal holds"). ALWAYS wired here, so production always enforces.
134
163
  const accessBundleStore = new access_bundle_store_js_1.AccessBundleStore(authzPool);
135
- // (`ceilingRegistry` is built and parity-checked at the top of forRoot.) `rootIds` marks
136
- // platform roots as never dominated.
164
+ // (`ceilingRegistry` is the shared record-authorization registry built at the top of forRoot.)
165
+ // `rootIds` marks platform roots as never dominated.
137
166
  // rootIds is passed EXPLICITLY (review F5): `null` states "not configured". The dominance
138
167
  // check still refuses any user flagged `u_is_platform_superuser`, so an unconfigured list
139
168
  // never lets a sub-admin take over a platform administrator.
@@ -174,8 +203,8 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
174
203
  // authz.module, the anti-pattern this replaces). A SECOND DecisionAuthority is built over the
175
204
  // same record-authorization inputs (matches astram3): the route guard's own decide() is the
176
205
  // first; this one backs the writer's server-side re-verification.
177
- // (The non-recording builder: these internal authorities are not the consumer's decision path.)
178
- const crossTenantAuthority = (0, guard_1.buildRecordAuthorization)((0, record_authorization_js_1.buildRecordAuthorizationInputs)(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);
179
208
  // Record-level LIST SCOPE (Task 3.1, Invariant #8): the engine-facing fn the engine's
180
209
  // `list()` ANDs into BOTH the data SELECT and the COUNT — the in-query visible-set filter.
181
210
  // Built HERE, over the SAME shared authzPool (guard constructs the un-overridable scope
@@ -183,7 +212,7 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
183
212
  // out-of-the-box into `EngineModule.forRoot({ listScope })` below (ADR-0003 injection seam —
184
213
  // the engine never imports guard). Fail-closed: a store outage propagates and `list()` fails
185
214
  // rather than returning unscoped rows.
186
- const listScope = (0, guard_1.buildListScopeFn)((0, record_authorization_js_1.buildRecordAuthorizationInputs)(authzPool));
215
+ const listScope = (0, guard_1.buildListScopeFn)(recordAuthorization);
187
216
  // The writer's `GrantAuthorizeFn`: decide()-backed, fail-closed (no try/catch — a throw
188
217
  // propagates and the writer maps it to a retryable 503). isRoot ← the SAME rootIds the
189
218
  // consumer passes to ShieldModule.forRoot (root-correctness for control-plane grants).
@@ -237,11 +266,12 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
237
266
  audit: grantAudit,
238
267
  });
239
268
  // The read side (inbox/mine/incoming/offerable-types) over the same pool + the merged object
240
- // set (offerable = tenantScoped types only) + the per-type action vocabulary.
241
- 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);
242
272
  // ── GUIDED RULE-BUILDER (list-scoping engine Phase 5, §9.1) ──────────────────────────────
243
273
  // The policy-authoring surface over the guard `MariaDbPolicyStore` + core `makeAuthorPolicy`.
244
- // Reuses the SAME `identityRegistry` + `crossTenantAuthority` (a DecisionAuthority over the
274
+ // Reuses the SAME shared registry + `crossTenantAuthority` (a DecisionAuthority over the
245
275
  // shared authzPool — the delegated-admin ceiling's decide()) as the cross-tenant writer. The
246
276
  // core AuditSink is one-arg (`record(event)`); the engine `MariaDbAuditSink` is two-arg
247
277
  // (`record(event, query)`) — so bind the query to the shared authzPool (mirrors the
@@ -275,7 +305,9 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
275
305
  };
276
306
  });
277
307
  const policyService = new policy_service_js_1.PolicyService({
278
- 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,
279
311
  policyStore,
280
312
  decide: crossTenantAuthority,
281
313
  audit: policyAudit,
@@ -401,6 +433,14 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
401
433
  // NestJS DiscoveryService/MetadataScanner — the boot bootstrap provider reflects the
402
434
  // live controllers through this to build the OperationInventory.
403
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
+ },
404
444
  ],
405
445
  // Route order: RoleController stays AHEAD of GrantAdminController (documented Nest
406
446
  // route-precedence constraint — the summary route `access/role-grants` dodges
@@ -449,6 +489,13 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
449
489
  { token: guard_1.SHIELD_SESSION_SWEEPER, optional: true },
450
490
  ],
451
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
+ },
452
499
  {
453
500
  provide: IDENTITY_VERSION_PUBLISHER_LINK,
454
501
  useFactory: (cache) => {
@@ -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 : '';
@@ -42,13 +42,20 @@ export declare class RoleService {
42
42
  * cached grants silently stale.
43
43
  *
44
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 before the delete,
46
- * still under the role lock, `GrantAdminService.purgeRole` removes both in its own committed
47
- * authz transaction — checking first that the actor may change each holding account, and
48
- * bumping every tenant that used the role (account tenants included). Order is fail-closed: a
49
- * failure after the purge leaves a role with no grants and no agents, never live grants or an
50
- * agent carrying a deleted role. A re-created role gets a fresh uuid (the engine mints it), so
51
- * it can never pick up anything left behind.
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.
52
59
  */
53
60
  deleteRole(actor: Principal, uuid: string): Promise<void>;
54
61
  }
@@ -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;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;;;;;;;;;;;;;;;;;;;OAmBG;IACG,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAoBhE"}
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"}
@@ -75,13 +75,20 @@ let RoleService = class RoleService {
75
75
  * cached grants silently stale.
76
76
  *
77
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 before the delete,
79
- * still under the role lock, `GrantAdminService.purgeRole` removes both in its own committed
80
- * authz transaction — checking first that the actor may change each holding account, and
81
- * bumping every tenant that used the role (account tenants included). Order is fail-closed: a
82
- * failure after the purge leaves a role with no grants and no agents, never live grants or an
83
- * agent carrying a deleted role. A re-created role gets a fresh uuid (the engine mints it), so
84
- * it can never pick up anything left behind.
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.
85
92
  */
86
93
  async deleteRole(actor, uuid) {
87
94
  await this.ceiling.assertRoleAdministrable(actor, uuid);
@@ -93,8 +100,8 @@ let RoleService = class RoleService {
93
100
  await this.ceiling.assertRoleAdministrable(actor, uuid);
94
101
  await this.ceiling.assertDominatesRoleHolders(actor, uuid);
95
102
  const affected = (await this.versionBump?.tenantsForRole?.(uuid)) ?? [];
103
+ await tx.delete('role', uuid); // the engine's delete gate decides first (Phase 9 #5)
96
104
  await grants.purgeRole(uuid, actor);
97
- await tx.delete('role', uuid);
98
105
  return affected;
99
106
  });
100
107
  if (!this.versionBump)
@@ -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"}
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ /**
3
+ * Lock the service accounts matching a predicate, without locking the table (Phase 9 #11).
4
+ *
5
+ * `service_account` has no index a role or scope predicate can use (`JSON_CONTAINS` on a JSON
6
+ * column, or an `OR` with one), so `SELECT … WHERE <predicate> FOR UPDATE` scans the whole table
7
+ * and InnoDB (REPEATABLE READ) keeps a next-key lock on every row it scanned: every other agent's
8
+ * edit, and every new agent, waits until the transaction ends. Instead:
9
+ * 1. a NON-locking read finds the candidate ids (a consistent read, no locks);
10
+ * 2. `FOR UPDATE` by primary key locks only those rows, sorted by id (a stable lock order), and
11
+ * re-evaluates the predicate on the latest committed version of each, so a candidate that
12
+ * stopped matching in between is dropped.
13
+ *
14
+ * A row that STARTS matching after step 1 (an agent that gains the role, or the company in its
15
+ * scope) is not locked or returned. Callers accept that (see their docs): the outcome equals the
16
+ * serial order "this transaction, then that edit", which the table-wide lock also allowed (the
17
+ * edit only waited, then applied).
18
+ *
19
+ * `columns` and `where.sql` are fixed SQL written by the caller (never client input); every value
20
+ * is `?`-bound.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.lockServiceAccountsWhere = lockServiceAccountsWhere;
24
+ const APP = 'shield_app';
25
+ async function lockServiceAccountsWhere(conn, columns, where) {
26
+ const candidates = (await conn.query(`SELECT sa_id FROM \`${APP}\`.\`service_account\` WHERE ${where.sql}`, [...where.params]));
27
+ const ids = [...new Set(candidates.map((r) => r.sa_id))].sort();
28
+ if (ids.length === 0)
29
+ return [];
30
+ return (await conn.query(`SELECT ${columns}
31
+ FROM \`${APP}\`.\`service_account\`
32
+ WHERE sa_id IN (${ids.map(() => '?').join(', ')}) AND (${where.sql})
33
+ ORDER BY sa_id
34
+ FOR UPDATE`, [...ids, ...where.params]));
35
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The non-expiring service-account key report (security hardening Phase 8 #13, shipped as the
3
+ * `astram-sa-keys-report` bin in Phase 9 #8).
4
+ *
5
+ * Keys issued before an expiry became mandatory have `sak_expires_at_ms IS NULL` and never expire
6
+ * (a key issued later with an explicit `neverExpires: true` looks the same). This read-only report
7
+ * lists each agent's CURRENT, unrevoked key with no expiry so an operator can rotate it with an
8
+ * expiry. It prints labels and company names only: never a secret, a hash or a raw id.
9
+ */
10
+ /** Read-only: the current unrevoked key of each agent that has no expiry, with display names. */
11
+ export declare const NO_EXPIRY_KEY_REPORT_SQL = "SELECT sa.sa_label AS label, c.c_name AS company,\n sa.sa_status AS status, k.sak_version AS version, k.sak_created_at_ms AS issuedAtMs\n FROM shield_authz.service_account_key k\n LEFT JOIN shield_app.service_account sa ON sa.sa_id = k.sak_account_id\n LEFT JOIN shield_app.companies c ON c.c_uuid = k.sak_tenant_id\n WHERE k.sak_expires_at_ms IS NULL\n AND k.sak_revoked_at_ms IS NULL\n AND k.sak_version = (SELECT MAX(v.sak_version)\n FROM shield_authz.service_account_key v\n WHERE v.sak_account_id = k.sak_account_id)\n ORDER BY k.sak_created_at_ms";
12
+ /** One non-expiring key, as printed (no ids, no secrets). */
13
+ export interface NonExpiringKey {
14
+ /** The agent's label; `null` when the agent row no longer exists. */
15
+ readonly label: string | null;
16
+ /** The tenant company's name; `null` when the company no longer exists. */
17
+ readonly company: string | null;
18
+ readonly status: string | null;
19
+ readonly version: number;
20
+ readonly issuedAtMs: number | null;
21
+ }
22
+ /** The query surface the report needs (a `mariadb` pool or connection satisfies it). */
23
+ export interface ReportQueryable {
24
+ query(sql: string, params?: unknown[]): Promise<unknown>;
25
+ }
26
+ /** Run the report query and map the rows. Read-only. */
27
+ export declare function findNonExpiringKeys(db: ReportQueryable): Promise<NonExpiringKey[]>;
28
+ /** Human-readable report text (one line per key, plus a summary line). */
29
+ export declare function formatNonExpiringKeyReport(keys: readonly NonExpiringKey[]): string;
30
+ /** The bin's behaviour, testable without a process: returns the exit code. */
31
+ export declare function runNonExpiringKeyReport(opts: {
32
+ readonly argv: readonly string[];
33
+ readonly openDb: () => ReportQueryable & {
34
+ end(): Promise<void>;
35
+ };
36
+ readonly out: (text: string) => void;
37
+ readonly err: (text: string) => void;
38
+ }): Promise<number>;
39
+ //# sourceMappingURL=no-expiry-key-report.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"no-expiry-key-report.d.ts","sourceRoot":"","sources":["../../../src/features/service-account/no-expiry-key-report.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,iGAAiG;AACjG,eAAO,MAAM,wBAAwB,2mBAUP,CAAC;AAE/B,6DAA6D;AAC7D,MAAM,WAAW,cAAc;IAC7B,qEAAqE;IACrE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,2EAA2E;IAC3E,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED,wFAAwF;AACxF,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC1D;AAMD,wDAAwD;AACxD,wBAAsB,mBAAmB,CAAC,EAAE,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CASxF;AAED,0EAA0E;AAC1E,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,SAAS,cAAc,EAAE,GAAG,MAAM,CAalF;AAcD,8EAA8E;AAC9E,wBAAsB,uBAAuB,CAAC,IAAI,EAAE;IAClD,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,eAAe,GAAG;QAAE,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;KAAE,CAAC;IAClE,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACtC,GAAG,OAAO,CAAC,MAAM,CAAC,CAwBlB"}