@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.
- package/dist/branch.object.d.ts +9 -1
- package/dist/branch.object.d.ts.map +1 -1
- package/dist/branch.object.js +14 -1
- package/dist/company-branch.object.d.ts +5 -4
- package/dist/company-branch.object.d.ts.map +1 -1
- package/dist/company-branch.object.js +6 -4
- package/dist/company-department.object.d.ts +6 -6
- package/dist/company-department.object.d.ts.map +1 -1
- package/dist/company-department.object.js +7 -6
- package/dist/department.object.d.ts +9 -1
- package/dist/department.object.d.ts.map +1 -1
- package/dist/department.object.js +14 -1
- package/dist/features/access/access-admin.access.d.ts.map +1 -1
- package/dist/features/access/access-admin.access.js +8 -1
- package/dist/features/access/index.d.ts +1 -0
- package/dist/features/access/index.d.ts.map +1 -1
- package/dist/features/access/index.js +10 -2
- package/dist/features/access/org-structure.access.d.ts +3 -0
- package/dist/features/access/org-structure.access.d.ts.map +1 -0
- package/dist/features/access/org-structure.access.js +41 -0
- package/dist/features/access/user-management.access.d.ts.map +1 -1
- package/dist/features/access/user-management.access.js +5 -0
- package/dist/features/audit/audit-read.service.d.ts +32 -1
- package/dist/features/audit/audit-read.service.d.ts.map +1 -1
- package/dist/features/audit/audit-read.service.js +67 -14
- package/dist/features/audit/auth-audit-sink.d.ts +19 -0
- package/dist/features/audit/auth-audit-sink.d.ts.map +1 -0
- package/dist/features/audit/auth-audit-sink.js +24 -0
- package/dist/features/audit/directory-label-resolver.d.ts +10 -1
- package/dist/features/audit/directory-label-resolver.d.ts.map +1 -1
- package/dist/features/audit/directory-label-resolver.js +28 -1
- package/dist/features/authz/grant-admin.controller.d.ts +4 -4
- package/dist/features/authz/grant-admin.controller.js +4 -4
- package/dist/features/authz/grant-admin.service.d.ts +9 -8
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.service.js +20 -24
- package/dist/features/authz/grant-ceiling.d.ts +35 -16
- package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
- package/dist/features/authz/grant-ceiling.js +44 -29
- package/dist/features/authz/role-binding.d.ts +25 -24
- package/dist/features/authz/role-binding.d.ts.map +1 -1
- package/dist/features/authz/role-binding.js +31 -44
- package/dist/features/identity-feature.module.d.ts +0 -11
- package/dist/features/identity-feature.module.d.ts.map +1 -1
- package/dist/features/identity-feature.module.js +90 -1
- package/dist/features/membership/membership.controller.d.ts.map +1 -1
- package/dist/features/membership/membership.controller.js +20 -6
- package/dist/features/membership/membership.service.d.ts +16 -10
- package/dist/features/membership/membership.service.d.ts.map +1 -1
- package/dist/features/membership/membership.service.js +61 -21
- package/dist/features/membership/posting.service.d.ts +25 -6
- package/dist/features/membership/posting.service.d.ts.map +1 -1
- package/dist/features/membership/posting.service.js +63 -16
- package/dist/features/org-bootstrap/org-bootstrap-file.d.ts +6 -2
- package/dist/features/org-bootstrap/org-bootstrap-file.d.ts.map +1 -1
- package/dist/features/org-bootstrap/org-bootstrap-file.js +54 -11
- package/dist/features/org-bootstrap/org-bootstrap-plan.d.ts +30 -11
- package/dist/features/org-bootstrap/org-bootstrap-plan.d.ts.map +1 -1
- package/dist/features/org-bootstrap/org-bootstrap-plan.js +172 -68
- package/dist/features/org-bootstrap/org-state.reader.d.ts.map +1 -1
- package/dist/features/org-bootstrap/org-state.reader.js +56 -49
- package/dist/features/org-bootstrap/run-org-bootstrap.d.ts +6 -1
- package/dist/features/org-bootstrap/run-org-bootstrap.d.ts.map +1 -1
- package/dist/features/org-bootstrap/run-org-bootstrap.js +81 -92
- package/dist/features/org-structure/org-catalog.controller.d.ts +29 -6
- package/dist/features/org-structure/org-catalog.controller.d.ts.map +1 -1
- package/dist/features/org-structure/org-catalog.controller.js +105 -17
- package/dist/features/org-structure/org-catalog.service.d.ts +99 -18
- package/dist/features/org-structure/org-catalog.service.d.ts.map +1 -1
- package/dist/features/org-structure/org-catalog.service.js +327 -51
- package/dist/features/org-structure/org-unit-name.d.ts +31 -0
- package/dist/features/org-structure/org-unit-name.d.ts.map +1 -0
- package/dist/features/org-structure/org-unit-name.js +50 -0
- package/dist/features/role/role.controller.d.ts +28 -13
- package/dist/features/role/role.controller.d.ts.map +1 -1
- package/dist/features/role/role.controller.js +54 -14
- package/dist/features/role/role.service.d.ts +76 -21
- package/dist/features/role/role.service.d.ts.map +1 -1
- package/dist/features/role/role.service.js +204 -27
- package/dist/features/security-policy/security-policy-version-bump.d.ts +41 -0
- package/dist/features/security-policy/security-policy-version-bump.d.ts.map +1 -0
- package/dist/features/security-policy/security-policy-version-bump.js +65 -0
- package/dist/features/security-policy/security-policy.controller.d.ts +51 -0
- package/dist/features/security-policy/security-policy.controller.d.ts.map +1 -0
- package/dist/features/security-policy/security-policy.controller.js +121 -0
- package/dist/features/security-policy/security-policy.service.d.ts +94 -0
- package/dist/features/security-policy/security-policy.service.d.ts.map +1 -0
- package/dist/features/security-policy/security-policy.service.js +429 -0
- package/dist/features/service-account/service-account.service.d.ts +13 -3
- package/dist/features/service-account/service-account.service.d.ts.map +1 -1
- package/dist/features/service-account/service-account.service.js +48 -10
- package/dist/features/service-account/service-account.store.d.ts +11 -1
- package/dist/features/service-account/service-account.store.d.ts.map +1 -1
- package/dist/features/service-account/service-account.store.js +20 -1
- package/dist/features/user/signin-admin.tokens.d.ts +11 -0
- package/dist/features/user/signin-admin.tokens.d.ts.map +1 -0
- package/dist/features/user/signin-admin.tokens.js +13 -0
- package/dist/features/user/user-mfa-status.d.ts +38 -0
- package/dist/features/user/user-mfa-status.d.ts.map +1 -0
- package/dist/features/user/user-mfa-status.js +47 -0
- package/dist/features/user/user-signin-admin.service.d.ts +48 -0
- package/dist/features/user/user-signin-admin.service.d.ts.map +1 -0
- package/dist/features/user/user-signin-admin.service.js +232 -0
- package/dist/features/user/user.controller.d.ts +29 -2
- package/dist/features/user/user.controller.d.ts.map +1 -1
- package/dist/features/user/user.controller.js +71 -3
- package/dist/features/user/user.service.d.ts +40 -14
- package/dist/features/user/user.service.d.ts.map +1 -1
- package/dist/features/user/user.service.js +143 -10
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/objects.d.ts.map +1 -1
- package/dist/objects.js +2 -0
- package/dist/role.object.d.ts +1 -1
- package/dist/role.object.d.ts.map +1 -1
- package/dist/role.object.js +6 -15
- package/dist/security-policy.object.d.ts +43 -0
- package/dist/security-policy.object.d.ts.map +1 -0
- package/dist/security-policy.object.js +107 -0
- package/dist/user.object.d.ts +6 -0
- package/dist/user.object.d.ts.map +1 -1
- package/dist/user.object.js +13 -1
- package/migrations/1710000000102-CreateAuditChain.cjs +71 -0
- package/migrations/1710000000103-AddUserLoginName.cjs +87 -0
- package/migrations/1710000000104-CreateSecurityPolicy.cjs +90 -0
- package/migrations/1710000000105-OwnDepartmentsAndBranches.cjs +327 -0
- package/migrations/1710000000106-OwnRolesPerCompany.cjs +622 -0
- package/package.json +9 -4
|
@@ -1,52 +1,39 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
4
|
-
exports.
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
16
|
+
function ownedInSubtree(nodeIds) {
|
|
17
|
+
if (nodeIds.length === 0)
|
|
18
|
+
return { sql: ' AND 1 = 0', params: [] };
|
|
11
19
|
const ph = nodeIds.map(() => '?').join(', ');
|
|
12
|
-
|
|
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
|
-
/**
|
|
19
|
-
function
|
|
20
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
108
|
+
userUuid = (0, guard_1.uuidField)(body.userUuid, 'userUuid');
|
|
100
109
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* ZERO
|
|
68
|
-
*
|
|
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
|
|
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
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* ZERO
|
|
162
|
-
*
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
deactivated.
|
|
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.
|
|
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
|
|
14
|
-
*
|
|
15
|
-
* and the role's keys are all held by the actor
|
|
16
|
-
* assignment). Removals need no ceiling:
|
|
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 (
|
|
70
|
+
/** Authority for ADDED postings (owner rule, ceiling, org units); returns the distinct roles. */
|
|
70
71
|
private assertMayAdd;
|
|
71
|
-
/**
|
|
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
|
|
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"}
|