@astram/identity 0.15.0 → 0.17.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 (129) hide show
  1. package/dist/branch.object.d.ts +9 -1
  2. package/dist/branch.object.d.ts.map +1 -1
  3. package/dist/branch.object.js +14 -1
  4. package/dist/company-branch.object.d.ts +5 -4
  5. package/dist/company-branch.object.d.ts.map +1 -1
  6. package/dist/company-branch.object.js +6 -4
  7. package/dist/company-department.object.d.ts +6 -6
  8. package/dist/company-department.object.d.ts.map +1 -1
  9. package/dist/company-department.object.js +7 -6
  10. package/dist/department.object.d.ts +9 -1
  11. package/dist/department.object.d.ts.map +1 -1
  12. package/dist/department.object.js +14 -1
  13. package/dist/features/access/access-admin.access.d.ts.map +1 -1
  14. package/dist/features/access/access-admin.access.js +8 -1
  15. package/dist/features/access/index.d.ts +1 -0
  16. package/dist/features/access/index.d.ts.map +1 -1
  17. package/dist/features/access/index.js +10 -2
  18. package/dist/features/access/org-structure.access.d.ts +3 -0
  19. package/dist/features/access/org-structure.access.d.ts.map +1 -0
  20. package/dist/features/access/org-structure.access.js +41 -0
  21. package/dist/features/access/user-management.access.d.ts.map +1 -1
  22. package/dist/features/access/user-management.access.js +5 -0
  23. package/dist/features/audit/audit-read.service.d.ts +32 -1
  24. package/dist/features/audit/audit-read.service.d.ts.map +1 -1
  25. package/dist/features/audit/audit-read.service.js +67 -14
  26. package/dist/features/audit/auth-audit-sink.d.ts +19 -0
  27. package/dist/features/audit/auth-audit-sink.d.ts.map +1 -0
  28. package/dist/features/audit/auth-audit-sink.js +24 -0
  29. package/dist/features/audit/directory-label-resolver.d.ts +10 -1
  30. package/dist/features/audit/directory-label-resolver.d.ts.map +1 -1
  31. package/dist/features/audit/directory-label-resolver.js +28 -1
  32. package/dist/features/authz/grant-admin.controller.d.ts +4 -4
  33. package/dist/features/authz/grant-admin.controller.js +4 -4
  34. package/dist/features/authz/grant-admin.service.d.ts +9 -8
  35. package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
  36. package/dist/features/authz/grant-admin.service.js +20 -24
  37. package/dist/features/authz/grant-ceiling.d.ts +35 -16
  38. package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
  39. package/dist/features/authz/grant-ceiling.js +44 -29
  40. package/dist/features/authz/role-binding.d.ts +25 -24
  41. package/dist/features/authz/role-binding.d.ts.map +1 -1
  42. package/dist/features/authz/role-binding.js +31 -44
  43. package/dist/features/identity-feature.module.d.ts +0 -11
  44. package/dist/features/identity-feature.module.d.ts.map +1 -1
  45. package/dist/features/identity-feature.module.js +90 -1
  46. package/dist/features/membership/membership.controller.d.ts.map +1 -1
  47. package/dist/features/membership/membership.controller.js +20 -6
  48. package/dist/features/membership/membership.service.d.ts +16 -10
  49. package/dist/features/membership/membership.service.d.ts.map +1 -1
  50. package/dist/features/membership/membership.service.js +61 -21
  51. package/dist/features/membership/posting.service.d.ts +25 -6
  52. package/dist/features/membership/posting.service.d.ts.map +1 -1
  53. package/dist/features/membership/posting.service.js +63 -16
  54. package/dist/features/org-bootstrap/org-bootstrap-file.d.ts +6 -2
  55. package/dist/features/org-bootstrap/org-bootstrap-file.d.ts.map +1 -1
  56. package/dist/features/org-bootstrap/org-bootstrap-file.js +54 -11
  57. package/dist/features/org-bootstrap/org-bootstrap-plan.d.ts +30 -11
  58. package/dist/features/org-bootstrap/org-bootstrap-plan.d.ts.map +1 -1
  59. package/dist/features/org-bootstrap/org-bootstrap-plan.js +172 -68
  60. package/dist/features/org-bootstrap/org-state.reader.d.ts.map +1 -1
  61. package/dist/features/org-bootstrap/org-state.reader.js +56 -49
  62. package/dist/features/org-bootstrap/run-org-bootstrap.d.ts +6 -1
  63. package/dist/features/org-bootstrap/run-org-bootstrap.d.ts.map +1 -1
  64. package/dist/features/org-bootstrap/run-org-bootstrap.js +81 -92
  65. package/dist/features/org-structure/org-catalog.controller.d.ts +29 -6
  66. package/dist/features/org-structure/org-catalog.controller.d.ts.map +1 -1
  67. package/dist/features/org-structure/org-catalog.controller.js +105 -17
  68. package/dist/features/org-structure/org-catalog.service.d.ts +99 -18
  69. package/dist/features/org-structure/org-catalog.service.d.ts.map +1 -1
  70. package/dist/features/org-structure/org-catalog.service.js +327 -51
  71. package/dist/features/org-structure/org-unit-name.d.ts +31 -0
  72. package/dist/features/org-structure/org-unit-name.d.ts.map +1 -0
  73. package/dist/features/org-structure/org-unit-name.js +50 -0
  74. package/dist/features/role/role.controller.d.ts +28 -13
  75. package/dist/features/role/role.controller.d.ts.map +1 -1
  76. package/dist/features/role/role.controller.js +54 -14
  77. package/dist/features/role/role.service.d.ts +76 -21
  78. package/dist/features/role/role.service.d.ts.map +1 -1
  79. package/dist/features/role/role.service.js +204 -27
  80. package/dist/features/security-policy/security-policy-version-bump.d.ts +41 -0
  81. package/dist/features/security-policy/security-policy-version-bump.d.ts.map +1 -0
  82. package/dist/features/security-policy/security-policy-version-bump.js +65 -0
  83. package/dist/features/security-policy/security-policy.controller.d.ts +51 -0
  84. package/dist/features/security-policy/security-policy.controller.d.ts.map +1 -0
  85. package/dist/features/security-policy/security-policy.controller.js +121 -0
  86. package/dist/features/security-policy/security-policy.service.d.ts +94 -0
  87. package/dist/features/security-policy/security-policy.service.d.ts.map +1 -0
  88. package/dist/features/security-policy/security-policy.service.js +429 -0
  89. package/dist/features/service-account/service-account.service.d.ts +13 -3
  90. package/dist/features/service-account/service-account.service.d.ts.map +1 -1
  91. package/dist/features/service-account/service-account.service.js +48 -10
  92. package/dist/features/service-account/service-account.store.d.ts +11 -1
  93. package/dist/features/service-account/service-account.store.d.ts.map +1 -1
  94. package/dist/features/service-account/service-account.store.js +20 -1
  95. package/dist/features/user/signin-admin.tokens.d.ts +11 -0
  96. package/dist/features/user/signin-admin.tokens.d.ts.map +1 -0
  97. package/dist/features/user/signin-admin.tokens.js +13 -0
  98. package/dist/features/user/user-mfa-status.d.ts +38 -0
  99. package/dist/features/user/user-mfa-status.d.ts.map +1 -0
  100. package/dist/features/user/user-mfa-status.js +47 -0
  101. package/dist/features/user/user-signin-admin.service.d.ts +48 -0
  102. package/dist/features/user/user-signin-admin.service.d.ts.map +1 -0
  103. package/dist/features/user/user-signin-admin.service.js +232 -0
  104. package/dist/features/user/user.controller.d.ts +29 -2
  105. package/dist/features/user/user.controller.d.ts.map +1 -1
  106. package/dist/features/user/user.controller.js +71 -3
  107. package/dist/features/user/user.service.d.ts +40 -14
  108. package/dist/features/user/user.service.d.ts.map +1 -1
  109. package/dist/features/user/user.service.js +143 -10
  110. package/dist/index.d.ts +1 -0
  111. package/dist/index.d.ts.map +1 -1
  112. package/dist/index.js +3 -1
  113. package/dist/objects.d.ts.map +1 -1
  114. package/dist/objects.js +2 -0
  115. package/dist/role.object.d.ts +1 -1
  116. package/dist/role.object.d.ts.map +1 -1
  117. package/dist/role.object.js +6 -15
  118. package/dist/security-policy.object.d.ts +43 -0
  119. package/dist/security-policy.object.d.ts.map +1 -0
  120. package/dist/security-policy.object.js +107 -0
  121. package/dist/user.object.d.ts +6 -0
  122. package/dist/user.object.d.ts.map +1 -1
  123. package/dist/user.object.js +13 -1
  124. package/migrations/1710000000102-CreateAuditChain.cjs +71 -0
  125. package/migrations/1710000000103-AddUserLoginName.cjs +87 -0
  126. package/migrations/1710000000104-CreateSecurityPolicy.cjs +90 -0
  127. package/migrations/1710000000105-OwnDepartmentsAndBranches.cjs +327 -0
  128. package/migrations/1710000000106-OwnRolesPerCompany.cjs +622 -0
  129. package/package.json +9 -4
