@astram/identity 0.6.0 → 0.7.1

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 (50) 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 +22 -5
  6. package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
  7. package/dist/features/authz/grant-admin.service.js +45 -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 +36 -8
  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 +13 -1
  15. package/dist/features/company/company-hierarchy.service.d.ts.map +1 -1
  16. package/dist/features/company/company-hierarchy.service.js +36 -9
  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 +18 -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/shared/http.d.ts +6 -0
  39. package/dist/features/shared/http.d.ts.map +1 -1
  40. package/dist/features/shared/http.js +9 -0
  41. package/dist/features/user/user.service.d.ts +4 -4
  42. package/dist/features/user/user.service.d.ts.map +1 -1
  43. package/dist/features/user/user.service.js +9 -6
  44. package/dist/index.d.ts +1 -11
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +8 -12
  47. package/dist/internal.d.ts +3 -2
  48. package/dist/internal.d.ts.map +1 -1
  49. package/dist/internal.js +3 -2
  50. package/package.json +7 -4
@@ -1,7 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CompanyHierarchyService = void 0;
4
+ exports.ownRootOf = ownRootOf;
5
+ const http_js_1 = require("../shared/http.js");
4
6
  const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
7
+ const lock_service_accounts_js_1 = require("../service-account/lock-service-accounts.js");
5
8
  const identity_errors_js_1 = require("../shared/identity-errors.js");
6
9
  const APP = 'shield_app';
7
10
  const AUTHZ = 'shield_authz';
@@ -18,6 +21,13 @@ function jsonIdArray(v) {
18
21
  }
19
22
  return Array.isArray(arr) ? arr.filter((x) => typeof x === 'string') : [];
20
23
  }
