@astram/identity 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +14 -5
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.service.js +27 -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 +34 -7
- 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 +6 -1
- package/dist/features/company/company-hierarchy.service.d.ts.map +1 -1
- package/dist/features/company/company-hierarchy.service.js +19 -7
- 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 +17 -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/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
|
@@ -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;AASpF;;;;;;;GAOG;AACH,qBACa,cAAc;IAEvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,SAAS;gBADT,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,uBAAuB;IAGrD;;;;;OAKG;IACG,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAetE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAmC;IAE1D,2EAA2E;IAC3E,aAAa,CAAC,MAAM,CAAC,EAAE,UAAU;IAOjC,6BAA6B;IAC7B,UAAU,CAAC,IAAI,EAAE,MAAM;IAIvB;;;;;;;OAOG;IACG,aAAa,CACjB,KAAK,EAAE,SAAS,EAChB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GACvC,OAAO,CAAC,IAAI,CAAC;IAiBhB,oGAAoG;IACpG,WAAW,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAIrF;;;;;OAKG;IACG,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAKnE"}
|
|
@@ -66,13 +66,28 @@ let CompanyService = CompanyService_1 = class CompanyService {
|
|
|
66
66
|
getCompany(uuid) {
|
|
67
67
|
return this.records.get('company', uuid);
|
|
68
68
|
}
|
|
69
|
-
/**
|
|
70
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Update a company's name/status. Any other key (parentUuid, type, …) is dropped.
|
|
71
|
+
*
|
|
72
|
+
* Phase 9 #13c: a non-root actor may not change the STATUS of the organisation its access is
|
|
73
|
+
* rooted at (the same own-root guard as move and delete, F6): deactivating it would act on the
|
|
74
|
+
* whole tenant above the actor's authority. Re-sending the current status (the company form
|
|
75
|
+
* always sends both fields) stays allowed, so the own root can still be renamed.
|
|
76
|
+
*/
|
|
77
|
+
async updateCompany(actor, uuid, patch) {
|
|
71
78
|
const fields = {};
|
|
72
79
|
for (const key of COMPANY_PATCH_KEYS) {
|
|
73
80
|
if (typeof patch?.[key] === 'string')
|
|
74
81
|
fields[key] = patch[key];
|
|
75
82
|
}
|
|
83
|
+
if (actor.isRoot !== true && fields.status !== undefined && uuid === (0, company_hierarchy_service_js_1.ownRootOf)(actor)) {
|
|
84
|
+
const current = await this.records.get('company', uuid);
|
|
85
|
+
if (current === null)
|
|
86
|
+
throw new identity_errors_js_1.IdentityNotFoundError('Company not found.');
|
|
87
|
+
if (current.fields.status !== fields.status) {
|
|
88
|
+
throw new identity_errors_js_1.IdentityForbiddenError('You cannot change the status of the organisation your access is rooted at.');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
76
91
|
return this.records.update('company', uuid, { fields });
|
|
77
92
|
}
|
|
78
93
|
/** Move a company under a new parent (hierarchy rules + cycle check, see the hierarchy service). */
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { type DynamicModule } from '@nestjs/common';
|
|
2
2
|
import { type ObjectTypeInput } from '@astram/engine';
|
|
3
|
+
/**
|
|
4
|
+
* The boot check behind {@link IDENTITY_DECISION_AUTHORITY_CHECK}: with this module wired, the
|
|
5
|
+
* request path must decide with the authority guard built from this module's inputs. A missing
|
|
6
|
+
* authority (no `ShieldModule`) or the deny-everything stub (a guard that does not read
|
|
7
|
+
* `SHIELD_RECORD_AUTHORIZATION`) fails the boot instead of silently denying, or deciding with a
|
|
8
|
+
* different registry. (Another source next to this one is refused by `ShieldModule` itself.)
|
|
9
|
+
*/
|
|
10
|
+
export declare function assertRequestPathUsesIdentityAuthorization(authority: unknown): true;
|
|
3
11
|
/**
|
|
4
12
|
* IdentityFeatureModule — the framework-owned identity FEATURE surface (company/user/role CRUD;
|
|
5
13
|
* more in later phases) as thin controllers over the engine's RecordsService. The CONSUMER imports
|
|
@@ -19,14 +27,13 @@ export declare class IdentityFeatureModule {
|
|
|
19
27
|
* by the writer's own `decide()` (a route-allows/writer-denies split). Omitted ⇒
|
|
20
28
|
* `isRoot` is always false (a non-root consumer works; only a root offering
|
|
21
29
|
* cross-tenant grants needs it). See ADR-0026 + the template wiring.
|
|
22
|
-
* @param extraObjects the consumer's OWN registered object types
|
|
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"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Lock the service accounts matching a predicate, without locking the table (Phase 9 #11).
|
|
4
|
+
*
|
|
5
|
+
* `service_account` has no index a role or scope predicate can use (`JSON_CONTAINS` on a JSON
|
|
6
|
+
* column, or an `OR` with one), so `SELECT … WHERE <predicate> FOR UPDATE` scans the whole table
|
|
7
|
+
* and InnoDB (REPEATABLE READ) keeps a next-key lock on every row it scanned: every other agent's
|
|
8
|
+
* edit, and every new agent, waits until the transaction ends. Instead:
|
|
9
|
+
* 1. a NON-locking read finds the candidate ids (a consistent read, no locks);
|
|
10
|
+
* 2. `FOR UPDATE` by primary key locks only those rows, sorted by id (a stable lock order), and
|
|
11
|
+
* re-evaluates the predicate on the latest committed version of each, so a candidate that
|
|
12
|
+
* stopped matching in between is dropped.
|
|
13
|
+
*
|
|
14
|
+
* A row that STARTS matching after step 1 (an agent that gains the role, or the company in its
|
|
15
|
+
* scope) is not locked or returned. Callers accept that (see their docs): the outcome equals the
|
|
16
|
+
* serial order "this transaction, then that edit", which the table-wide lock also allowed (the
|
|
17
|
+
* edit only waited, then applied).
|
|
18
|
+
*
|
|
19
|
+
* `columns` and `where.sql` are fixed SQL written by the caller (never client input); every value
|
|
20
|
+
* is `?`-bound.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.lockServiceAccountsWhere = lockServiceAccountsWhere;
|
|
24
|
+
const APP = 'shield_app';
|
|
25
|
+
async function lockServiceAccountsWhere(conn, columns, where) {
|
|
26
|
+
const candidates = (await conn.query(`SELECT sa_id FROM \`${APP}\`.\`service_account\` WHERE ${where.sql}`, [...where.params]));
|
|
27
|
+
const ids = [...new Set(candidates.map((r) => r.sa_id))].sort();
|
|
28
|
+
if (ids.length === 0)
|
|
29
|
+
return [];
|
|
30
|
+
return (await conn.query(`SELECT ${columns}
|
|
31
|
+
FROM \`${APP}\`.\`service_account\`
|
|
32
|
+
WHERE sa_id IN (${ids.map(() => '?').join(', ')}) AND (${where.sql})
|
|
33
|
+
ORDER BY sa_id
|
|
34
|
+
FOR UPDATE`, [...ids, ...where.params]));
|
|
35
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The non-expiring service-account key report (security hardening Phase 8 #13, shipped as the
|
|
3
|
+
* `astram-sa-keys-report` bin in Phase 9 #8).
|
|
4
|
+
*
|
|
5
|
+
* Keys issued before an expiry became mandatory have `sak_expires_at_ms IS NULL` and never expire
|
|
6
|
+
* (a key issued later with an explicit `neverExpires: true` looks the same). This read-only report
|
|
7
|
+
* lists each agent's CURRENT, unrevoked key with no expiry so an operator can rotate it with an
|
|
8
|
+
* expiry. It prints labels and company names only: never a secret, a hash or a raw id.
|
|
9
|
+
*/
|
|
10
|
+
/** Read-only: the current unrevoked key of each agent that has no expiry, with display names. */
|
|
11
|
+
export declare const NO_EXPIRY_KEY_REPORT_SQL = "SELECT sa.sa_label AS label, c.c_name AS company,\n sa.sa_status AS status, k.sak_version AS version, k.sak_created_at_ms AS issuedAtMs\n FROM shield_authz.service_account_key k\n LEFT JOIN shield_app.service_account sa ON sa.sa_id = k.sak_account_id\n LEFT JOIN shield_app.companies c ON c.c_uuid = k.sak_tenant_id\n WHERE k.sak_expires_at_ms IS NULL\n AND k.sak_revoked_at_ms IS NULL\n AND k.sak_version = (SELECT MAX(v.sak_version)\n FROM shield_authz.service_account_key v\n WHERE v.sak_account_id = k.sak_account_id)\n ORDER BY k.sak_created_at_ms";
|
|
12
|
+
/** One non-expiring key, as printed (no ids, no secrets). */
|
|
13
|
+
export interface NonExpiringKey {
|
|
14
|
+
/** The agent's label; `null` when the agent row no longer exists. */
|
|
15
|
+
readonly label: string | null;
|
|
16
|
+
/** The tenant company's name; `null` when the company no longer exists. */
|
|
17
|
+
readonly company: string | null;
|
|
18
|
+
readonly status: string | null;
|
|
19
|
+
readonly version: number;
|
|
20
|
+
readonly issuedAtMs: number | null;
|
|
21
|
+
}
|
|
22
|
+
/** The query surface the report needs (a `mariadb` pool or connection satisfies it). */
|
|
23
|
+
export interface ReportQueryable {
|
|
24
|
+
query(sql: string, params?: unknown[]): Promise<unknown>;
|
|
25
|
+
}
|
|
26
|
+
/** Run the report query and map the rows. Read-only. */
|
|
27
|
+
export declare function findNonExpiringKeys(db: ReportQueryable): Promise<NonExpiringKey[]>;
|
|
28
|
+
/** Human-readable report text (one line per key, plus a summary line). */
|
|
29
|
+
export declare function formatNonExpiringKeyReport(keys: readonly NonExpiringKey[]): string;
|
|
30
|
+
/** The bin's behaviour, testable without a process: returns the exit code. */
|
|
31
|
+
export declare function runNonExpiringKeyReport(opts: {
|
|
32
|
+
readonly argv: readonly string[];
|
|
33
|
+
readonly openDb: () => ReportQueryable & {
|
|
34
|
+
end(): Promise<void>;
|
|
35
|
+
};
|
|
36
|
+
readonly out: (text: string) => void;
|
|
37
|
+
readonly err: (text: string) => void;
|
|
38
|
+
}): Promise<number>;
|
|
39
|
+
//# sourceMappingURL=no-expiry-key-report.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"no-expiry-key-report.d.ts","sourceRoot":"","sources":["../../../src/features/service-account/no-expiry-key-report.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,iGAAiG;AACjG,eAAO,MAAM,wBAAwB,2mBAUP,CAAC;AAE/B,6DAA6D;AAC7D,MAAM,WAAW,cAAc;IAC7B,qEAAqE;IACrE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,2EAA2E;IAC3E,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED,wFAAwF;AACxF,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC1D;AAMD,wDAAwD;AACxD,wBAAsB,mBAAmB,CAAC,EAAE,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CASxF;AAED,0EAA0E;AAC1E,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,SAAS,cAAc,EAAE,GAAG,MAAM,CAalF;AAcD,8EAA8E;AAC9E,wBAAsB,uBAAuB,CAAC,IAAI,EAAE;IAClD,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,eAAe,GAAG;QAAE,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;KAAE,CAAC;IAClE,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACtC,GAAG,OAAO,CAAC,MAAM,CAAC,CAwBlB"}
|