@@ -1,52 +1,39 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.subtreeExists = subtreeExists;
4
- exports.readRoleBinding = readRoleBinding;
3
+ exports.ROLE_NOT_IN_COMPANY = void 0;
4
+ exports.ownedInSubtree = ownedInSubtree;
5
+ exports.readRoleOwner = readRoleOwner;
6
+ exports.ownerInSubtree = ownerInSubtree;
7
+ const http_js_1 = require("../shared/http.js");
8
+ /** The 400 of a posting or service-account role owned by another company (G-013, spec §10). */
9
+ exports.ROLE_NOT_IN_COMPANY = "The role is not one of this company's roles.";
5
10
  /**
6
- * The shared EXISTS-subtree fragment for a non-root caller's READS (Task 4.2): a role row `r`
7
- * qualifies only if it is bound (`membership_posting → company_membership`) to a node in the
8
- * subtree. The caller splices `params` where `sql` sits in the surrounding query.
11
+ * The grants-summary READ fragment for a non-root caller (G-013): a role row `r` qualifies only if
12
+ * its owner company is in the caller's subtree (the same owner rule as every other role route;
13
+ * before G-013 it was "posted to a node in the subtree", which hid owned roles nobody held yet). An
14
+ * empty subtree matches nothing. The caller splices `params` where `sql` sits in the query.
9
15
  */