24
+ /**
25
+ * The actor's own root node (the active node its subtree hangs from). A non-root actor may not
26
+ * move, delete (F6) or change the status of (Phase 9 #13c) this node.
27
+ */
28
+ function ownRootOf(actor) {
29
+ return actor.activeScopeNodeId ?? actor.tenantId;
30
+ }
21
31
  class CompanyHierarchyService {
22
32
  pool;
23
33
  closure;
@@ -33,9 +43,19 @@ class CompanyHierarchyService {
33
43
  this.audit = audit;
34
44
  this.now = now;
35
45
  }
36
- /** The actor's own root node (the active node its subtree hangs from). */
46
+ /** The actor's own root node (see {@link ownRootOf}). */
37
47
  ownRoot(actor) {
38
- return actor.activeScopeNodeId ?? actor.tenantId;
48
+ return ownRootOf(actor);
49
+ }
50
+ /**
51
+ * The company's uuid as stored (Phase 10 #1): `c_uuid` is `utf8mb4_unicode_ci`, so a route uuid
52
+ * in another letter case still reaches the row, while JS-side comparisons (own root, agent
53
+ * tenants, `JSON_CONTAINS` over scope lists, name maps) are exact. Every entry resolves the
54
+ * stored spelling once and passes that down. Unknown → the input unchanged (later steps 404).
55
+ */
56
+ async storedUuid(uuid) {
57
+ const rows = (await this.pool.query(`SELECT c_uuid FROM \`${APP}\`.\`companies\` WHERE c_uuid = ? LIMIT 1`, [uuid]));
58
+ return rows[0]?.c_uuid ?? uuid;
39
59
  }
40
60
  /** Names of the given companies (unknown → absent). */
41
61
  async names(q, ids) {
@@ -66,11 +86,14 @@ class CompanyHierarchyService {
66
86
  (typeof newParentUuid !== 'string' || newParentUuid.length === 0)) {
67
87
  throw new identity_errors_js_1.IdentityBadRequestError('parentUuid must be a company or null.');
68
88
  }
89
+ nodeUuid = await this.storedUuid(nodeUuid);
90
+ if (newParentUuid !== null)
91
+ newParentUuid = await this.storedUuid(newParentUuid);
69
92
  if (actor.isRoot !== true) {
70
93
  const subtree = new Set((0, grant_ceiling_js_1.subtreeOf)(actor));
71
94
  if (!subtree.has(nodeUuid))
72
95
  throw new identity_errors_js_1.IdentityNotFoundError('Company not found.');
73
- if (nodeUuid === this.ownRoot(actor)) {
96
+ if ((0, http_js_1.sameUuid)(nodeUuid, this.ownRoot(actor))) {
74
97
  throw new identity_errors_js_1.IdentityForbiddenError('You cannot move the organisation your access is rooted at.');
75
98
  }
76
99
  if (newParentUuid === null || !subtree.has(newParentUuid)) {
@@ -116,9 +139,10 @@ class CompanyHierarchyService {
116
139
  * depending on it as they were; a commit leaves a deleted company with no live agent or grant.
117
140
  */
118
141
  async remove(actor, nodeUuid) {
142
+ nodeUuid = await this.storedUuid(nodeUuid); // Phase 10 #1: one spelling downstream
119
143
  // Security review F6: the same own-root guard as `move` — a sub-admin never deletes the
120
144
  // organisation its access is rooted at.
121
- if (actor.isRoot !== true && nodeUuid === this.ownRoot(actor)) {
145
+ if (actor.isRoot !== true && (0, http_js_1.sameUuid)(nodeUuid, this.ownRoot(actor))) {
122
146
  throw new identity_errors_js_1.IdentityForbiddenError('You cannot delete the organisation your access is rooted at.');
123
147
  }
124
148
  const name = (await this.names(this.pool, [nodeUuid])).get(nodeUuid) ?? '(unknown)';
@@ -152,11 +176,14 @@ class CompanyHierarchyService {
152
176
  * Every change is audited on this transaction (names; ids only in structured attributes).
153
177
  */
154
178
  async retireDependents(conn, actor, nodeUuid, companyName) {
155
- const agents = (await conn.query(`SELECT sa_id, sa_tenant_id, sa_label, sa_scope_subtree, sa_role_ids, sa_updated_at_ms
156
- FROM \`${APP}\`.\`service_account\`
157
- WHERE sa_tenant_id = ? OR JSON_CONTAINS(sa_scope_subtree, JSON_QUOTE(?))
158
- ORDER BY sa_id
159
- FOR UPDATE`, [nodeUuid, nodeUuid]));
179
+ // Phase 9 #11: lock only the matching agents, never the whole table. An agent created in the
180
+ // company, or given it in scope, after the candidate read is not seen: the outcome equals
181
+ // "delete, then that edit" (the API-key path joins `companies`, so an agent of a deleted
182
+ // tenant cannot authenticate; a deleted node in a scope reaches nothing).
183
+ const agents = await (0, lock_service_accounts_js_1.lockServiceAccountsWhere)(conn, 'sa_id, sa_tenant_id, sa_label, sa_scope_subtree, sa_role_ids, sa_updated_at_ms', {
184
+ sql: 'sa_tenant_id = ? OR JSON_CONTAINS(sa_scope_subtree, JSON_QUOTE(?))',
185
+ params: [nodeUuid, nodeUuid],
186
+ });
160
187
  const own = agents.filter((a) => a.sa_tenant_id === nodeUuid);
161
188
  const others = agents.filter((a) => a.sa_tenant_id !== nodeUuid);
162
189
  // Root bypasses inside; a refusal (403, names no agent) rolls the whole delete back.
@@ -41,7 +41,7 @@ export declare class CompanyController {
41
41
  * Update a company's name/status — gated on `identity:company:update`. Any other key
42
42
  * (parentUuid, type, …) is ignored: use `PUT companies/:uuid/parent` to move a company.
43
43
  */
44
- updateCompany(uuid: string, body: {
44
+ updateCompany(req: AuthedRequest, uuid: string, body: {
45
45
  name?: string;
46
46
  status?: string;
47
47
  }): Promise<{
@@ -1 +1 @@
1
- {"version":3,"file":"company.controller.d.ts","sourceRoot":"","sources":["../../../src/features/company/company.controller.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAkB,KAAK,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAC5E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAGvD,qBAEa,iBAAiB;IAChB,OAAO,CAAC,QAAQ,CAAC,SAAS;gBAAT,SAAS,EAAE,cAAc;IAEtD;;;;OAIG;IAGG,aAAa,CAAU,KAAK,EAAE,YAAY;;;;;;IAMhD;;;OAGG;IAGG,aAAa,CAAQ,GAAG,EAAE,aAAa,EAAU,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE;;;IAK9E,2DAA2D;IAG3D,UAAU,CAAgB,IAAI,EAAE,MAAM;IAItC;;;;;OAKG;IAGG,WAAW,CACR,GAAG,EAAE,aAAa,EACV,IAAI,EAAE,MAAM,EACnB,IAAI,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;;;IAa9C;;;OAGG;IAGG,aAAa,CACF,IAAI,EAAE,MAAM,EACnB,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE;;;IAMlD,iGAAiG;IAG3F,aAAa,CAAQ,GAAG,EAAE,aAAa,EAAiB,IAAI,EAAE,MAAM;;;CAI3E"}
1
+ {"version":3,"file":"company.controller.d.ts","sourceRoot":"","sources":["../../../src/features/company/company.controller.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAkB,KAAK,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAC5E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAGvD,qBAEa,iBAAiB;IAChB,OAAO,CAAC,QAAQ,CAAC,SAAS;gBAAT,SAAS,EAAE,cAAc;IAEtD;;;;OAIG;IAGG,aAAa,CAAU,KAAK,EAAE,YAAY;;;;;;IAMhD;;;OAGG;IAGG,aAAa,CAAQ,GAAG,EAAE,aAAa,EAAU,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE;;;IAK9E,2DAA2D;IAG3D,UAAU,CAAgB,IAAI,EAAE,MAAM;IAItC;;;;;OAKG;IAGG,WAAW,CACR,GAAG,EAAE,aAAa,EACV,IAAI,EAAE,MAAM,EACnB,IAAI,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;;;IAa9C;;;OAGG;IAGG,aAAa,CACV,GAAG,EAAE,aAAa,EACV,IAAI,EAAE,MAAM,EACnB,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE;;;IASlD,iGAAiG;IAG3F,aAAa,CAAQ,GAAG,EAAE,aAAa,EAAiB,IAAI,EAAE,MAAM;;;CAI3E"}
@@ -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;AAUpF;;;;;;;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"}
@@ -15,6 +15,7 @@ const common_1 = require("@nestjs/common");
15
15
  const engine_1 = require("@astram/engine");
16
16
  const list_query_js_1 = require("../shared/list-query.js");
17
17
  const company_hierarchy_service_js_1 = require("./company-hierarchy.service.js");
18
+ const http_js_1 = require("../shared/http.js");
18
19
  const identity_errors_js_1 = require("../shared/identity-errors.js");
19
20
  const COMPANY_SEARCH_FIELDS = ['name'];
20
21
  const COMPANY_SORT_FIELDS = ['name', 'status', 'createdAt'];
@@ -66,13 +67,28 @@ let CompanyService = CompanyService_1 = class CompanyService {
66
67
  getCompany(uuid) {
67
68
  return this.records.get('company', uuid);
68
69
  }
69
- /** Update a company's name/status. Any other key (parentUuid, type, …) is dropped. */
70
- updateCompany(uuid, patch) {
70
+ /**
71
+ * Update a company's name/status. Any other key (parentUuid, type, …) is dropped.
72
+ *
73
+ * Phase 9 #13c: a non-root actor may not change the STATUS of the organisation its access is
74
+ * rooted at (the same own-root guard as move and delete, F6): deactivating it would act on the
75
+ * whole tenant above the actor's authority. Re-sending the current status (the company form
76
+ * always sends both fields) stays allowed, so the own root can still be renamed.
77
+ */
78
+ async updateCompany(actor, uuid, patch) {
71
79
  const fields = {};
72
80
  for (const key of COMPANY_PATCH_KEYS) {
73
81
  if (typeof patch?.[key] === 'string')
74
82
  fields[key] = patch[key];
75
83
  }
84
+ if (actor.isRoot !== true && fields.status !== undefined && (0, http_js_1.sameUuid)(uuid, (0, company_hierarchy_service_js_1.ownRootOf)(actor))) {
85
+ const current = await this.records.get('company', uuid);
86
+ if (current === null)
87
+ throw new identity_errors_js_1.IdentityNotFoundError('Company not found.');
88
+ if (current.fields.status !== fields.status) {
89
+ throw new identity_errors_js_1.IdentityForbiddenError('You cannot change the status of the organisation your access is rooted at.');
90
+ }
91
+ }
76
92
  return this.records.update('company', uuid, { fields });
77
93
  }
78
94
  /** 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"}