@astram/identity 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/dist/bin/sa-keys-report.d.ts +3 -0
- package/dist/bin/sa-keys-report.d.ts.map +1 -0
- package/dist/bin/sa-keys-report.js +18 -0
- package/dist/features/authz/grant-admin.service.d.ts +22 -5
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.service.js +45 -10
- package/dist/features/authz/grant-ceiling.d.ts +19 -6
- package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
- package/dist/features/authz/grant-ceiling.js +36 -8
- package/dist/features/authz/record-authorization.d.ts +15 -32
- package/dist/features/authz/record-authorization.d.ts.map +1 -1
- package/dist/features/authz/record-authorization.js +15 -69
- package/dist/features/company/company-hierarchy.service.d.ts +13 -1
- package/dist/features/company/company-hierarchy.service.d.ts.map +1 -1
- package/dist/features/company/company-hierarchy.service.js +36 -9
- package/dist/features/company/company.controller.d.ts +1 -1
- package/dist/features/company/company.controller.d.ts.map +1 -1
- package/dist/features/company/company.controller.js +9 -5
- package/dist/features/company/company.service.d.ts +9 -2
- package/dist/features/company/company.service.d.ts.map +1 -1
- package/dist/features/company/company.service.js +18 -2
- package/dist/features/identity-feature.module.d.ts +15 -8
- package/dist/features/identity-feature.module.d.ts.map +1 -1
- package/dist/features/identity-feature.module.js +75 -28
- package/dist/features/membership/posting.service.d.ts +2 -0
- package/dist/features/membership/posting.service.d.ts.map +1 -1
- package/dist/features/membership/posting.service.js +9 -0
- package/dist/features/role/role.service.d.ts +14 -7
- package/dist/features/role/role.service.d.ts.map +1 -1
- package/dist/features/role/role.service.js +15 -8
- package/dist/features/service-account/lock-service-accounts.d.ts +29 -0
- package/dist/features/service-account/lock-service-accounts.d.ts.map +1 -0
- package/dist/features/service-account/lock-service-accounts.js +35 -0
- package/dist/features/service-account/no-expiry-key-report.d.ts +39 -0
- package/dist/features/service-account/no-expiry-key-report.d.ts.map +1 -0
- package/dist/features/service-account/no-expiry-key-report.js +91 -0
- package/dist/features/shared/http.d.ts +6 -0
- package/dist/features/shared/http.d.ts.map +1 -1
- package/dist/features/shared/http.js +9 -0
- package/dist/features/user/user.service.d.ts +4 -4
- package/dist/features/user/user.service.d.ts.map +1 -1
- package/dist/features/user/user.service.js +9 -6
- package/dist/index.d.ts +1 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -12
- package/dist/internal.d.ts +3 -2
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +3 -2
- package/package.json +7 -4
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.CompanyHierarchyService = void 0;
|
|
4
|
+
exports.ownRootOf = ownRootOf;
|
|
5
|
+
const http_js_1 = require("../shared/http.js");
|
|
4
6
|
const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
|
|
7
|
+
const lock_service_accounts_js_1 = require("../service-account/lock-service-accounts.js");
|
|
5
8
|
const identity_errors_js_1 = require("../shared/identity-errors.js");
|
|
6
9
|
const APP = 'shield_app';
|
|
7
10
|
const AUTHZ = 'shield_authz';
|
|
@@ -18,6 +21,13 @@ function jsonIdArray(v) {
|
|
|
18
21
|
}
|
|
19
22
|
return Array.isArray(arr) ? arr.filter((x) => typeof x === 'string') : [];
|
|
20
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* The actor's own root node (the active node its subtree hangs from). A non-root actor may not
|
|
26
|
+
* move, delete (F6) or change the status of (Phase 9 #13c) this node.
|
|
27
|
+
*/
|
|
28
|
+
function ownRootOf(actor) {
|
|
29
|
+
return actor.activeScopeNodeId ?? actor.tenantId;
|
|
30
|
+
}
|
|
21
31
|
class CompanyHierarchyService {
|
|
22
32
|
pool;
|
|
23
33
|
closure;
|
|
@@ -33,9 +43,19 @@ class CompanyHierarchyService {
|
|
|
33
43
|
this.audit = audit;
|
|
34
44
|
this.now = now;
|
|
35
45
|
}
|
|
36
|
-
/** The actor's own root node (
|
|
46
|
+
/** The actor's own root node (see {@link ownRootOf}). */
|
|
37
47
|
ownRoot(actor) {
|
|
38
|
-
return actor
|
|
48
|
+
return ownRootOf(actor);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The company's uuid as stored (Phase 10 #1): `c_uuid` is `utf8mb4_unicode_ci`, so a route uuid
|
|
52
|
+
* in another letter case still reaches the row, while JS-side comparisons (own root, agent
|
|
53
|
+
* tenants, `JSON_CONTAINS` over scope lists, name maps) are exact. Every entry resolves the
|
|
54
|
+
* stored spelling once and passes that down. Unknown → the input unchanged (later steps 404).
|
|
55
|
+
*/
|
|
56
|
+
async storedUuid(uuid) {
|
|
57
|
+
const rows = (await this.pool.query(`SELECT c_uuid FROM \`${APP}\`.\`companies\` WHERE c_uuid = ? LIMIT 1`, [uuid]));
|
|
58
|
+
return rows[0]?.c_uuid ?? uuid;
|
|
39
59
|
}
|
|
40
60
|
/** Names of the given companies (unknown → absent). */
|
|
41
61
|
async names(q, ids) {
|
|
@@ -66,11 +86,14 @@ class CompanyHierarchyService {
|
|
|
66
86
|
(typeof newParentUuid !== 'string' || newParentUuid.length === 0)) {
|
|
67
87
|
throw new identity_errors_js_1.IdentityBadRequestError('parentUuid must be a company or null.');
|
|
68
88
|
}
|
|
89
|
+
nodeUuid = await this.storedUuid(nodeUuid);
|
|
90
|
+
if (newParentUuid !== null)
|
|
91
|
+
newParentUuid = await this.storedUuid(newParentUuid);
|
|
69
92
|
if (actor.isRoot !== true) {
|
|
70
93
|
const subtree = new Set((0, grant_ceiling_js_1.subtreeOf)(actor));
|
|
71
94
|
if (!subtree.has(nodeUuid))
|
|
72
95
|
throw new identity_errors_js_1.IdentityNotFoundError('Company not found.');
|
|
73
|
-
if (nodeUuid
|
|
96
|
+
if ((0, http_js_1.sameUuid)(nodeUuid, this.ownRoot(actor))) {
|
|
74
97
|
throw new identity_errors_js_1.IdentityForbiddenError('You cannot move the organisation your access is rooted at.');
|
|
75
98
|
}
|
|
76
99
|
if (newParentUuid === null || !subtree.has(newParentUuid)) {
|
|
@@ -116,9 +139,10 @@ class CompanyHierarchyService {
|
|
|
116
139
|
* depending on it as they were; a commit leaves a deleted company with no live agent or grant.
|
|
117
140
|
*/
|
|
118
141
|
async remove(actor, nodeUuid) {
|
|
142
|
+
nodeUuid = await this.storedUuid(nodeUuid); // Phase 10 #1: one spelling downstream
|
|
119
143
|
// Security review F6: the same own-root guard as `move` — a sub-admin never deletes the
|
|
120
144
|
// organisation its access is rooted at.
|
|
121
|
-
if (actor.isRoot !== true && nodeUuid
|
|
145
|
+
if (actor.isRoot !== true && (0, http_js_1.sameUuid)(nodeUuid, this.ownRoot(actor))) {
|
|
122
146
|
throw new identity_errors_js_1.IdentityForbiddenError('You cannot delete the organisation your access is rooted at.');
|
|
123
147
|
}
|
|
124
148
|
const name = (await this.names(this.pool, [nodeUuid])).get(nodeUuid) ?? '(unknown)';
|
|
@@ -152,11 +176,14 @@ class CompanyHierarchyService {
|
|
|
152
176
|
* Every change is audited on this transaction (names; ids only in structured attributes).
|
|
153
177
|
*/
|
|
154
178
|
async retireDependents(conn, actor, nodeUuid, companyName) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
179
|
+
// Phase 9 #11: lock only the matching agents, never the whole table. An agent created in the
|
|
180
|
+
// company, or given it in scope, after the candidate read is not seen: the outcome equals
|
|
181
|
+
// "delete, then that edit" (the API-key path joins `companies`, so an agent of a deleted
|
|
182
|
+
// tenant cannot authenticate; a deleted node in a scope reaches nothing).
|
|
183
|
+
const agents = await (0, lock_service_accounts_js_1.lockServiceAccountsWhere)(conn, 'sa_id, sa_tenant_id, sa_label, sa_scope_subtree, sa_role_ids, sa_updated_at_ms', {
|
|
184
|
+
sql: 'sa_tenant_id = ? OR JSON_CONTAINS(sa_scope_subtree, JSON_QUOTE(?))',
|
|
185
|
+
params: [nodeUuid, nodeUuid],
|
|
186
|
+
});
|
|
160
187
|
const own = agents.filter((a) => a.sa_tenant_id === nodeUuid);
|
|
161
188
|
const others = agents.filter((a) => a.sa_tenant_id !== nodeUuid);
|
|
162
189
|
// Root bypasses inside; a refusal (403, names no agent) rolls the whole delete back.
|
|
@@ -41,7 +41,7 @@ export declare class CompanyController {
|
|
|
41
41
|
* Update a company's name/status — gated on `identity:company:update`. Any other key
|
|
42
42
|
* (parentUuid, type, …) is ignored: use `PUT companies/:uuid/parent` to move a company.
|
|
43
43
|
*/
|
|
44
|
-
updateCompany(uuid: string, body: {
|
|
44
|
+
updateCompany(req: AuthedRequest, uuid: string, body: {
|
|
45
45
|
name?: string;
|
|
46
46
|
status?: string;
|
|
47
47
|
}): Promise<{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"company.controller.d.ts","sourceRoot":"","sources":["../../../src/features/company/company.controller.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAkB,KAAK,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAC5E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAGvD,qBAEa,iBAAiB;IAChB,OAAO,CAAC,QAAQ,CAAC,SAAS;gBAAT,SAAS,EAAE,cAAc;IAEtD;;;;OAIG;IAGG,aAAa,CAAU,KAAK,EAAE,YAAY;;;;;;IAMhD;;;OAGG;IAGG,aAAa,CAAQ,GAAG,EAAE,aAAa,EAAU,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE;;;IAK9E,2DAA2D;IAG3D,UAAU,CAAgB,IAAI,EAAE,MAAM;IAItC;;;;;OAKG;IAGG,WAAW,CACR,GAAG,EAAE,aAAa,EACV,IAAI,EAAE,MAAM,EACnB,IAAI,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;;;IAa9C;;;OAGG;IAGG,aAAa,
|
|
1
|
+
{"version":3,"file":"company.controller.d.ts","sourceRoot":"","sources":["../../../src/features/company/company.controller.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAkB,KAAK,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAC5E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAGvD,qBAEa,iBAAiB;IAChB,OAAO,CAAC,QAAQ,CAAC,SAAS;gBAAT,SAAS,EAAE,cAAc;IAEtD;;;;OAIG;IAGG,aAAa,CAAU,KAAK,EAAE,YAAY;;;;;;IAMhD;;;OAGG;IAGG,aAAa,CAAQ,GAAG,EAAE,aAAa,EAAU,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE;;;IAK9E,2DAA2D;IAG3D,UAAU,CAAgB,IAAI,EAAE,MAAM;IAItC;;;;;OAKG;IAGG,WAAW,CACR,GAAG,EAAE,aAAa,EACV,IAAI,EAAE,MAAM,EACnB,IAAI,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;;;IAa9C;;;OAGG;IAGG,aAAa,CACV,GAAG,EAAE,aAAa,EACV,IAAI,EAAE,MAAM,EACnB,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE;;;IASlD,iGAAiG;IAG3F,aAAa,CAAQ,GAAG,EAAE,aAAa,EAAiB,IAAI,EAAE,MAAM;;;CAI3E"}
|
|
@@ -64,8 +64,11 @@ let CompanyController = class CompanyController {
|
|
|
64
64
|
* Update a company's name/status — gated on `identity:company:update`. Any other key
|
|
65
65
|
* (parentUuid, type, …) is ignored: use `PUT companies/:uuid/parent` to move a company.
|
|
66
66
|
*/
|
|
67
|
-
async updateCompany(uuid, body) {
|
|
68
|
-
await this.companies.updateCompany(uuid, {
|
|
67
|
+
async updateCompany(req, uuid, body) {
|
|
68
|
+
await this.companies.updateCompany(req.principal, uuid, {
|
|
69
|
+
name: body?.name,
|
|
70
|
+
status: body?.status,
|
|
71
|
+
});
|
|
69
72
|
return { success: true };
|
|
70
73
|
}
|
|
71
74
|
/** Delete a company — gated on `identity:company:delete`. A company with sub-companies → 409. */
|
|
@@ -113,10 +116,11 @@ __decorate([
|
|
|
113
116
|
__decorate([
|
|
114
117
|
(0, common_1.Put)('companies/:uuid'),
|
|
115
118
|
(0, guard_1.Classified)({ typeKey: 'company', action: 'update' }),
|
|
116
|
-
__param(0, (0, common_1.
|
|
117
|
-
__param(1, (0, common_1.
|
|
119
|
+
__param(0, (0, common_1.Req)()),
|
|
120
|
+
__param(1, (0, common_1.Param)('uuid')),
|
|
121
|
+
__param(2, (0, common_1.Body)()),
|
|
118
122
|
__metadata("design:type", Function),
|
|
119
|
-
__metadata("design:paramtypes", [String, Object]),
|
|
123
|
+
__metadata("design:paramtypes", [Object, String, Object]),
|
|
120
124
|
__metadata("design:returntype", Promise)
|
|
121
125
|
], CompanyController.prototype, "updateCompany", null);
|
|
122
126
|
__decorate([
|
|
@@ -26,8 +26,15 @@ export declare class CompanyService {
|
|
|
26
26
|
listCompanies(params?: ListParams): Promise<import("@astram/engine").RecordPage>;
|
|
27
27
|
/** Read a single company. */
|
|
28
28
|
getCompany(uuid: string): Promise<import("@astram/engine").RecordView | null>;
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Update a company's name/status. Any other key (parentUuid, type, …) is dropped.
|
|
31
|
+
*
|
|
32
|
+
* Phase 9 #13c: a non-root actor may not change the STATUS of the organisation its access is
|
|
33
|
+
* rooted at (the same own-root guard as move and delete, F6): deactivating it would act on the
|
|
34
|
+
* whole tenant above the actor's authority. Re-sending the current status (the company form
|
|
35
|
+
* always sends both fields) stays allowed, so the own root can still be renamed.
|
|
36
|
+
*/
|
|
37
|
+
updateCompany(actor: Principal, uuid: string, patch: Readonly<Record<string, unknown>>): Promise<void>;
|
|
31
38
|
/** Move a company under a new parent (hierarchy rules + cycle check, see the hierarchy service). */
|
|
32
39
|
moveCompany(actor: Principal, uuid: string, parentUuid: string | null): Promise<void>;
|
|
33
40
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"company.service.d.ts","sourceRoot":"","sources":["../../../src/features/company/company.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAkB,KAAK,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1E,OAAO,EAAE,uBAAuB,
|
|
1
|
+
{"version":3,"file":"company.service.d.ts","sourceRoot":"","sources":["../../../src/features/company/company.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAkB,KAAK,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1E,OAAO,EAAE,uBAAuB,EAAa,MAAM,gCAAgC,CAAC;AAUpF;;;;;;;GAOG;AACH,qBACa,cAAc;IAEvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,SAAS;gBADT,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,uBAAuB;IAGrD;;;;;OAKG;IACG,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAetE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAmC;IAE1D,2EAA2E;IAC3E,aAAa,CAAC,MAAM,CAAC,EAAE,UAAU;IAOjC,6BAA6B;IAC7B,UAAU,CAAC,IAAI,EAAE,MAAM;IAIvB;;;;;;;OAOG;IACG,aAAa,CACjB,KAAK,EAAE,SAAS,EAChB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GACvC,OAAO,CAAC,IAAI,CAAC;IAiBhB,oGAAoG;IACpG,WAAW,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAIrF;;;;;OAKG;IACG,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAKnE"}
|
|
@@ -15,6 +15,7 @@ const common_1 = require("@nestjs/common");
|
|
|
15
15
|
const engine_1 = require("@astram/engine");
|
|
16
16
|
const list_query_js_1 = require("../shared/list-query.js");
|
|
17
17
|
const company_hierarchy_service_js_1 = require("./company-hierarchy.service.js");
|
|
18
|
+
const http_js_1 = require("../shared/http.js");
|
|
18
19
|
const identity_errors_js_1 = require("../shared/identity-errors.js");
|
|
19
20
|
const COMPANY_SEARCH_FIELDS = ['name'];
|
|
20
21
|
const COMPANY_SORT_FIELDS = ['name', 'status', 'createdAt'];
|
|
@@ -66,13 +67,28 @@ let CompanyService = CompanyService_1 = class CompanyService {
|
|
|
66
67
|
getCompany(uuid) {
|
|
67
68
|
return this.records.get('company', uuid);
|
|
68
69
|
}
|
|
69
|
-
/**
|
|
70
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Update a company's name/status. Any other key (parentUuid, type, …) is dropped.
|
|
72
|
+
*
|
|
73
|
+
* Phase 9 #13c: a non-root actor may not change the STATUS of the organisation its access is
|
|
74
|
+
* rooted at (the same own-root guard as move and delete, F6): deactivating it would act on the
|
|
75
|
+
* whole tenant above the actor's authority. Re-sending the current status (the company form
|
|
76
|
+
* always sends both fields) stays allowed, so the own root can still be renamed.
|
|
77
|
+
*/
|
|
78
|
+
async updateCompany(actor, uuid, patch) {
|
|
71
79
|
const fields = {};
|
|
72
80
|
for (const key of COMPANY_PATCH_KEYS) {
|
|
73
81
|
if (typeof patch?.[key] === 'string')
|
|
74
82
|
fields[key] = patch[key];
|
|
75
83
|
}
|
|
84
|
+
if (actor.isRoot !== true && fields.status !== undefined && (0, http_js_1.sameUuid)(uuid, (0, company_hierarchy_service_js_1.ownRootOf)(actor))) {
|
|
85
|
+
const current = await this.records.get('company', uuid);
|
|
86
|
+
if (current === null)
|
|
87
|
+
throw new identity_errors_js_1.IdentityNotFoundError('Company not found.');
|
|
88
|
+
if (current.fields.status !== fields.status) {
|
|
89
|
+
throw new identity_errors_js_1.IdentityForbiddenError('You cannot change the status of the organisation your access is rooted at.');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
76
92
|
return this.records.update('company', uuid, { fields });
|
|
77
93
|
}
|
|
78
94
|
/** Move a company under a new parent (hierarchy rules + cycle check, see the hierarchy service). */
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { type DynamicModule } from '@nestjs/common';
|
|
2
2
|
import { type ObjectTypeInput } from '@astram/engine';
|
|
3
|
+
/**
|
|
4
|
+
* The boot check behind {@link IDENTITY_DECISION_AUTHORITY_CHECK}: with this module wired, the
|
|
5
|
+
* request path must decide with the authority guard built from this module's inputs. A missing
|
|
6
|
+
* authority (no `ShieldModule`) or the deny-everything stub (a guard that does not read
|
|
7
|
+
* `SHIELD_RECORD_AUTHORIZATION`) fails the boot instead of silently denying, or deciding with a
|
|
8
|
+
* different registry. (Another source next to this one is refused by `ShieldModule` itself.)
|
|
9
|
+
*/
|
|
10
|
+
export declare function assertRequestPathUsesIdentityAuthorization(authority: unknown): true;
|
|
3
11
|
/**
|
|
4
12
|
* IdentityFeatureModule — the framework-owned identity FEATURE surface (company/user/role CRUD;
|
|
5
13
|
* more in later phases) as thin controllers over the engine's RecordsService. The CONSUMER imports
|
|
@@ -19,14 +27,13 @@ export declare class IdentityFeatureModule {
|
|
|
19
27
|
* by the writer's own `decide()` (a route-allows/writer-denies split). Omitted ⇒
|
|
20
28
|
* `isRoot` is always false (a non-root consumer works; only a root offering
|
|
21
29
|
* cross-tenant grants needs it). See ADR-0026 + the template wiring.
|
|
22
|
-
* @param extraObjects the consumer's OWN registered object types
|
|
23
|
-
* given
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* `buildIdentityRecordAuthorization`, the boot fails with `IdentityRegistryMismatchError`.
|
|
30
|
+
* @param extraObjects the consumer's OWN registered object types (the ones it registers with
|
|
31
|
+
* `EngineModule.forFeature`). The ONE place they are given (Phase 9 #7): the module builds
|
|
32
|
+
* the record authorization from `[...identityObjects, ...extraObjects]` and provides it to
|
|
33
|
+
* `ShieldModule` (request path, gate-5 key projection), and uses the same registry for its
|
|
34
|
+
* cross-tenant / policy authority, grant ceiling, guided rule-builder schema and owner-share
|
|
35
|
+
* resolver. Without them a consumer type is unregistered everywhere (denied), not policy-
|
|
36
|
+
* authorable and not shareable ("type '…' is not registered — cannot share").
|
|
30
37
|
*/
|
|
31
38
|
static forRoot({ rootIds, extraObjects, }?: {
|
|
32
39
|
rootIds?: readonly string[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"identity-feature.module.d.ts","sourceRoot":"","sources":["../../src/features/identity-feature.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpE,OAAO,EAYL,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"identity-feature.module.d.ts","sourceRoot":"","sources":["../../src/features/identity-feature.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpE,OAAO,EAYL,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AA6FxB;;;;;;GAMG;AACH,wBAAgB,0CAA0C,CAAC,SAAS,EAAE,OAAO,GAAG,IAAI,CAUnF;AAiBD;;;;;;;;;;GAUG;AACH,qBACa,qBAAqB;IAChC;;;;;;;;;;;;;;OAcG;IACH,MAAM,CAAC,OAAO,CAAC,EACb,OAAO,EACP,YAAiB,GAClB,GAAE;QACD,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;QAC5B,YAAY,CAAC,EAAE,SAAS,eAAe,EAAE,CAAC;KACtC,GAAG,aAAa;CA8dvB"}
|
|
@@ -8,6 +8,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
8
8
|
var IdentityFeatureModule_1;
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
10
|
exports.IdentityFeatureModule = void 0;
|
|
11
|
+
exports.assertRequestPathUsesIdentityAuthorization = assertRequestPathUsesIdentityAuthorization;
|
|
11
12
|
const common_1 = require("@nestjs/common");
|
|
12
13
|
const core_1 = require("@nestjs/core");
|
|
13
14
|
const engine_1 = require("@astram/engine");
|
|
@@ -58,6 +59,35 @@ const grant_authorize_js_1 = require("./cross-tenant/grant-authorize.js");
|
|
|
58
59
|
const IDENTITY_VERSION_PUBLISHER_LINK = Symbol('IDENTITY_VERSION_PUBLISHER_LINK');
|
|
59
60
|
/** DI token of the boot check that warns when a user disable cannot revoke sessions. */
|
|
60
61
|
const IDENTITY_SESSION_REVOCATION_CHECK = Symbol('IDENTITY_SESSION_REVOCATION_CHECK');
|
|
62
|
+
/** DI token of the boot check that the request path decides with this module's authorization. */
|
|
63
|
+
const IDENTITY_DECISION_AUTHORITY_CHECK = Symbol('IDENTITY_DECISION_AUTHORITY_CHECK');
|
|
64
|
+
/**
|
|
65
|
+
* Holds the record-authorization inputs `IdentityFeatureModule.forRoot` builds, provided GLOBALLY
|
|
66
|
+
* under guard's `SHIELD_RECORD_AUTHORIZATION` so `ShieldModule` (a sibling, which never imports
|
|
67
|
+
* identity) builds the request-path decision authority from them (Phase 9 #7). Only this token is
|
|
68
|
+
* global; the feature module's own providers stay module-scoped.
|
|
69
|
+
*/
|
|
70
|
+
let IdentityRecordAuthorizationModule = class IdentityRecordAuthorizationModule {
|
|
71
|
+
};
|
|
72
|
+
IdentityRecordAuthorizationModule = __decorate([
|
|
73
|
+
(0, common_1.Module)({})
|
|
74
|
+
], IdentityRecordAuthorizationModule);
|
|
75
|
+
/**
|
|
76
|
+
* The boot check behind {@link IDENTITY_DECISION_AUTHORITY_CHECK}: with this module wired, the
|
|
77
|
+
* request path must decide with the authority guard built from this module's inputs. A missing
|
|
78
|
+
* authority (no `ShieldModule`) or the deny-everything stub (a guard that does not read
|
|
79
|
+
* `SHIELD_RECORD_AUTHORIZATION`) fails the boot instead of silently denying, or deciding with a
|
|
80
|
+
* different registry. (Another source next to this one is refused by `ShieldModule` itself.)
|
|
81
|
+
*/
|
|
82
|
+
function assertRequestPathUsesIdentityAuthorization(authority) {
|
|
83
|
+
if (authority === undefined || authority === null || authority === guard_1.stubDecisionAuthority) {
|
|
84
|
+
throw new Error('IdentityFeatureModule: the request path has no decision authority built from the identity ' +
|
|
85
|
+
'record authorization. Import ShieldModule.forRoot(...) from @astram/guard >= 0.10 ' +
|
|
86
|
+
'without `recordAuthorization`/`decisionAuthority`: it builds the authority from the ' +
|
|
87
|
+
'inputs IdentityFeatureModule provides (SHIELD_RECORD_AUTHORIZATION).');
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
61
91
|
/** Map guard's env DbConfig → the engine's EngineDbConfig (adds the pool cap the engine needs). */
|
|
62
92
|
function engineDbFromEnv() {
|
|
63
93
|
const db = (0, guard_1.loadDbConfig)();
|
|
@@ -91,29 +121,28 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
91
121
|
* by the writer's own `decide()` (a route-allows/writer-denies split). Omitted ⇒
|
|
92
122
|
* `isRoot` is always false (a non-root consumer works; only a root offering
|
|
93
123
|
* cross-tenant grants needs it). See ADR-0026 + the template wiring.
|
|
94
|
-
* @param extraObjects the consumer's OWN registered object types
|
|
95
|
-
* given
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* `buildIdentityRecordAuthorization`, the boot fails with `IdentityRegistryMismatchError`.
|
|
124
|
+
* @param extraObjects the consumer's OWN registered object types (the ones it registers with
|
|
125
|
+
* `EngineModule.forFeature`). The ONE place they are given (Phase 9 #7): the module builds
|
|
126
|
+
* the record authorization from `[...identityObjects, ...extraObjects]` and provides it to
|
|
127
|
+
* `ShieldModule` (request path, gate-5 key projection), and uses the same registry for its
|
|
128
|
+
* cross-tenant / policy authority, grant ceiling, guided rule-builder schema and owner-share
|
|
129
|
+
* resolver. Without them a consumer type is unregistered everywhere (denied), not policy-
|
|
130
|
+
* authorable and not shareable ("type '…' is not registered — cannot share").
|
|
102
131
|
*/
|
|
103
132
|
static forRoot({ rootIds, extraObjects = [], } = {}) {
|
|
104
|
-
// The ceiling's key universe includes the consumer's `extraObjects` (the SAME registry shape
|
|
105
|
-
// `buildIdentityRecordAuthorization(pool, extraObjects)` gives the decision path): otherwise a
|
|
106
|
-
// role holding a consumer-type key could never be assigned or granted by a non-root admin who
|
|
107
|
-
// holds that key (review M5) — the security-hardening role-assignment ceiling makes that
|
|
108
|
-
// reachable from every posting edit. Phase 8 #4: the two registries must have the SAME action
|
|
109
|
-
// keys — checked here, before anything is opened; a mismatch fails the boot.
|
|
110
|
-
const ceilingRegistry = (0, guard_1.buildIdentityModuleRegistry)([...objects_js_1.identityObjects, ...extraObjects]);
|
|
111
|
-
(0, record_authorization_js_1.noteIdentityCeilingRegistry)(ceilingRegistry);
|
|
112
133
|
// ONE shared shield_authz pool for the whole grant-admin stack (ADR-0011): the
|
|
113
134
|
// permission-version bumper, the AccessBundleStore, the GrantAdminService, and the two
|
|
114
135
|
// reconcile jobs all run over this single pool (don't open several). Opened once at
|
|
115
|
-
// forRoot() (not import time) — mirrors the pre-existing bump-pool pattern.
|
|
136
|
+
// forRoot() (not import time) — mirrors the pre-existing bump-pool pattern. Since Phase 9 #7
|
|
137
|
+
// it also backs the request path's decision authority (provided to ShieldModule below).
|
|
116
138
|
const authzPool = (0, record_authorization_js_1.createIdentityAuthzPool)();
|
|
139
|
+
// The ONE record authorization (Phase 9 #7, closes KI #55/#57): built here from this module's
|
|
140
|
+
// own `extraObjects`, provided to ShieldModule under SHIELD_RECORD_AUTHORIZATION, and reused for
|
|
141
|
+
// every internal authority below. Its registry is the ONE key universe: the grant ceiling sees
|
|
142
|
+
// every key the request path grants (review M5 / Phase 8 #4), and a consumer type is
|
|
143
|
+
// registered for the cross-tenant writer and the policy author too.
|
|
144
|
+
const recordAuthorization = (0, record_authorization_js_1.buildRecordAuthorizationInputs)(authzPool, extraObjects);
|
|
145
|
+
const ceilingRegistry = recordAuthorization.registry;
|
|
117
146
|
const bumper = new permission_version_bumper_js_1.PermissionVersionBumper();
|
|
118
147
|
const postingVersionBump = {
|
|
119
148
|
bumpTenant: (tenantUuid) => bumper.bumpForTenant(tenantUuid, authzPool),
|
|
@@ -123,8 +152,8 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
123
152
|
tenantsForRole: (roleUuid) => bumper.tenantsForRole(roleUuid, authzPool),
|
|
124
153
|
};
|
|
125
154
|
// The core ModuleRegistry the catalog reconcile job needs, built from the identity
|
|
126
|
-
// object registrations (guard's bridge reflects decorated classes → registry)
|
|
127
|
-
//
|
|
155
|
+
// object registrations only (guard's bridge reflects decorated classes → registry): it
|
|
156
|
+
// catalogs the identity permissions. Authorities use the shared `recordAuthorization.registry`.
|
|
128
157
|
const identityRegistry = (0, guard_1.buildIdentityModuleRegistry)([...objects_js_1.identityObjects]);
|
|
129
158
|
// The grant-admin collaborators built at forRoot() over the shared pool (mirrors the
|
|
130
159
|
// postingVersionBump closure pattern above). GrantCeiling (ADR-0029) enforces the
|
|
@@ -132,8 +161,8 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
132
161
|
// resolved over the SAME authz pool + registry the object-type gate uses (one source of
|
|
133
162
|
// truth for "what a principal holds"). ALWAYS wired here, so production always enforces.
|
|
134
163
|
const accessBundleStore = new access_bundle_store_js_1.AccessBundleStore(authzPool);
|
|
135
|
-
// (`ceilingRegistry` is
|
|
136
|
-
// platform roots as never dominated.
|
|
164
|
+
// (`ceilingRegistry` is the shared record-authorization registry built at the top of forRoot.)
|
|
165
|
+
// `rootIds` marks platform roots as never dominated.
|
|
137
166
|
// rootIds is passed EXPLICITLY (review F5): `null` states "not configured". The dominance
|
|
138
167
|
// check still refuses any user flagged `u_is_platform_superuser`, so an unconfigured list
|
|
139
168
|
// never lets a sub-admin take over a platform administrator.
|
|
@@ -174,8 +203,8 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
174
203
|
// authz.module, the anti-pattern this replaces). A SECOND DecisionAuthority is built over the
|
|
175
204
|
// same record-authorization inputs (matches astram3): the route guard's own decide() is the
|
|
176
205
|
// first; this one backs the writer's server-side re-verification.
|
|
177
|
-
//
|
|
178
|
-
const crossTenantAuthority = (0, guard_1.buildRecordAuthorization)(
|
|
206
|
+
// Same inputs as the request path (KI #57): a consumer type is registered here too.
|
|
207
|
+
const crossTenantAuthority = (0, guard_1.buildRecordAuthorization)(recordAuthorization);
|
|
179
208
|
// Record-level LIST SCOPE (Task 3.1, Invariant #8): the engine-facing fn the engine's
|
|
180
209
|
// `list()` ANDs into BOTH the data SELECT and the COUNT — the in-query visible-set filter.
|
|
181
210
|
// Built HERE, over the SAME shared authzPool (guard constructs the un-overridable scope
|
|
@@ -183,7 +212,7 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
183
212
|
// out-of-the-box into `EngineModule.forRoot({ listScope })` below (ADR-0003 injection seam —
|
|
184
213
|
// the engine never imports guard). Fail-closed: a store outage propagates and `list()` fails
|
|
185
214
|
// rather than returning unscoped rows.
|
|
186
|
-
const listScope = (0, guard_1.buildListScopeFn)(
|
|
215
|
+
const listScope = (0, guard_1.buildListScopeFn)(recordAuthorization);
|
|
187
216
|
// The writer's `GrantAuthorizeFn`: decide()-backed, fail-closed (no try/catch — a throw
|
|
188
217
|
// propagates and the writer maps it to a retryable 503). isRoot ← the SAME rootIds the
|
|
189
218
|
// consumer passes to ShieldModule.forRoot (root-correctness for control-plane grants).
|
|
@@ -237,11 +266,12 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
237
266
|
audit: grantAudit,
|
|
238
267
|
});
|
|
239
268
|
// The read side (inbox/mine/incoming/offerable-types) over the same pool + the merged object
|
|
240
|
-
// set (offerable = tenantScoped types only) + the per-type
|
|
241
|
-
|
|
269
|
+
// set (offerable = tenantScoped types only, the consumer's included, KI #57) + the per-type
|
|
270
|
+
// action vocabulary.
|
|
271
|
+
const crossTenantGrantReadService = new cross_tenant_grant_read_service_js_1.CrossTenantGrantReadService(authzPool, [...objects_js_1.identityObjects, ...extraObjects], guard_1.actionsFor);
|
|
242
272
|
// ── GUIDED RULE-BUILDER (list-scoping engine Phase 5, §9.1) ──────────────────────────────
|
|
243
273
|
// The policy-authoring surface over the guard `MariaDbPolicyStore` + core `makeAuthorPolicy`.
|
|
244
|
-
// Reuses the SAME
|
|
274
|
+
// Reuses the SAME shared registry + `crossTenantAuthority` (a DecisionAuthority over the
|
|
245
275
|
// shared authzPool — the delegated-admin ceiling's decide()) as the cross-tenant writer. The
|
|
246
276
|
// core AuditSink is one-arg (`record(event)`); the engine `MariaDbAuditSink` is two-arg
|
|
247
277
|
// (`record(event, query)`) — so bind the query to the shared authzPool (mirrors the
|
|
@@ -275,7 +305,9 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
275
305
|
};
|
|
276
306
|
});
|
|
277
307
|
const policyService = new policy_service_js_1.PolicyService({
|
|
278
|
-
registry:
|
|
308
|
+
// The shared registry (KI #57): the schema below already offers the consumer's types, so
|
|
309
|
+
// the author must know their actions too.
|
|
310
|
+
registry: recordAuthorization.registry,
|
|
279
311
|
policyStore,
|
|
280
312
|
decide: crossTenantAuthority,
|
|
281
313
|
audit: policyAudit,
|
|
@@ -401,6 +433,14 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
401
433
|
// NestJS DiscoveryService/MetadataScanner — the boot bootstrap provider reflects the
|
|
402
434
|
// live controllers through this to build the OperationInventory.
|
|
403
435
|
core_1.DiscoveryModule,
|
|
436
|
+
// Phase 9 #7: the ONE record authorization, global so ShieldModule's decision-authority
|
|
437
|
+
// factory reads it (guard never imports identity; the token is the seam).
|
|
438
|
+
{
|
|
439
|
+
module: IdentityRecordAuthorizationModule,
|
|
440
|
+
global: true,
|
|
441
|
+
providers: [{ provide: guard_1.SHIELD_RECORD_AUTHORIZATION, useValue: recordAuthorization }],
|
|
442
|
+
exports: [guard_1.SHIELD_RECORD_AUTHORIZATION],
|
|
443
|
+
},
|
|
404
444
|
],
|
|
405
445
|
// Route order: RoleController stays AHEAD of GrantAdminController (documented Nest
|
|
406
446
|
// route-precedence constraint — the summary route `access/role-grants` dodges
|
|
@@ -449,6 +489,13 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
449
489
|
{ token: guard_1.SHIELD_SESSION_SWEEPER, optional: true },
|
|
450
490
|
],
|
|
451
491
|
},
|
|
492
|
+
// Phase 9 #7: fail the boot unless the request path decides with the authority guard
|
|
493
|
+
// built from this module's record authorization (never the silent deny-everything stub).
|
|
494
|
+
{
|
|
495
|
+
provide: IDENTITY_DECISION_AUTHORITY_CHECK,
|
|
496
|
+
useFactory: assertRequestPathUsesIdentityAuthorization,
|
|
497
|
+
inject: [{ token: guard_1.SHIELD_DECISION_AUTHORITY, optional: true }],
|
|
498
|
+
},
|
|
452
499
|
{
|
|
453
500
|
provide: IDENTITY_VERSION_PUBLISHER_LINK,
|
|
454
501
|
useFactory: (cache) => {
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* (`GrantCeiling.assertRoleAssignable` — a role also used elsewhere is never assignable),
|
|
15
15
|
* and the role's keys are all held by the actor (`assertCanAssignRoles`, ADR-0029 applied to
|
|
16
16
|
* assignment). Removals need no ceiling: self-demotion stays allowed.
|
|
17
|
+
* - for every ADDED posting: its department and branch belong to the membership's company
|
|
18
|
+
* (`assertOrgUnitsInCompany`, Phase 9 #13b) — else 400.
|
|
17
19
|
*
|
|
18
20
|
* ── Atomicity (KI #16) ────────────────────────────────────────────────────────────────
|
|
19
21
|
* The diff runs inside ONE `records.transaction` (ADR-0034): creates FIRST, then deletes, so a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"posting.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/posting.service.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"posting.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/posting.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAGH,OAAO,EAAE,cAAc,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAwB,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAC1F,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAGzD;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACvC,cAAc,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CACzC;AA0ED,qBACa,cAAc;IAEvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAGxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAKxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;gBARZ,OAAO,EAAE,cAAc,EAGvB,OAAO,EAAE,YAAY,EAKrB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA;IAG1D,gEAAgE;IAC1D,YAAY,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,UAAU,EAAE,CAAC;IAK1E;;;;;OAKG;IACG,eAAe,CACnB,KAAK,EAAE,SAAS,EAChB,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,SAAS,YAAY,EAAE,GAC/B,OAAO,CAAC,IAAI,CAAC;CA8GjB"}
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* (`GrantCeiling.assertRoleAssignable` — a role also used elsewhere is never assignable),
|
|
16
16
|
* and the role's keys are all held by the actor (`assertCanAssignRoles`, ADR-0029 applied to
|
|
17
17
|
* assignment). Removals need no ceiling: self-demotion stays allowed.
|
|
18
|
+
* - for every ADDED posting: its department and branch belong to the membership's company
|
|
19
|
+
* (`assertOrgUnitsInCompany`, Phase 9 #13b) — else 400.
|
|
18
20
|
*
|
|
19
21
|
* ── Atomicity (KI #16) ────────────────────────────────────────────────────────────────
|
|
20
22
|
* The diff runs inside ONE `records.transaction` (ADR-0034): creates FIRST, then deletes, so a
|
|
@@ -163,6 +165,13 @@ let PostingService = class PostingService {
|
|
|
163
165
|
await this.ceiling.assertRoleAssignable(actor, roleUuid);
|
|
164
166
|
}
|
|
165
167
|
await this.ceiling.assertCanAssignRoles(actor, addedRoles);
|
|
168
|
+
// Phase 9 #13b (review F4): an added posting's department/branch must be the membership
|
|
169
|
+
// company's own (junction rows), never another tenant's (400).
|
|
170
|
+
const present = (v) => typeof v === 'string' && v.length > 0;
|
|
171
|
+
await this.ceiling.assertOrgUnitsInCompany(tenantUuid, {
|
|
172
|
+
departmentUuids: added.map((d) => d.departmentUuid).filter(present),
|
|
173
|
+
branchUuids: added.map((d) => d.branchUuid).filter(present),
|
|
174
|
+
});
|
|
166
175
|
// Removing a PEER's postings strips their authority: the actor must dominate the member
|
|
167
176
|
// (security review). Removing your OWN postings (self-demotion) stays allowed.
|
|
168
177
|
const memberUuid = typeof membership.fields?.userUuid === 'string' ? membership.fields.userUuid : '';
|
|
@@ -42,13 +42,20 @@ export declare class RoleService {
|
|
|
42
42
|
* cached grants silently stale.
|
|
43
43
|
*
|
|
44
44
|
* The role's `role_permission` rows (shield_authz, no FK) and its place in every service
|
|
45
|
-
* account's `sa_role_ids` (read live by the API-key store) do not cascade, so
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
45
|
+
* account's `sa_role_ids` (read live by the API-key store) do not cascade, so
|
|
46
|
+
* `GrantAdminService.purgeRole` removes both in its own committed authz transaction — checking
|
|
47
|
+
* first that the actor may change each holding account, and bumping every tenant that used the
|
|
48
|
+
* role (account tenants included).
|
|
49
|
+
*
|
|
50
|
+
* Phase 9 #5: the engine's delete (its full gate: the coarse `role:delete` decision, then the
|
|
51
|
+
* record-level one, through the single decision authority) runs BEFORE the purge, on this
|
|
52
|
+
* transaction. A refused delete throws before anything is purged, so nothing changes. The purge
|
|
53
|
+
* then commits on its own connection while this transaction still holds the role row; it reads
|
|
54
|
+
* only committed state (the postings this delete removes are still visible to it, so their
|
|
55
|
+
* tenants are bumped). A purge refusal or failure rolls the delete back. A failure after the
|
|
56
|
+
* purge committed (this commit) leaves a role with no grants and no agents: fewer permissions,
|
|
57
|
+
* never live grants or an agent carrying a deleted role. A re-created role gets a fresh uuid
|
|
58
|
+
* (the engine mints it), so it can never pick up anything left behind.
|
|
52
59
|
*/
|
|
53
60
|
deleteRole(actor: Principal, uuid: string): Promise<void>;
|
|
54
61
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"role.service.d.ts","sourceRoot":"","sources":["../../../src/features/role/role.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,OAAO,EAAkB,KAAK,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1E,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iCAAiC,CAAC;AACpE,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,uCAAuC,CAAC;AAK/C;;;;;;;;;GASG;AACH,qBACa,WAAW;IAEpB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAKxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAK7B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;gBAXP,OAAO,EAAE,cAAc,EACvB,OAAO,EAAE,YAAY,EAKrB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA,EAKvC,MAAM,CAAC,GAAE,iBAAiB,GAAG,IAAI,aAAA;IAGpD,qBAAqB;IACrB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAI3C,uEAAuE;IACvE,SAAS,CAAC,MAAM,CAAC,EAAE,UAAU;IAI7B,0BAA0B;IAC1B,OAAO,CAAC,IAAI,EAAE,MAAM;IAIpB,8FAA8F;IACxF,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE;QAAE,IAAI,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAO1F
|
|
1
|
+
{"version":3,"file":"role.service.d.ts","sourceRoot":"","sources":["../../../src/features/role/role.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,OAAO,EAAkB,KAAK,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1E,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iCAAiC,CAAC;AACpE,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,uCAAuC,CAAC;AAK/C;;;;;;;;;GASG;AACH,qBACa,WAAW;IAEpB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAKxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAK7B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;gBAXP,OAAO,EAAE,cAAc,EACvB,OAAO,EAAE,YAAY,EAKrB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA,EAKvC,MAAM,CAAC,GAAE,iBAAiB,GAAG,IAAI,aAAA;IAGpD,qBAAqB;IACrB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAI3C,uEAAuE;IACvE,SAAS,CAAC,MAAM,CAAC,EAAE,UAAU;IAI7B,0BAA0B;IAC1B,OAAO,CAAC,IAAI,EAAE,MAAM;IAIpB,8FAA8F;IACxF,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE;QAAE,IAAI,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAO1F;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACG,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAoBhE"}
|
|
@@ -75,13 +75,20 @@ let RoleService = class RoleService {
|
|
|
75
75
|
* cached grants silently stale.
|
|
76
76
|
*
|
|
77
77
|
* The role's `role_permission` rows (shield_authz, no FK) and its place in every service
|
|
78
|
-
* account's `sa_role_ids` (read live by the API-key store) do not cascade, so
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
78
|
+
* account's `sa_role_ids` (read live by the API-key store) do not cascade, so
|
|
79
|
+
* `GrantAdminService.purgeRole` removes both in its own committed authz transaction — checking
|
|
80
|
+
* first that the actor may change each holding account, and bumping every tenant that used the
|
|
81
|
+
* role (account tenants included).
|
|
82
|
+
*
|
|
83
|
+
* Phase 9 #5: the engine's delete (its full gate: the coarse `role:delete` decision, then the
|
|
84
|
+
* record-level one, through the single decision authority) runs BEFORE the purge, on this
|
|
85
|
+
* transaction. A refused delete throws before anything is purged, so nothing changes. The purge
|
|
86
|
+
* then commits on its own connection while this transaction still holds the role row; it reads
|
|
87
|
+
* only committed state (the postings this delete removes are still visible to it, so their
|
|
88
|
+
* tenants are bumped). A purge refusal or failure rolls the delete back. A failure after the
|
|
89
|
+
* purge committed (this commit) leaves a role with no grants and no agents: fewer permissions,
|
|
90
|
+
* never live grants or an agent carrying a deleted role. A re-created role gets a fresh uuid
|
|
91
|
+
* (the engine mints it), so it can never pick up anything left behind.
|
|
85
92
|
*/
|
|
86
93
|
async deleteRole(actor, uuid) {
|
|
87
94
|
await this.ceiling.assertRoleAdministrable(actor, uuid);
|
|
@@ -93,8 +100,8 @@ let RoleService = class RoleService {
|
|
|
93
100
|
await this.ceiling.assertRoleAdministrable(actor, uuid);
|
|
94
101
|
await this.ceiling.assertDominatesRoleHolders(actor, uuid);
|
|
95
102
|
const affected = (await this.versionBump?.tenantsForRole?.(uuid)) ?? [];
|
|
103
|
+
await tx.delete('role', uuid); // the engine's delete gate decides first (Phase 9 #5)
|
|
96
104
|
await grants.purgeRole(uuid, actor);
|
|
97
|
-
await tx.delete('role', uuid);
|
|
98
105
|
return affected;
|
|
99
106
|
});
|
|
100
107
|
if (!this.versionBump)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lock the service accounts matching a predicate, without locking the table (Phase 9 #11).
|
|
3
|
+
*
|
|
4
|
+
* `service_account` has no index a role or scope predicate can use (`JSON_CONTAINS` on a JSON
|
|
5
|
+
* column, or an `OR` with one), so `SELECT … WHERE <predicate> FOR UPDATE` scans the whole table
|
|
6
|
+
* and InnoDB (REPEATABLE READ) keeps a next-key lock on every row it scanned: every other agent's
|
|
7
|
+
* edit, and every new agent, waits until the transaction ends. Instead:
|
|
8
|
+
* 1. a NON-locking read finds the candidate ids (a consistent read, no locks);
|
|
9
|
+
* 2. `FOR UPDATE` by primary key locks only those rows, sorted by id (a stable lock order), and
|
|
10
|
+
* re-evaluates the predicate on the latest committed version of each, so a candidate that
|
|
11
|
+
* stopped matching in between is dropped.
|
|
12
|
+
*
|
|
13
|
+
* A row that STARTS matching after step 1 (an agent that gains the role, or the company in its
|
|
14
|
+
* scope) is not locked or returned. Callers accept that (see their docs): the outcome equals the
|
|
15
|
+
* serial order "this transaction, then that edit", which the table-wide lock also allowed (the
|
|
16
|
+
* edit only waited, then applied).
|
|
17
|
+
*
|
|
18
|
+
* `columns` and `where.sql` are fixed SQL written by the caller (never client input); every value
|
|
19
|
+
* is `?`-bound.
|
|
20
|
+
*/
|
|
21
|
+
/** The connection surface: one transaction's `query`. */
|
|
22
|
+
export interface LockConn {
|
|
23
|
+
query(sql: string, params?: readonly unknown[]): Promise<unknown>;
|
|
24
|
+
}
|
|
25
|
+
export declare function lockServiceAccountsWhere<T>(conn: LockConn, columns: string, where: {
|
|
26
|
+
readonly sql: string;
|
|
27
|
+
readonly params: readonly unknown[];
|
|
28
|
+
}): Promise<T[]>;
|
|
29
|
+
//# sourceMappingURL=lock-service-accounts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lock-service-accounts.d.ts","sourceRoot":"","sources":["../../../src/features/service-account/lock-service-accounts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,yDAAyD;AACzD,MAAM,WAAW,QAAQ;IACvB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAED,wBAAsB,wBAAwB,CAAC,CAAC,EAC9C,IAAI,EAAE,QAAQ,EACd,OAAO,EAAE,MAAM,EACf,KAAK,EAAE;IAAE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,SAAS,OAAO,EAAE,CAAA;CAAE,GACnE,OAAO,CAAC,CAAC,EAAE,CAAC,CAed"}
|