10
- function subtreeExists(nodeIds, schemas) {
16
+ function ownedInSubtree(nodeIds) {
17
+ if (nodeIds.length === 0)
18
+ return { sql: ' AND 1 = 0', params: [] };
11
19
  const ph = nodeIds.map(() => '?').join(', ');
12
- const sql = ` AND EXISTS (` +
13
- `SELECT 1 FROM \`${schemas.app}\`.\`membership_posting\` mp` +
14
- ` JOIN \`${schemas.app}\`.\`company_membership\` cm ON cm.cm_uuid = mp.mp_membership_uuid` +
15
- ` WHERE mp.mp_role_uuid = r.r_uuid AND cm.cm_company_uuid IN (${ph}))`;
16
- return { sql, params: [...nodeIds] };
20
+ return { sql: ` AND r.r_owner_company_uuid IN (${ph})`, params: [...nodeIds] };
17
21
  }
18
- /** Coerce a driver aggregate (number | bigint | string | null) to a boolean. */
19
- function truthy(v) {
20
- if (v === null || v === undefined)
21
- return false;
22
- if (typeof v === 'bigint')
23
- return v !== 0n;
24
- return Number(v) > 0;
25
- }
26
- /**
27
- * Read where `roleUuid` is bound relative to `nodeIds` (human postings + service accounts) in ONE
28
- * query. An empty `nodeIds` is treated as "nothing is inside" (every binding is outside) — fail
29
- * closed, never an `IN ()` syntax error.
30
- */
31
- async function readRoleBinding(q, roleUuid, nodeIds, schemas) {
32
- const ids = nodeIds.length > 0 ? [...nodeIds] : [];
33
- const inList = ids.length > 0 ? ids.map(() => '?').join(', ') : 'NULL';
34
- // `x IN (NULL)` is NULL (never true) and `x NOT IN (NULL)` is NULL too, so for an empty list the
35
- // outside test is written as a plain existence check instead.
36
- const insideExpr = ids.length > 0 ? `b.node IN (${inList})` : '0';
37
- const outsideExpr = ids.length > 0 ? `b.node NOT IN (${inList})` : '1';
38
- const rows = (await q.query(`SELECT COALESCE(MAX(${insideExpr}), 0) AS bound_inside,
39
- COALESCE(MAX(${outsideExpr}), 0) AS bound_outside
40
- FROM (
41
- SELECT cm.cm_company_uuid AS node
42
- FROM \`${schemas.app}\`.\`membership_posting\` mp
43
- JOIN \`${schemas.app}\`.\`company_membership\` cm ON cm.cm_uuid = mp.mp_membership_uuid
44
- WHERE mp.mp_role_uuid = ?
45
- UNION ALL
46
- SELECT sa.sa_tenant_id AS node
47
- FROM \`${schemas.app}\`.\`service_account\` sa
48
- WHERE JSON_CONTAINS(sa.sa_role_ids, JSON_QUOTE(?))
49
- ) b`, [...ids, ...ids, roleUuid, roleUuid]));
22
+ /** Read `roleUuid`'s owner company on `q` (a pool or a transaction connection). */
23
+ async function readRoleOwner(q, roleUuid, schemas) {
24
+ const rows = (await q.query(`SELECT r_owner_company_uuid AS owner FROM \`${schemas.app}\`.\`roles\` WHERE r_uuid = ? LIMIT 1`, [roleUuid]));
50
25
  const row = rows[0];
51
- return { inside: truthy(row?.bound_inside), outside: truthy(row?.bound_outside) };
26
+ if (row === undefined)
27
+ return { exists: false, owner: null };
28
+ return {
29
+ exists: true,
30
+ owner: typeof row.owner === 'string' && row.owner.length > 0 ? row.owner : null,
31
+ };
32
+ }
33
+ /** Is the stored role owned by a node of `nodeIds`? An ownerless or missing role never is. */
34
+ function ownerInSubtree(role, nodeIds) {
35
+ if (!role.exists || role.owner === null)
36
+ return false;
37
+ const owner = role.owner;
38
+ return nodeIds.some((n) => (0, http_js_1.sameUuid)(n, owner));
52
39
  }
@@ -37,17 +37,6 @@ export declare function assertFrameworkDatabaseAgrees(identityDatabase: string,
37
37
  * explicit `APP_DB_USER` / `APP_DB_NAME` still wins (QA P2-5).
38
38
  */
39
39
  export declare function engineDbFromEnv(schemas: FrameworkSchemas, env?: NodeJS.ProcessEnv): EngineDbConfig;
40
- /**
41
- * IdentityFeatureModule — the framework-owned identity FEATURE surface (company/user/role CRUD;
42
- * more in later phases) as thin controllers over the engine's RecordsService. The CONSUMER imports
43
- * `IdentityFeatureModule.forRoot()` alongside `ShieldModule.forRoot(...)` (ADR-0026) — @astram/guard
44
- * never imports @astram/identity. `forRoot()` SELF-WIRES the engine (EngineModule.forRoot over the
45
- * app's APP_DB_* env) + registers the identity objects + mounts the CRUD routes, so the consumer
46
- * needs no separate engine setup.
47
- *
48
- * CONTROLLER ORDER: RoleController is kept ahead of any future grant-admin controller (Phase 4) with
49
- * a conflicting `GET identity/roles/:uuid` path — a Nest route-precedence constraint.
50
- */
51
40
  export declare class IdentityFeatureModule {
52
41
  /**
53
42
  * @param rootIds the framework's root allowlist — pass the SAME array given to
@@ -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,EAGL,KAAK,gBAAgB,EAEtB,MAAM,cAAc,CAAC;AACtB,OAAO,EAYL,KAAK,cAAc,EAGnB,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AAiDxB,OAAO,EAAoB,KAAK,aAAa,EAAE,MAAM,yBAAyB,CAAC;AA0E/E;;;;;;GAMG;AACH,wBAAgB,0CAA0C,CAAC,SAAS,EAAE,OAAO,GAAG,IAAI,CAUnF;AAKD;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,gBAAgB,EAC1B,KAAK,EAAE,gBAAgB,GAAG,IAAI,GAAG,SAAS,GACzC,IAAI,CAwBN;AAKD;;;;;;GAMG;AACH,wBAAgB,6BAA6B,CAC3C,gBAAgB,EAAE,MAAM,EACxB,WAAW,EAAE;IAAE,QAAQ,CAAC,EAAE,CAAC,EAAE;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,GAC/E,IAAI,CAQN;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,gBAAgB,EACzB,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,cAAc,CAqBhB;AAED;;;;;;;;;;GAUG;AACH,qBACa,qBAAqB;IAChC;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,MAAM,CAAC,OAAO,CAAC,EACb,OAAO,EACP,YAAiB,EACjB,OAAO,EAAE,YAAY,EACrB,MAAM,GACP,GAAE;QACD,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;QAC5B,YAAY,CAAC,EAAE,SAAS,eAAe,EAAE,CAAC;QAC1C,OAAO,CAAC,EAAE,gBAAgB,CAAC;QAC3B,MAAM,CAAC,EAAE,aAAa,CAAC;KACnB,GAAG,aAAa;CA+lBvB"}
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,EAGL,KAAK,gBAAgB,EAEtB,MAAM,cAAc,CAAC;AACtB,OAAO,EAYL,KAAK,cAAc,EAGnB,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AAwDxB,OAAO,EAAoB,KAAK,aAAa,EAAE,MAAM,yBAAyB,CAAC;AA0G/E;;;;;;GAMG;AACH,wBAAgB,0CAA0C,CAAC,SAAS,EAAE,OAAO,GAAG,IAAI,CAUnF;AAKD;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,gBAAgB,EAC1B,KAAK,EAAE,gBAAgB,GAAG,IAAI,GAAG,SAAS,GACzC,IAAI,CAwBN;AAKD;;;;;;GAMG;AACH,wBAAgB,6BAA6B,CAC3C,gBAAgB,EAAE,MAAM,EACxB,WAAW,EAAE;IAAE,QAAQ,CAAC,EAAE,CAAC,EAAE;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,GAC/E,IAAI,CAQN;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,gBAAgB,EACzB,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,cAAc,CAqBhB;AA2BD,qBACa,qBAAqB;IAChC;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,MAAM,CAAC,OAAO,CAAC,EACb,OAAO,EACP,YAAiB,EACjB,OAAO,EAAE,YAAY,EACrB,MAAM,GACP,GAAE;QACD,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;QAC5B,YAAY,CAAC,EAAE,SAAS,eAAe,EAAE,CAAC;QAC1C,OAAO,CAAC,EAAE,gBAAgB,CAAC;QAC3B,MAAM,CAAC,EAAE,aAAa,CAAC;KACnB,GAAG,aAAa;CA6pBvB"}
@@ -34,6 +34,9 @@ const user_controller_js_1 = require("./user/user.controller.js");
34
34
  const invite_service_js_1 = require("./user/invite.service.js");
35
35
  const invite_config_js_1 = require("./user/invite.config.js");
36
36
  const invite_tokens_js_1 = require("./user/invite.tokens.js");
37
+ const user_signin_admin_service_js_1 = require("./user/user-signin-admin.service.js");
38
+ const user_mfa_status_js_1 = require("./user/user-mfa-status.js");
39
+ const signin_admin_tokens_js_1 = require("./user/signin-admin.tokens.js");
37
40
  const membership_controller_js_1 = require("./membership/membership.controller.js");
38
41
  const membership_service_js_1 = require("./membership/membership.service.js");
39
42
  const posting_service_js_1 = require("./membership/posting.service.js");
@@ -51,6 +54,9 @@ const grant_admin_service_js_1 = require("./authz/grant-admin.service.js");
51
54
  const grant_admin_controller_js_1 = require("./authz/grant-admin.controller.js");
52
55
  const grant_ceiling_js_1 = require("./authz/grant-ceiling.js");
53
56
  const policy_service_js_1 = require("./policy/policy.service.js");
57
+ const security_policy_service_js_1 = require("./security-policy/security-policy.service.js");
58
+ const security_policy_controller_js_1 = require("./security-policy/security-policy.controller.js");
59
+ const security_policy_version_bump_js_1 = require("./security-policy/security-policy-version-bump.js");
54
60
  const policy_controller_js_1 = require("./policy/policy.controller.js");
55
61
  const share_service_js_1 = require("./share/share.service.js");
56
62
  const share_controller_js_1 = require("./share/share.controller.js");
@@ -65,6 +71,7 @@ const mariadb_audit_reader_js_1 = require("./audit/mariadb-audit-reader.js");
65
71
  const directory_label_resolver_js_1 = require("./audit/directory-label-resolver.js");
66
72
  const audit_read_service_js_1 = require("./audit/audit-read.service.js");
67
73
  const audit_controller_js_1 = require("./audit/audit.controller.js");
74
+ const auth_audit_sink_js_1 = require("./audit/auth-audit-sink.js");
68
75
  const cross_tenant_grant_read_service_js_1 = require("./cross-tenant/cross-tenant-grant-read.service.js");
69
76
  const cross_tenant_grant_controller_js_1 = require("./cross-tenant/cross-tenant-grant.controller.js");
70
77
  const grant_authorize_js_1 = require("./cross-tenant/grant-authorize.js");
@@ -87,6 +94,28 @@ let IdentityRecordAuthorizationModule = class IdentityRecordAuthorizationModule
87
94
  IdentityRecordAuthorizationModule = __decorate([
88
95
  (0, common_1.Module)({})
89
96
  ], IdentityRecordAuthorizationModule);
97
+ /**
98
+ * Holds the default auth audit sink (R12.1, B-3), provided GLOBALLY under guard's
99
+ * `SHIELD_DEFAULT_AUTH_AUDIT_SINK` so guard's `AuthModule` (a sibling that never imports identity)
100
+ * injects it optionally for `SHIELD_AUDIT_SINK` when the app supplied no `auditSink`. Only this
101
+ * token is global.
102
+ */
103
+ let IdentityDefaultAuthAuditSinkModule = class IdentityDefaultAuthAuditSinkModule {
104
+ };
105
+ IdentityDefaultAuthAuditSinkModule = __decorate([
106
+ (0, common_1.Module)({})
107
+ ], IdentityDefaultAuthAuditSinkModule);
108
+ /**
109
+ * Signals guard that the security-policy feature is registered (auth capabilities R2, task 14):
110
+ * provided GLOBALLY under guard's `SHIELD_MFA_POLICY_FEATURE`, so guard's `AuthModule` turns on
111
+ * mandatory-2FA evaluation (login, request-path recompute, device confirm and removal). Only this
112
+ * token is global.
113
+ */
114
+ let IdentityMfaPolicyFeatureModule = class IdentityMfaPolicyFeatureModule {
115
+ };
116
+ IdentityMfaPolicyFeatureModule = __decorate([
117
+ (0, common_1.Module)({})
118
+ ], IdentityMfaPolicyFeatureModule);
90
119
  /**
91
120
  * The boot check behind {@link IDENTITY_DECISION_AUTHORITY_CHECK}: with this module wired, the
92
121
  * request path must decide with the authority guard built from this module's inputs. A missing
@@ -187,6 +216,14 @@ function engineDbFromEnv(schemas, env = process.env) {
187
216
  * CONTROLLER ORDER: RoleController is kept ahead of any future grant-admin controller (Phase 4) with
188
217
  * a conflicting `GET identity/roles/:uuid` path — a Nest route-precedence constraint.
189
218
  */
219
+ /** R11.3: guard's mandatory-2FA service, when bound (`null` unless the policy feature is on). */
220
+ function hasStatusesFor(v) {
221
+ return typeof v?.statusesFor === 'function';
222
+ }
223
+ /** R11.3: guard's MariaDB requirement source with its batched confirmed-device read. */
224
+ function hasConfirmedFactorsOf(v) {
225
+ return typeof v?.confirmedFactorsOf === 'function';
226
+ }
190
227
  let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModule {
191
228
  /**
192
229
  * @param rootIds the framework's root allowlist — pass the SAME array given to
@@ -290,7 +327,8 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
290
327
  const rows = (await authzPool.query(`SELECT u_uuid AS id FROM \`${schemas.app}\`.\`users\` WHERE u_username = ? LIMIT 1`, [name]));
291
328
  return rows[0]?.id ?? null;
292
329
  };
293
- const auditReadService = new audit_read_service_js_1.AuditReadService(auditReader, auditLabels, auditNameToId);
330
+ // KI #140: the same resolver tells users from service accounts (operations need no lookup).
331
+ const auditReadService = new audit_read_service_js_1.AuditReadService(auditReader, auditLabels, auditNameToId, auditLabels);
294
332
  // Cross-tenant grants (Phase 6): the ENTIRE offer/accept/revoke surface, SELF-WIRED here
295
333
  // over the SAME shared authzPool (no consumer code — astram3 hand-built this in its consumer
296
334
  // authz.module, the anti-pattern this replaces). A SECOND DecisionAuthority is built over the
@@ -526,6 +564,10 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
526
564
  const filesAudit = {
527
565
  record: (event) => filesAuditSink.record(event, (sql, params) => authzPool.query(sql, params)),
528
566
  };
567
+ // R12.1 / B-3: guard's auth events (sign-in, failure, logout, MFA devices) default to the SAME
568
+ // `<audit>.audit_log` (sealed into the per-tenant chain by guard's sealer, ADR-0044). Each row
569
+ // is one autocommit write on the shared pool, outside any business transaction.
570
+ const defaultAuthAuditSink = (0, auth_audit_sink_js_1.createDefaultAuthAuditSink)(new engine_1.MariaDbAuditSink(auditSinkConfig), (sql, params) => authzPool.query(sql, params));
529
571
  return {
530
572
  module: IdentityFeatureModule_1,
531
573
  imports: [
@@ -556,6 +598,20 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
556
598
  providers: [{ provide: guard_1.SHIELD_RECORD_AUTHORIZATION, useValue: recordAuthorization }],
557
599
  exports: [guard_1.SHIELD_RECORD_AUTHORIZATION],
558
600
  },
601
+ // R12.1 / B-3: the default auth audit sink, global so guard's AuthModule sees it.
602
+ {
603
+ module: IdentityDefaultAuthAuditSinkModule,
604
+ global: true,
605
+ providers: [{ provide: internal_1.SHIELD_DEFAULT_AUTH_AUDIT_SINK, useValue: defaultAuthAuditSink }],
606
+ exports: [internal_1.SHIELD_DEFAULT_AUTH_AUDIT_SINK],
607
+ },
608
+ // Auth capabilities R2: the security-policy feature is registered, so guard evaluates it.
609
+ {
610
+ module: IdentityMfaPolicyFeatureModule,
611
+ global: true,
612
+ providers: [{ provide: internal_1.SHIELD_MFA_POLICY_FEATURE, useValue: true }],
613
+ exports: [internal_1.SHIELD_MFA_POLICY_FEATURE],
614
+ },
559
615
  ],
560
616
  // Route order: RoleController stays AHEAD of GrantAdminController (documented Nest
561
617
  // route-precedence constraint — the summary route `access/role-grants` dodges
@@ -583,6 +639,9 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
583
639
  // Stored-file download (file-storage SPEC §3) — `files/:uuid` collides with nothing.
584
640
  // Gated on `stored_file:read`; answers 501 when no object store is bound. Appended last.
585
641
  files_controller_js_1.FilesController,
642
+ // Mandatory-2FA policies (auth capabilities R2.9) — `security-policies/*` collides with
643
+ // nothing. Gated on `security_policy:<action>`. Appended last.
644
+ security_policy_controller_js_1.SecurityPolicyController,
586
645
  ],
587
646
  providers: [
588
647
  company_service_js_1.CompanyService,
@@ -593,6 +652,13 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
593
652
  org_catalog_service_js_1.OrgCatalogService,
594
653
  record_resolver_js_1.RecordResolverService,
595
654
  { provide: posting_version_bump_js_1.POSTING_VERSION_BUMP, useValue: postingVersionBump },
655
+ // Mandatory-2FA policies (R2.6, R2.9): the service, and its permission-version bump over
656
+ // the ONE shared bumper (bumps on the write's transaction, published after the commit).
657
+ security_policy_service_js_1.SecurityPolicyService,
658
+ {
659
+ provide: security_policy_version_bump_js_1.SECURITY_POLICY_VERSION_BUMP,
660
+ useValue: new security_policy_version_bump_js_1.SecurityPolicyVersionBump(bumper, schemas),
661
+ },
596
662
  // Phase 12 (KI #14): the admin email change goes through guard's ONE email-change helper
597
663
  // (collision refusal + login-hint follow in one transaction), over the shared authz pool.
598
664
  {
@@ -736,6 +802,29 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
736
802
  : null,
737
803
  inject: [{ token: internal_1.SHIELD_ATTEMPT_LIMITER_FACTORY, optional: true }],
738
804
  },
805
+ // Auth capabilities R11 (task 18): admin sign-in controls (reset 2FA, unlock, end sessions).
806
+ // The resetter is guard's class over the ONE shared runtime pool (the MFA tables are not the
807
+ // engine's), with the `auth.mfa.reset_by_admin` row on its transaction; the unlock and
808
+ // end-sessions rows go through the same audit writer as `user.invited`.
809
+ user_signin_admin_service_js_1.UserSignInAdminService,
810
+ { provide: signin_admin_tokens_js_1.IDENTITY_MFA_RESETTER, useValue: new internal_1.MariaDbMfaResetter(authzPool, schemas) },
811
+ { provide: signin_admin_tokens_js_1.IDENTITY_SIGNIN_ADMIN_AUDIT, useValue: filesAudit },
812
+ // R11.3: the users list's batched `mfaStatus` (guard's service when the policy feature is on,
813
+ // else confirmed devices only; nothing wired ⇒ null and rows carry no mfaStatus).
814
+ {
815
+ provide: signin_admin_tokens_js_1.IDENTITY_MFA_STATUS_READER,
816
+ useFactory: (service, source) => (0, user_mfa_status_js_1.createUserMfaStatusReader)({
817
+ query: (sql, params) => authzPool.query(sql, params),
818
+ schemas,
819
+ rootIds: rootIds ?? [],
820
+ service: hasStatusesFor(service) ? service : null,
821
+ devices: hasConfirmedFactorsOf(source) ? source : null,
822
+ }),
823
+ inject: [
824
+ { token: internal_1.SHIELD_MFA_REQUIREMENT, optional: true },
825
+ { token: internal_1.SHIELD_MFA_REQUIREMENT_SOURCE, optional: true },
826
+ ],
827
+ },
739
828
  {
740
829
  provide: files_service_js_1.FilesService,
741
830
  useFactory: (records, store) => new files_service_js_1.FilesService(records, store ?? null, filesAudit),
@@ -1 +1 @@
1
- {"version":3,"file":"membership.controller.d.ts","sourceRoot":"","sources":["../../../src/features/membership/membership.controller.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAC5D,OAAO,EAAE,cAAc,EAAE,KAAK,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACzE,OAAO,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AACrE,OAAO,EAAqB,KAAK,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAG1E,iFAAiF;AACjF,UAAU,iBAAiB;IACzB,uFAAuF;IACvF,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,cAAc,EAAE,CAAC;CAC5B;AAED,4EAA4E;AAC5E,UAAU,cAAc;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,kFAAkF;IAClF,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/B;AAYD,qBAEa,oBAAoB;IAE7B,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC5B,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,QAAQ;gBAFR,WAAW,EAAE,iBAAiB,EAC9B,QAAQ,EAAE,cAAc,EACxB,QAAQ,EAAE,qBAAqB;IAGlD;;;;;OAKG;IAGG,mBAAmB,CAA8B,QAAQ,EAAE,MAAM;;;IA0CvE,uEAAuE;IAGjE,iBAAiB,CACd,GAAG,EAAE,aAAa,EACI,QAAQ,EAAE,MAAM,EACrC,IAAI,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE;;;IAUxC;;;;OAIG;IAGG,gBAAgB,CACb,GAAG,EAAE,aAAa,EACU,cAAc,EAAE,MAAM,EACjD,IAAI,EAAE;QAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE;;;IAoB1E,2FAA2F;IAGrF,gBAAgB,CACb,GAAG,EAAE,aAAa,EACU,cAAc,EAAE,MAAM;;;IAM3D,0EAA0E;IAGpE,eAAe,CACZ,GAAG,EAAE,aAAa,EACU,cAAc,EAAE,MAAM,EACjD,IAAI,EAAE;QAAE,QAAQ,CAAC,EAAE,YAAY,EAAE,CAAA;KAAE;;;IA4B7C;;;;OAIG;YACW,WAAW;CAM1B"}
1
+ {"version":3,"file":"membership.controller.d.ts","sourceRoot":"","sources":["../../../src/features/membership/membership.controller.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAC5D,OAAO,EAAE,cAAc,EAAE,KAAK,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACzE,OAAO,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AACrE,OAAO,EAAqB,KAAK,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAI1E,iFAAiF;AACjF,UAAU,iBAAiB;IACzB,uFAAuF;IACvF,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,cAAc,EAAE,CAAC;CAC5B;AAED,4EAA4E;AAC5E,UAAU,cAAc;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,kFAAkF;IAClF,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/B;AAYD,qBAEa,oBAAoB;IAE7B,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC5B,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,QAAQ;gBAFR,WAAW,EAAE,iBAAiB,EAC9B,QAAQ,EAAE,cAAc,EACxB,QAAQ,EAAE,qBAAqB;IAGlD;;;;;OAKG;IAGG,mBAAmB,CAA8B,QAAQ,EAAE,MAAM;;;IA0CvE,uEAAuE;IAGjE,iBAAiB,CACd,GAAG,EAAE,aAAa,EACI,QAAQ,EAAE,MAAM,EACrC,IAAI,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE;;;IAUxC;;;;OAIG;IAGG,gBAAgB,CACb,GAAG,EAAE,aAAa,EACU,cAAc,EAAE,MAAM,EACjD,IAAI,EAAE;QAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE;;;IA6B1E,2FAA2F;IAGrF,gBAAgB,CACb,GAAG,EAAE,aAAa,EACU,cAAc,EAAE,MAAM;;;IAM3D,0EAA0E;IAGpE,eAAe,CACZ,GAAG,EAAE,aAAa,EACU,cAAc,EAAE,MAAM,EACjD,IAAI,EAAE;QAAE,QAAQ,CAAC,EAAE,YAAY,EAAE,CAAA;KAAE;;;IA4B7C;;;;OAIG;YACW,WAAW;CAM1B"}
@@ -20,6 +20,7 @@ const posting_service_js_1 = require("./posting.service.js");
20
20
  const record_resolver_js_1 = require("../shared/record-resolver.js");
21
21
  const http_js_1 = require("../shared/http.js");
22
22
  const identity_error_interceptor_js_1 = require("../shared/identity-error.interceptor.js");
23
+ const identity_errors_js_1 = require("../shared/identity-errors.js");
23
24
  // ── Memberships + postings ───────────────────────────────────────────────────
24
25
  //
25
26
  // Gated on the `company_membership` / `membership_posting` grants via `@Classified`
@@ -92,16 +93,29 @@ let MembershipController = class MembershipController {
92
93
  * the invariant can deactivate the user's OTHER memberships.
93
94
  */
94
95
  async updateMembership(req, membershipUuid, body) {
95
- if (body?.isActive === true) {
96
+ const activate = body?.isActive === true;
97
+ const status = typeof body?.status === 'string' ? body.status : undefined;
98
+ // G-011: the active membership has status `active`, so this pair can never succeed.
99
+ // Refused before any write.
100
+ if (activate && status !== undefined && status !== 'active') {
101
+ throw new identity_errors_js_1.IdentityBadRequestError('isActive and a non-active status cannot be set together');
102
+ }
103
+ let userUuid;
104
+ if (activate) {
96
105
  if (!body.userUuid) {
97
106
  throw new common_1.ForbiddenException('userUuid is required to activate a membership');
98
107
  }
99
- await this.memberships.setActive(req.principal, membershipUuid, (0, guard_1.uuidField)(body.userUuid, 'userUuid'));
108
+ userUuid = (0, guard_1.uuidField)(body.userUuid, 'userUuid');
100
109
  }
101
- if (typeof body?.status === 'string') {
102
- await this.memberships.updateMembership(req.principal, membershipUuid, {
103
- status: body.status,
104
- });
110
+ // Status `active` first, so one body can set it and then activate.
111
+ if (status === 'active') {
112
+ await this.memberships.updateMembership(req.principal, membershipUuid, { status });
113
+ }
114
+ if (userUuid !== undefined) {
115
+ await this.memberships.setActive(req.principal, membershipUuid, userUuid);
116
+ }
117
+ if (status !== undefined && status !== 'active') {
118
+ await this.memberships.updateMembership(req.principal, membershipUuid, { status });
105
119
  }
106
120
  return { success: true };
107
121
  }
@@ -9,6 +9,12 @@
9
9
  * (exactly one active per user) + `status`. This service owns the ONE invariant the DB
10
10
  * cannot express — one-active-per-user — because there is no partial-unique on
11
11
  * `cm_is_active`; `setActive` is the guard.
12
+ *
13
+ * Second invariant (G-011, F2): the active membership always has status exactly `active`.
14
+ * Grants read `cm_is_active = 1`, while scope-switch and the profile writer also require
15
+ * `cm_status = 'active'`; keeping the two in step on every write makes the readers agree.
16
+ * `setActive` refuses a target whose status is not `active`, and `updateMembership` refuses
17
+ * moving the active membership off `active`; each checks under a row lock in one transaction.
12
18
  */
13
19
  import { RecordsService, type RecordId, type RecordView } from '@astram/engine';
14
20
  import type { Principal } from '@astram/core';
@@ -56,16 +62,16 @@ export declare class MembershipService {
56
62
  * Flip which of the user's memberships is active — the ONE-ACTIVE invariant. The service
57
63
  * is the guard because the DB has no partial-unique on `cm_is_active`.
58
64
  *
59
- * ⚠️ ORDER IS LOAD-BEARING (fail-safe against a LOGIN LOCKOUT). Each `update` is its OWN
60
- * transaction (no cross-call atomicity), and the engine's `list` order (PK-ordered since the
61
- * Phase 3 tiebreaker) says nothing about which membership is the target. So this MUST
62
- * activate the TARGET FIRST, then deactivate the others:
63
- * - Worst case of a mid-loop failure is then a transient TWO-active window, which is
64
- * RECOVERABLE — the credential resolver reads `is_active=1 LIMIT 1`, so it still
65
- * resolves a tenant and login works.
66
- * - The forbidden alternative (deactivate-old-then-activate-target) can leave a
67
- * ZERO-active window on a mid-failure → the resolver returns null → the user is
68
- * LOCKED OUT of login until an admin re-activates. Never do that.
65
+ * The whole flip commits in ONE transaction (G-011), so a mid-loop failure rolls back and the
66
+ * user keeps the activation they had. Inside it:
67
+ * - every membership of the user is locked in id order, then re-listed, so two concurrent
68
+ * flips for the same user queue instead of deadlocking, and a membership added since the
69
+ * pre-check is still deactivated;
70
+ * - the target's status is re-read under its lock: only status `active` may be activated;
71
+ * - the TARGET is activated FIRST, then the others are deactivated. Atomicity makes the
72
+ * order a defence in depth now, but keep it: deactivate-first is the order that can leave
73
+ * ZERO active (a login lockout: the credential resolver finds no tenant) if atomicity is
74
+ * ever lost.
69
75
  *
70
76
  * Guard: if `membershipUuid` is NOT one of the user's memberships, throw — otherwise the
71
77
  * loop would deactivate everything and activate nothing (also a zero-active lockout).
@@ -1 +1 @@
1
- {"version":3,"file":"membership.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/membership.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAChF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAwB,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAE1F,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AASzD,qBACa,iBAAiB;IAE1B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAGxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAQxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;gBAXZ,OAAO,EAAE,cAAc,EAGvB,OAAO,EAAE,YAAY,EAQrB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA;IAG1D;;;;OAIG;YACW,WAAW;IAQzB;;;;;;OAMG;YACW,WAAW;IAYzB;;;;;;OAMG;IACG,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAkB/F;;;;;;;OAOG;IACG,mBAAmB,CACvB,KAAK,EAAE,SAAS,EAChB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,QAAQ,CAAC;IAwBpB,kEAAkE;IAC5D,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,UAAU,EAAE,CAAC;IAKvE;;;;;;;;;;;;;;;;;OAiBG;IACG,SAAS,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAsC1F;;;;OAIG;IACG,gBAAgB,CACpB,KAAK,EAAE,SAAS,EAChB,cAAc,EAAE,MAAM,EACtB,KAAK,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GACzB,OAAO,CAAC,IAAI,CAAC;IAchB;;;OAGG;IACG,gBAAgB,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAYhF"}
1
+ {"version":3,"file":"membership.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/membership.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAGH,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAChF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAwB,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAE1F,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAYzD,qBACa,iBAAiB;IAE1B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAGxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAQxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;gBAXZ,OAAO,EAAE,cAAc,EAGvB,OAAO,EAAE,YAAY,EAQrB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA;IAG1D;;;;OAIG;YACW,WAAW;IAQzB;;;;;;OAMG;YACW,WAAW;IAYzB;;;;;;OAMG;IACG,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAkB/F;;;;;;;OAOG;IACG,mBAAmB,CACvB,KAAK,EAAE,SAAS,EAChB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,QAAQ,CAAC;IAwBpB,kEAAkE;IAC5D,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,UAAU,EAAE,CAAC;IAKvE;;;;;;;;;;;;;;;;;OAiBG;IACG,SAAS,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA0D1F;;;;OAIG;IACG,gBAAgB,CACpB,KAAK,EAAE,SAAS,EAChB,cAAc,EAAE,MAAM,EACtB,KAAK,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GACzB,OAAO,CAAC,IAAI,CAAC;IA4BhB;;;OAGG;IACG,gBAAgB,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAYhF"}
@@ -10,6 +10,12 @@
10
10
  * (exactly one active per user) + `status`. This service owns the ONE invariant the DB
11
11
  * cannot express — one-active-per-user — because there is no partial-unique on
12
12
  * `cm_is_active`; `setActive` is the guard.
13
+ *
14
+ * Second invariant (G-011, F2): the active membership always has status exactly `active`.
15
+ * Grants read `cm_is_active = 1`, while scope-switch and the profile writer also require
16
+ * `cm_status = 'active'`; keeping the two in step on every write makes the readers agree.
17
+ * `setActive` refuses a target whose status is not `active`, and `updateMembership` refuses
18
+ * moving the active membership off `active`; each checks under a row lock in one transaction.
13
19
  */
14
20
  var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
15
21
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -30,6 +36,8 @@ const engine_1 = require("@astram/engine");
30
36
  const posting_version_bump_js_1 = require("./posting-version-bump.js");
31
37
  const identity_errors_js_1 = require("../shared/identity-errors.js");
32
38
  const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
39
+ /** The only status the active membership may have (case-sensitive, as the readers' predicate). */
40
+ const ACTIVE_STATUS = 'active';
33
41
  /** A driver duplicate-key error (errno 1062), recognised by errno/code only, never its message. */
34
42
  function isDuplicateKey(err) {
35
43
  if (typeof err !== 'object' || err === null)
@@ -150,16 +158,16 @@ let MembershipService = class MembershipService {
150
158
  * Flip which of the user's memberships is active — the ONE-ACTIVE invariant. The service
151
159
  * is the guard because the DB has no partial-unique on `cm_is_active`.
152
160
  *
153
- * ⚠️ ORDER IS LOAD-BEARING (fail-safe against a LOGIN LOCKOUT). Each `update` is its OWN
154
- * transaction (no cross-call atomicity), and the engine's `list` order (PK-ordered since the
155
- * Phase 3 tiebreaker) says nothing about which membership is the target. So this MUST
156
- * activate the TARGET FIRST, then deactivate the others:
157
- * - Worst case of a mid-loop failure is then a transient TWO-active window, which is
158
- * RECOVERABLE — the credential resolver reads `is_active=1 LIMIT 1`, so it still
159
- * resolves a tenant and login works.
160
- * - The forbidden alternative (deactivate-old-then-activate-target) can leave a
161
- * ZERO-active window on a mid-failure → the resolver returns null → the user is
162
- * LOCKED OUT of login until an admin re-activates. Never do that.
161
+ * The whole flip commits in ONE transaction (G-011), so a mid-loop failure rolls back and the
162
+ * user keeps the activation they had. Inside it:
163
+ * - every membership of the user is locked in id order, then re-listed, so two concurrent
164
+ * flips for the same user queue instead of deadlocking, and a membership added since the
165
+ * pre-check is still deactivated;
166
+ * - the target's status is re-read under its lock: only status `active` may be activated;
167
+ * - the TARGET is activated FIRST, then the others are deactivated. Atomicity makes the
168
+ * order a defence in depth now, but keep it: deactivate-first is the order that can leave
169
+ * ZERO active (a login lockout: the credential resolver finds no tenant) if atomicity is
170
+ * ever lost.
163
171
  *
164
172
  * Guard: if `membershipUuid` is NOT one of the user's memberships, throw — otherwise the
165
173
  * loop would deactivate everything and activate nothing (also a zero-active lockout).
@@ -176,17 +184,35 @@ let MembershipService = class MembershipService {
176
184
  // (a login lockout). Fail-closed. No ids in the message (ADR-0012).
177
185
  throw new identity_errors_js_1.IdentityNotFoundError('Membership not found.');
178
186
  }
179
- // 1) TARGET FIRST — guarantees an active membership exists before any deactivation, so
180
- // a mid-failure below leaves TWO active (recoverable), never ZERO (lockout).
181
- await this.records.update('company_membership', target.id, { fields: { isActive: true } });
182
- // 2) Deactivate every OTHER membership (skip the target we just activated).
183
187
  const deactivated = [];
184
- for (const m of page.rows) {
185
- if (m.id === membershipUuid)
186
- continue;
187
- await this.records.update('company_membership', m.id, { fields: { isActive: false } });
188
- deactivated.push(m);
189
- }
188
+ await this.records.transaction(async (tx) => {
189
+ // G-011: lock the user's memberships in id order FIRST (one queue per user, no lock-order
190
+ // deadlock). The locks are locking reads, so the plain reads below take their snapshot
191
+ // after them and see every committed change: the re-list (a membership added since the
192
+ // pre-check is still deactivated) and the target's status.
193
+ for (const id of page.rows.map((m) => String(m.id)).sort()) {
194
+ await tx.lockForUpdate('company_membership', id);
195
+ }
196
+ const rows = (await tx.list('company_membership', { where: { userUuid } })).rows;
197
+ if (!rows.some((m) => m.id === membershipUuid)) {
198
+ throw new identity_errors_js_1.IdentityNotFoundError('Membership not found.');
199
+ }
200
+ const current = await tx.get('company_membership', target.id);
201
+ if (current === null)
202
+ throw new identity_errors_js_1.IdentityNotFoundError('Membership not found.');
203
+ if (current.fields?.status !== ACTIVE_STATUS) {
204
+ throw new identity_errors_js_1.IdentityConflictError('Only a membership with status active can be made the active membership.');
205
+ }
206
+ // 1) TARGET FIRST (defence in depth, see the method comment).
207
+ await tx.update('company_membership', target.id, { fields: { isActive: true } });
208
+ // 2) Deactivate every OTHER membership (skip the target we just activated).
209
+ for (const m of rows) {
210
+ if (m.id === membershipUuid)
211
+ continue;
212
+ await tx.update('company_membership', m.id, { fields: { isActive: false } });
213
+ deactivated.push(m);
214
+ }
215
+ });
190
216
  // ADR-0011 STALENESS FIX: activation ADDS the target company's role grants and each
191
217
  // deactivation REMOVES that company's grants — a coarse-grant change in BOTH directions.
192
218
  // A user's memberships can span DIFFERENT companies, so setActive may affect MULTIPLE
@@ -210,6 +236,8 @@ let MembershipService = class MembershipService {
210
236
  const fields = {};
211
237
  if (typeof patch?.status === 'string')
212
238
  fields.status = patch.status;
239
+ if (Object.keys(fields).length === 0)
240
+ return;
213
241
  // NO version bump here (ADR-0011): this patches `status` ONLY — `isActive` is
214
242
  // deliberately not a patch key (activity flips solely via `setActive`, which bumps),
215
243
  // and the controller only ever passes `{ status }`. Coarse-grant resolution gates on
@@ -217,7 +245,19 @@ let MembershipService = class MembershipService {
217
245
  // resolve → the warm cache stays correct → no invalidation needed. If a future patch key
218
246
  // is ever added here that CAN change which roles/postings resolve, it MUST bump the
219
247
  // membership's tenant (mirror `setActive`).
220
- await this.records.update('company_membership', membershipUuid, { fields });
248
+ await this.records.transaction(async (tx) => {
249
+ // G-011: the active membership keeps status `active`. Lock and re-read on this
250
+ // transaction so a concurrent `setActive` cannot activate it between check and write.
251
+ await tx.lockForUpdate('company_membership', membershipUuid);
252
+ const current = await tx.get('company_membership', membershipUuid);
253
+ if (current === null)
254
+ throw new identity_errors_js_1.IdentityNotFoundError('Membership not found.');
255
+ // TINYINT(1): the engine returns 1/0, not true/false.
256
+ if (current.fields?.isActive && fields.status !== ACTIVE_STATUS) {
257
+ throw new identity_errors_js_1.IdentityConflictError('Make another membership active before changing the status of the active one.');
258
+ }
259
+ await tx.update('company_membership', membershipUuid, { fields });
260
+ });
221
261
  }
222
262
  /**
223
263
  * Remove a membership card (its postings cascade at the DB via FK ON DELETE CASCADE). A peer's
@@ -10,10 +10,11 @@
10
10
  * A posting IS a role assignment, so before ANY write the actor (the request principal) must:
11
11
  * - reach the membership (a walled `records.get` → null ⇒ 404; the engine's ADR-0034 create
12
12
  * wall re-checks the posting's membership company inside the transaction);
13
- * - for every ADDED role: the role is bound within the actor's subtree or not bound at all
14
- * (`GrantCeiling.assertRoleAssignable` — a role also used elsewhere is never assignable),
15
- * and the role's keys are all held by the actor (`assertCanAssignRoles`, ADR-0029 applied to
16
- * assignment). Removals need no ceiling: self-demotion stays allowed.
13
+ * - for every ADDED role: the role is owned by the membership's company (G-013,
14
+ * `GrantCeiling.assertRoleAssignable` — another company's or an ownerless role is a 400, for
15
+ * every actor, root included), and the role's keys are all held by the actor
16
+ * (`assertCanAssignRoles`, ADR-0029 applied to assignment). Removals need no ceiling:
17
+ * self-demotion stays allowed.
17
18
  * - for every ADDED posting: its department and branch belong to the membership's company
18
19
  * (`assertOrgUnitsInCompany`, Phase 9 #13b) — else 400.
19
20
  *
@@ -66,10 +67,28 @@ export declare class PostingService {
66
67
  private assertEveryRole;
67
68
  /** The membership through the subtree read wall (404 when out of reach) and its company. */
68
69
  private reachMembership;
69
- /** Authority for ADDED postings (binding rule, ceiling, org units); returns the distinct roles. */
70
+ /** Authority for ADDED postings (owner rule, ceiling, org units); returns the distinct roles. */
70
71
  private assertMayAdd;
71
- /** Lock each added role row on the transaction, then re-run the binding rule (review F2). */
72
+ /**
73
+ * Lock each added role row on the transaction, then re-check the owner rule (review F2, G-013).
74
+ * A role changes owner only through its company's delete (owner cleared), which also removes the
75
+ * memberships of that company, so the re-check after the lock sees the committed outcome.
76
+ */
72
77
  private lockAndRecheck;
78
+ /**
79
+ * G-012 (spec §5.A, NAES review D): lock every ADDED posting's department and branch row on THIS
80
+ * transaction (departments then branches, each sorted and de-duplicated: one lock order), then
81
+ * re-check that each still belongs to the membership's company. A department/branch delete locks
82
+ * the same row and counts postings after its lock, so the two serialise: delete first ⇒ the unit
83
+ * (and its junction row, which cascades) is gone and this add is refused with 400; add first ⇒
84
+ * the delete counts this posting and answers 409. The FK is never reached, so no driver error.
85
+ *
86
+ * The re-check reuses `assertOrgUnitsInCompany` (fresh reads on the authz pool, after the lock is
87
+ * held, so they see the committed delete) rather than an engine read: the org bootstrap's
88
+ * `postings` step is gate-7 capped to membership types and may not read `department`/`branch`.
89
+ * The pre-transaction check already passed, so a failure here can only mean the row is gone.
90
+ */
91
+ private lockOrgUnits;
73
92
  private bumpTenant;
74
93
  }
75
94
  //# sourceMappingURL=posting.service.d.ts.map
@@ -1 +1 @@
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,EAA6B,MAAM,2BAA2B,CAAC;AAOpF;;;;;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;IAyEhB;;;;;;OAMG;IACG,WAAW,CACf,KAAK,EAAE,SAAS,EAChB,cAAc,EAAE,MAAM,EACtB,KAAK,EAAE,SAAS,YAAY,EAAE,EAC9B,IAAI,CAAC,EAAE;QAAE,gBAAgB,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,SAAS,MAAM,EAAE,CAAA;KAAE,GACpE,OAAO,CAAC,IAAI,CAAC;IA2ChB,OAAO,CAAC,eAAe;IAQvB,4FAA4F;YAC9E,eAAe;IAY7B,mGAAmG;YACrF,YAAY;IAqB1B,6FAA6F;YAC/E,cAAc;YAYd,UAAU;CAkBzB"}
1
+ {"version":3,"file":"posting.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/posting.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;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,EAA6B,MAAM,2BAA2B,CAAC;AAOpF;;;;;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;IA0EhB;;;;;;OAMG;IACG,WAAW,CACf,KAAK,EAAE,SAAS,EAChB,cAAc,EAAE,MAAM,EACtB,KAAK,EAAE,SAAS,YAAY,EAAE,EAC9B,IAAI,CAAC,EAAE;QAAE,gBAAgB,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,SAAS,MAAM,EAAE,CAAA;KAAE,GACpE,OAAO,CAAC,IAAI,CAAC;IA4ChB,OAAO,CAAC,eAAe;IAQvB,4FAA4F;YAC9E,eAAe;IAY7B,iGAAiG;YACnF,YAAY;IAuB1B;;;;OAIG;YACW,cAAc;IAa5B;;;;;;;;;;;;OAYG;YACW,YAAY;YA+BZ,UAAU;CAkBzB"}