@astram/identity 0.5.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 +33 -1
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.service.js +105 -1
- package/dist/features/authz/grant-ceiling.d.ts +62 -1
- package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
- package/dist/features/authz/grant-ceiling.js +145 -10
- package/dist/features/authz/permission-version.bumper.d.ts +7 -0
- package/dist/features/authz/permission-version.bumper.d.ts.map +1 -1
- package/dist/features/authz/permission-version.bumper.js +19 -10
- package/dist/features/authz/record-authorization.d.ts +16 -21
- package/dist/features/authz/record-authorization.d.ts.map +1 -1
- package/dist/features/authz/record-authorization.js +17 -22
- package/dist/features/company/company-hierarchy.service.d.ts +71 -4
- package/dist/features/company/company-hierarchy.service.d.ts.map +1 -1
- package/dist/features/company/company-hierarchy.service.js +197 -8
- 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 -6
- package/dist/features/identity-feature.module.d.ts.map +1 -1
- package/dist/features/identity-feature.module.js +79 -22
- package/dist/features/membership/posting-version-bump.d.ts +7 -0
- package/dist/features/membership/posting-version-bump.d.ts.map +1 -1
- 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 +32 -2
- package/dist/features/role/role.service.d.ts.map +1 -1
- package/dist/features/role/role.service.js +64 -4
- package/dist/features/scope/scope-closure.service.d.ts +11 -1
- package/dist/features/scope/scope-closure.service.d.ts.map +1 -1
- package/dist/features/scope/scope-closure.service.js +23 -4
- 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/service-account/service-account.service.d.ts +15 -0
- package/dist/features/service-account/service-account.service.d.ts.map +1 -1
- package/dist/features/service-account/service-account.service.js +17 -1
- package/dist/features/service-account/service-account.store.d.ts +15 -0
- package/dist/features/service-account/service-account.store.d.ts.map +1 -1
- package/dist/features/service-account/service-account.store.js +29 -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 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -11
- package/dist/internal.d.ts +11 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +14 -0
- package/package.json +18 -4
|
@@ -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,12 +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
|
-
*
|
|
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").
|
|
28
37
|
*/
|
|
29
38
|
static forRoot({ rootIds, extraObjects, }?: {
|
|
30
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;AA6FxB;;;;;;;;;;GAUG;AACH,qBACa,qBAAqB;IAChC
|
|
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,28 +121,39 @@ 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
|
-
*
|
|
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").
|
|
100
131
|
*/
|
|
101
132
|
static forRoot({ rootIds, extraObjects = [], } = {}) {
|
|
102
133
|
// ONE shared shield_authz pool for the whole grant-admin stack (ADR-0011): the
|
|
103
134
|
// permission-version bumper, the AccessBundleStore, the GrantAdminService, and the two
|
|
104
135
|
// reconcile jobs all run over this single pool (don't open several). Opened once at
|
|
105
|
-
// 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).
|
|
106
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;
|
|
107
146
|
const bumper = new permission_version_bumper_js_1.PermissionVersionBumper();
|
|
108
147
|
const postingVersionBump = {
|
|
109
148
|
bumpTenant: (tenantUuid) => bumper.bumpForTenant(tenantUuid, authzPool),
|
|
110
149
|
// A user disabled/re-enabled: every membership tenant (autocommit pool → published at once).
|
|
111
150
|
bumpForUser: (userUuid) => bumper.bumpForUser(userUuid, authzPool),
|
|
151
|
+
// A role delete (Phase 8 #2): its holder tenants, read before the delete cascades postings.
|
|
152
|
+
tenantsForRole: (roleUuid) => bumper.tenantsForRole(roleUuid, authzPool),
|
|
112
153
|
};
|
|
113
154
|
// The core ModuleRegistry the catalog reconcile job needs, built from the identity
|
|
114
|
-
// object registrations (guard's bridge reflects decorated classes → registry)
|
|
115
|
-
//
|
|
155
|
+
// object registrations only (guard's bridge reflects decorated classes → registry): it
|
|
156
|
+
// catalogs the identity permissions. Authorities use the shared `recordAuthorization.registry`.
|
|
116
157
|
const identityRegistry = (0, guard_1.buildIdentityModuleRegistry)([...objects_js_1.identityObjects]);
|
|
117
158
|
// The grant-admin collaborators built at forRoot() over the shared pool (mirrors the
|
|
118
159
|
// postingVersionBump closure pattern above). GrantCeiling (ADR-0029) enforces the
|
|
@@ -120,12 +161,8 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
120
161
|
// resolved over the SAME authz pool + registry the object-type gate uses (one source of
|
|
121
162
|
// truth for "what a principal holds"). ALWAYS wired here, so production always enforces.
|
|
122
163
|
const accessBundleStore = new access_bundle_store_js_1.AccessBundleStore(authzPool);
|
|
123
|
-
//
|
|
124
|
-
// `
|
|
125
|
-
// role holding a consumer-type key could never be assigned or granted by a non-root admin who
|
|
126
|
-
// holds that key (review M5) — the security-hardening role-assignment ceiling makes that
|
|
127
|
-
// reachable from every posting edit. `rootIds` marks platform roots as never dominated.
|
|
128
|
-
const ceilingRegistry = (0, guard_1.buildIdentityModuleRegistry)([...objects_js_1.identityObjects, ...extraObjects]);
|
|
164
|
+
// (`ceilingRegistry` is the shared record-authorization registry built at the top of forRoot.)
|
|
165
|
+
// `rootIds` marks platform roots as never dominated.
|
|
129
166
|
// rootIds is passed EXPLICITLY (review F5): `null` states "not configured". The dominance
|
|
130
167
|
// check still refuses any user flagged `u_is_platform_superuser`, so an unconfigured list
|
|
131
168
|
// never lets a sub-admin take over a platform administrator.
|
|
@@ -146,7 +183,8 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
146
183
|
const scopeClosureService = new scope_closure_service_js_1.ScopeClosureService(authzPool, bumper);
|
|
147
184
|
// Company hierarchy (security hardening #2): the ONLY path that moves/deletes/attaches a company
|
|
148
185
|
// node — through the closure service above — audited on the move/delete transaction.
|
|
149
|
-
const companyHierarchyService = new company_hierarchy_service_js_1.CompanyHierarchyService(authzPool, scopeClosureService,
|
|
186
|
+
const companyHierarchyService = new company_hierarchy_service_js_1.CompanyHierarchyService(authzPool, scopeClosureService, grantCeiling, // Phase 8 #3: a company delete must dominate every member
|
|
187
|
+
new engine_1.MariaDbAuditSink({}));
|
|
150
188
|
// Audit viewer (Phase 5): the AuditReader + LabelResolver over the SAME shared authzPool
|
|
151
189
|
// (the reader queries shield_audit + the resolver/actor-lookup query shield_app, both
|
|
152
190
|
// reachable by that pool). `auditNameToId` is the actor-name -> uuid reverse lookup for the
|
|
@@ -165,7 +203,8 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
165
203
|
// authz.module, the anti-pattern this replaces). A SECOND DecisionAuthority is built over the
|
|
166
204
|
// same record-authorization inputs (matches astram3): the route guard's own decide() is the
|
|
167
205
|
// first; this one backs the writer's server-side re-verification.
|
|
168
|
-
|
|
206
|
+
// Same inputs as the request path (KI #57): a consumer type is registered here too.
|
|
207
|
+
const crossTenantAuthority = (0, guard_1.buildRecordAuthorization)(recordAuthorization);
|
|
169
208
|
// Record-level LIST SCOPE (Task 3.1, Invariant #8): the engine-facing fn the engine's
|
|
170
209
|
// `list()` ANDs into BOTH the data SELECT and the COUNT — the in-query visible-set filter.
|
|
171
210
|
// Built HERE, over the SAME shared authzPool (guard constructs the un-overridable scope
|
|
@@ -173,7 +212,7 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
173
212
|
// out-of-the-box into `EngineModule.forRoot({ listScope })` below (ADR-0003 injection seam —
|
|
174
213
|
// the engine never imports guard). Fail-closed: a store outage propagates and `list()` fails
|
|
175
214
|
// rather than returning unscoped rows.
|
|
176
|
-
const listScope = (0, guard_1.buildListScopeFn)(
|
|
215
|
+
const listScope = (0, guard_1.buildListScopeFn)(recordAuthorization);
|
|
177
216
|
// The writer's `GrantAuthorizeFn`: decide()-backed, fail-closed (no try/catch — a throw
|
|
178
217
|
// propagates and the writer maps it to a retryable 503). isRoot ← the SAME rootIds the
|
|
179
218
|
// consumer passes to ShieldModule.forRoot (root-correctness for control-plane grants).
|
|
@@ -227,11 +266,12 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
227
266
|
audit: grantAudit,
|
|
228
267
|
});
|
|
229
268
|
// The read side (inbox/mine/incoming/offerable-types) over the same pool + the merged object
|
|
230
|
-
// set (offerable = tenantScoped types only) + the per-type
|
|
231
|
-
|
|
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);
|
|
232
272
|
// ── GUIDED RULE-BUILDER (list-scoping engine Phase 5, §9.1) ──────────────────────────────
|
|
233
273
|
// The policy-authoring surface over the guard `MariaDbPolicyStore` + core `makeAuthorPolicy`.
|
|
234
|
-
// Reuses the SAME
|
|
274
|
+
// Reuses the SAME shared registry + `crossTenantAuthority` (a DecisionAuthority over the
|
|
235
275
|
// shared authzPool — the delegated-admin ceiling's decide()) as the cross-tenant writer. The
|
|
236
276
|
// core AuditSink is one-arg (`record(event)`); the engine `MariaDbAuditSink` is two-arg
|
|
237
277
|
// (`record(event, query)`) — so bind the query to the shared authzPool (mirrors the
|
|
@@ -265,7 +305,9 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
265
305
|
};
|
|
266
306
|
});
|
|
267
307
|
const policyService = new policy_service_js_1.PolicyService({
|
|
268
|
-
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,
|
|
269
311
|
policyStore,
|
|
270
312
|
decide: crossTenantAuthority,
|
|
271
313
|
audit: policyAudit,
|
|
@@ -391,6 +433,14 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
391
433
|
// NestJS DiscoveryService/MetadataScanner — the boot bootstrap provider reflects the
|
|
392
434
|
// live controllers through this to build the OperationInventory.
|
|
393
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
|
+
},
|
|
394
444
|
],
|
|
395
445
|
// Route order: RoleController stays AHEAD of GrantAdminController (documented Nest
|
|
396
446
|
// route-precedence constraint — the summary route `access/role-grants` dodges
|
|
@@ -439,6 +489,13 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
439
489
|
{ token: guard_1.SHIELD_SESSION_SWEEPER, optional: true },
|
|
440
490
|
],
|
|
441
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
|
+
},
|
|
442
499
|
{
|
|
443
500
|
provide: IDENTITY_VERSION_PUBLISHER_LINK,
|
|
444
501
|
useFactory: (cache) => {
|
|
@@ -10,6 +10,13 @@ export interface PostingVersionBump {
|
|
|
10
10
|
* the account itself was disabled or re-enabled. Returns the bumped tenants; throws on failure.
|
|
11
11
|
*/
|
|
12
12
|
bumpForUser?(userUuid: string): Promise<string[]>;
|
|
13
|
+
/**
|
|
14
|
+
* OPTIONAL (Phase 8 #2): the tenants whose cached grants change when the role goes away — every
|
|
15
|
+
* tenant where it is actively posted, plus every service account's tenant holding it. Read
|
|
16
|
+
* BEFORE a role delete (the delete cascades the postings); bump each with `bumpTenant` after the
|
|
17
|
+
* commit. Throws on failure.
|
|
18
|
+
*/
|
|
19
|
+
tenantsForRole?(roleUuid: string): Promise<string[]>;
|
|
13
20
|
}
|
|
14
21
|
export declare const POSTING_VERSION_BUMP: unique symbol;
|
|
15
22
|
//# sourceMappingURL=posting-version-bump.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"posting-version-bump.d.ts","sourceRoot":"","sources":["../../../src/features/membership/posting-version-bump.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C;;;OAGG;IACH,WAAW,CAAC,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"posting-version-bump.d.ts","sourceRoot":"","sources":["../../../src/features/membership/posting-version-bump.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C;;;OAGG;IACH,WAAW,CAAC,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAClD;;;;;OAKG;IACH,cAAc,CAAC,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CACtD;AAED,eAAO,MAAM,oBAAoB,eAAiC,CAAC"}
|
|
@@ -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 : '';
|
|
@@ -2,6 +2,8 @@ import { RecordsService, type RecordId } from '@astram/engine';
|
|
|
2
2
|
import type { Principal } from '@astram/core';
|
|
3
3
|
import { type ListParams } from '../shared/list-query.js';
|
|
4
4
|
import { GrantCeiling } from '../authz/grant-ceiling.js';
|
|
5
|
+
import { GrantAdminService } from '../authz/grant-admin.service.js';
|
|
6
|
+
import { type PostingVersionBump } from '../membership/posting-version-bump.js';
|
|
5
7
|
/**
|
|
6
8
|
* RoleService — role CRUD on the paved road, via the framework's RecordsService
|
|
7
9
|
* facade. The facade supplies the acting identity (the request principal) for the pipe;
|
|
@@ -15,7 +17,9 @@ import { GrantCeiling } from '../authz/grant-ceiling.js';
|
|
|
15
17
|
export declare class RoleService {
|
|
16
18
|
private readonly records;
|
|
17
19
|
private readonly ceiling;
|
|
18
|
-
|
|
20
|
+
private readonly versionBump?;
|
|
21
|
+
private readonly grants?;
|
|
22
|
+
constructor(records: RecordsService, ceiling: GrantCeiling, versionBump?: (PostingVersionBump | null) | undefined, grants?: (GrantAdminService | null) | undefined);
|
|
19
23
|
/** Create a role. */
|
|
20
24
|
createRole(name: string): Promise<RecordId>;
|
|
21
25
|
/** List roles (tenant-scoped by the pipe), with paging/search/sort. */
|
|
@@ -26,7 +30,33 @@ export declare class RoleService {
|
|
|
26
30
|
updateRole(actor: Principal, uuid: string, patch: {
|
|
27
31
|
name?: unknown;
|
|
28
32
|
}): Promise<void>;
|
|
29
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Delete a role — only a role the actor may administer (binding rule) AND whose every holder the
|
|
35
|
+
* actor dominates (Phase 8 #2: the delete cascades the holders' postings, stripping their
|
|
36
|
+
* authority). In ONE transaction: lock the role row (the posting and grant paths lock the same
|
|
37
|
+
* row first), re-check the binding rule and holder dominance against what is committed now, read
|
|
38
|
+
* the holder tenants (before the cascade removes the postings), then delete. After the commit,
|
|
39
|
+
* every holder tenant's permission version is bumped so cached roles drop at the next request.
|
|
40
|
+
* The bump runs after the commit, never before: an earlier bump would let a session reload the
|
|
41
|
+
* pre-delete roles under the new version. A bump failure throws (503) rather than leave holders'
|
|
42
|
+
* cached grants silently stale.
|
|
43
|
+
*
|
|
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
|
+
* `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.
|
|
59
|
+
*/
|
|
30
60
|
deleteRole(actor: Principal, uuid: string): Promise<void>;
|
|
31
61
|
}
|
|
32
62
|
//# sourceMappingURL=role.service.d.ts.map
|
|
@@ -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;
|
|
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"}
|
|
@@ -8,12 +8,18 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
8
8
|
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
9
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
10
|
};
|
|
11
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
11
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
15
|
exports.RoleService = void 0;
|
|
13
16
|
const common_1 = require("@nestjs/common");
|
|
14
17
|
const engine_1 = require("@astram/engine");
|
|
18
|
+
const core_1 = require("@astram/core");
|
|
15
19
|
const list_query_js_1 = require("../shared/list-query.js");
|
|
16
20
|
const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
|
|
21
|
+
const grant_admin_service_js_1 = require("../authz/grant-admin.service.js");
|
|
22
|
+
const posting_version_bump_js_1 = require("../membership/posting-version-bump.js");
|
|
17
23
|
const ROLE_SEARCH_FIELDS = ['name'];
|
|
18
24
|
const ROLE_SORT_FIELDS = ['name', 'createdAt'];
|
|
19
25
|
/**
|
|
@@ -29,9 +35,13 @@ const ROLE_SORT_FIELDS = ['name', 'createdAt'];
|
|
|
29
35
|
let RoleService = class RoleService {
|
|
30
36
|
records;
|
|
31
37
|
ceiling;
|
|
32
|
-
|
|
38
|
+
versionBump;
|
|
39
|
+
grants;
|
|
40
|
+
constructor(records, ceiling, versionBump, grants) {
|
|
33
41
|
this.records = records;
|
|
34
42
|
this.ceiling = ceiling;
|
|
43
|
+
this.versionBump = versionBump;
|
|
44
|
+
this.grants = grants;
|
|
35
45
|
}
|
|
36
46
|
/** Create a role. */
|
|
37
47
|
createRole(name) {
|
|
@@ -53,15 +63,65 @@ let RoleService = class RoleService {
|
|
|
53
63
|
fields.name = patch.name;
|
|
54
64
|
await this.records.update('role', uuid, { fields });
|
|
55
65
|
}
|
|
56
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Delete a role — only a role the actor may administer (binding rule) AND whose every holder the
|
|
68
|
+
* actor dominates (Phase 8 #2: the delete cascades the holders' postings, stripping their
|
|
69
|
+
* authority). In ONE transaction: lock the role row (the posting and grant paths lock the same
|
|
70
|
+
* row first), re-check the binding rule and holder dominance against what is committed now, read
|
|
71
|
+
* the holder tenants (before the cascade removes the postings), then delete. After the commit,
|
|
72
|
+
* every holder tenant's permission version is bumped so cached roles drop at the next request.
|
|
73
|
+
* The bump runs after the commit, never before: an earlier bump would let a session reload the
|
|
74
|
+
* pre-delete roles under the new version. A bump failure throws (503) rather than leave holders'
|
|
75
|
+
* cached grants silently stale.
|
|
76
|
+
*
|
|
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
|
+
* `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.
|
|
92
|
+
*/
|
|
57
93
|
async deleteRole(actor, uuid) {
|
|
58
94
|
await this.ceiling.assertRoleAdministrable(actor, uuid);
|
|
59
|
-
|
|
95
|
+
const grants = this.grants;
|
|
96
|
+
if (!grants)
|
|
97
|
+
throw new core_1.AuthorizationUnavailableError('role grant purge is not configured');
|
|
98
|
+
const tenants = await this.records.transaction(async (tx) => {
|
|
99
|
+
await tx.lockForUpdate('role', uuid);
|
|
100
|
+
await this.ceiling.assertRoleAdministrable(actor, uuid);
|
|
101
|
+
await this.ceiling.assertDominatesRoleHolders(actor, uuid);
|
|
102
|
+
const affected = (await this.versionBump?.tenantsForRole?.(uuid)) ?? [];
|
|
103
|
+
await tx.delete('role', uuid); // the engine's delete gate decides first (Phase 9 #5)
|
|
104
|
+
await grants.purgeRole(uuid, actor);
|
|
105
|
+
return affected;
|
|
106
|
+
});
|
|
107
|
+
if (!this.versionBump)
|
|
108
|
+
return;
|
|
109
|
+
try {
|
|
110
|
+
for (const tenant of tenants)
|
|
111
|
+
await this.versionBump.bumpTenant(tenant);
|
|
112
|
+
}
|
|
113
|
+
catch (cause) {
|
|
114
|
+
throw new core_1.AuthorizationUnavailableError('permission version bump failed', cause);
|
|
115
|
+
}
|
|
60
116
|
}
|
|
61
117
|
};
|
|
62
118
|
exports.RoleService = RoleService;
|
|
63
119
|
exports.RoleService = RoleService = __decorate([
|
|
64
120
|
(0, common_1.Injectable)(),
|
|
121
|
+
__param(2, (0, common_1.Optional)()),
|
|
122
|
+
__param(2, (0, common_1.Inject)(posting_version_bump_js_1.POSTING_VERSION_BUMP)),
|
|
123
|
+
__param(3, (0, common_1.Optional)()),
|
|
124
|
+
__param(3, (0, common_1.Inject)(grant_admin_service_js_1.GrantAdminService)),
|
|
65
125
|
__metadata("design:paramtypes", [engine_1.RecordsService,
|
|
66
|
-
grant_ceiling_js_1.GrantCeiling])
|
|
126
|
+
grant_ceiling_js_1.GrantCeiling, Object, Object])
|
|
67
127
|
], RoleService);
|
|
@@ -88,6 +88,10 @@ export declare class ScopeClosureService {
|
|
|
88
88
|
* subtree, so those admins' cached session subtrees are stale (they'd keep the deleted node in
|
|
89
89
|
* their set) — bump them so they re-derive at the next refresh (same discipline as moveNode's
|
|
90
90
|
* former-parent bump; without it, a parent admin's list/decide would reference a gone node).
|
|
91
|
+
* Also bumps the deleted node's own tenant (Phase 8 #3). `beforeDelete` runs first, on the same
|
|
92
|
+
* transaction, so a caller can lock the node and refuse (rollback) before anything changes; it
|
|
93
|
+
* may also retire rows that depend on the node and return the tenants those changes touched,
|
|
94
|
+
* which are bumped with the rest.
|
|
91
95
|
*
|
|
92
96
|
* v1 SCOPE NOTE: this refuses a node with CHILDREN. Broader resource-orphan cleanup (rows in
|
|
93
97
|
* the many tenant-scoped app tables that reference the deleted node) is out of scope for v1 and
|
|
@@ -95,7 +99,13 @@ export declare class ScopeClosureService {
|
|
|
95
99
|
*/
|
|
96
100
|
deleteNode(nodeUuid: string,
|
|
97
101
|
/** Runs on the delete's transaction after the bump (e.g. the audit row) — a throw rolls back. */
|
|
98
|
-
inTransaction?: (conn: ScopeConn) => Promise<void
|
|
102
|
+
inTransaction?: (conn: ScopeConn) => Promise<void>,
|
|
103
|
+
/**
|
|
104
|
+
* Runs on the delete's transaction BEFORE anything is deleted (Phase 8 #3: lock the node, check
|
|
105
|
+
* authority over its members and agents, retire its agents and cross-tenant grants) — a throw
|
|
106
|
+
* rolls back and nothing is deleted or bumped. Returned tenant ids are bumped too.
|
|
107
|
+
*/
|
|
108
|
+
beforeDelete?: (conn: ScopeConn) => Promise<void | readonly string[]>): Promise<void>;
|
|
99
109
|
/** Read the node forest's parent edges (`c_uuid` → `c_parent_uuid`) as `ScopeEdge[]`. */
|
|
100
110
|
private readEdges;
|
|
101
111
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scope-closure.service.d.ts","sourceRoot":"","sources":["../../../src/features/scope/scope-closure.service.ts"],"names":[],"mappings":"AAqCA,OAAO,EAAqC,KAAK,SAAS,EAAE,MAAM,eAAe,CAAC;AAOlF;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAED,iGAAiG;AACjG,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAClE,aAAa,IAAI,OAAO,CACtB,SAAS,GAAG;QACV,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QAClC,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QACxB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QAC1B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QACzB,2FAA2F;QAC3F,OAAO,CAAC,IAAI,IAAI,CAAC;KAClB,CACF,CAAC;CACH;AAOD,wFAAwF;AACxF,MAAM,WAAW,kBAAkB;IACjC,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE,+FAA+F;IAC/F,gBAAgB,CAAC,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClD,qFAAqF;IACrF,cAAc,CAAC,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAAC;CACxC;AAED,qBAAa,mBAAmB;IAE5B,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,MAAM;gBADN,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,kBAAkB;IAG7C;;;;;;OAMG;IACG,gBAAgB,IAAI,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IAsBvF;;;;;;OAMG;IACG,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAcjD;;;;;;;OAOG;YACW,UAAU;IAwCxB;;;;;;;;;;;;;;;;OAgBG;IACG,QAAQ,CACZ,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,MAAM,GAAG,IAAI;IAC5B,+FAA+F;IAC/F,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC,GACjD,OAAO,CAAC,IAAI,CAAC;IAiFhB
|
|
1
|
+
{"version":3,"file":"scope-closure.service.d.ts","sourceRoot":"","sources":["../../../src/features/scope/scope-closure.service.ts"],"names":[],"mappings":"AAqCA,OAAO,EAAqC,KAAK,SAAS,EAAE,MAAM,eAAe,CAAC;AAOlF;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAED,iGAAiG;AACjG,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAClE,aAAa,IAAI,OAAO,CACtB,SAAS,GAAG;QACV,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QAClC,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QACxB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QAC1B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QACzB,2FAA2F;QAC3F,OAAO,CAAC,IAAI,IAAI,CAAC;KAClB,CACF,CAAC;CACH;AAOD,wFAAwF;AACxF,MAAM,WAAW,kBAAkB;IACjC,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE,+FAA+F;IAC/F,gBAAgB,CAAC,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClD,qFAAqF;IACrF,cAAc,CAAC,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAAC;CACxC;AAED,qBAAa,mBAAmB;IAE5B,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,MAAM;gBADN,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,kBAAkB;IAG7C;;;;;;OAMG;IACG,gBAAgB,IAAI,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IAsBvF;;;;;;OAMG;IACG,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAcjD;;;;;;;OAOG;YACW,UAAU;IAwCxB;;;;;;;;;;;;;;;;OAgBG;IACG,QAAQ,CACZ,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,MAAM,GAAG,IAAI;IAC5B,+FAA+F;IAC/F,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC,GACjD,OAAO,CAAC,IAAI,CAAC;IAiFhB;;;;;;;;;;;;;;;;OAgBG;IACG,UAAU,CACd,QAAQ,EAAE,MAAM;IAChB,iGAAiG;IACjG,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC;IAClD;;;;OAIG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,GAAG,SAAS,MAAM,EAAE,CAAC,GACpE,OAAO,CAAC,IAAI,CAAC;IA6ChB,yFAAyF;YAC3E,SAAS;IAOvB;;;;OAIG;YACW,cAAc;CAiB7B;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,SAAS,EAAE,GAAG;IAC7D,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB,CA8BA;AAED,kGAAkG;AAClG,qBAAa,oBAAqB,SAAQ,KAAK;gBACjC,QAAQ,EAAE,MAAM;CAQ7B"}
|
|
@@ -246,6 +246,10 @@ class ScopeClosureService {
|
|
|
246
246
|
* subtree, so those admins' cached session subtrees are stale (they'd keep the deleted node in
|
|
247
247
|
* their set) — bump them so they re-derive at the next refresh (same discipline as moveNode's
|
|
248
248
|
* former-parent bump; without it, a parent admin's list/decide would reference a gone node).
|
|
249
|
+
* Also bumps the deleted node's own tenant (Phase 8 #3). `beforeDelete` runs first, on the same
|
|
250
|
+
* transaction, so a caller can lock the node and refuse (rollback) before anything changes; it
|
|
251
|
+
* may also retire rows that depend on the node and return the tenants those changes touched,
|
|
252
|
+
* which are bumped with the rest.
|
|
249
253
|
*
|
|
250
254
|
* v1 SCOPE NOTE: this refuses a node with CHILDREN. Broader resource-orphan cleanup (rows in
|
|
251
255
|
* the many tenant-scoped app tables that reference the deleted node) is out of scope for v1 and
|
|
@@ -253,7 +257,13 @@ class ScopeClosureService {
|
|
|
253
257
|
*/
|
|
254
258
|
async deleteNode(nodeUuid,
|
|
255
259
|
/** Runs on the delete's transaction after the bump (e.g. the audit row) — a throw rolls back. */
|
|
256
|
-
inTransaction
|
|
260
|
+
inTransaction,
|
|
261
|
+
/**
|
|
262
|
+
* Runs on the delete's transaction BEFORE anything is deleted (Phase 8 #3: lock the node, check
|
|
263
|
+
* authority over its members and agents, retire its agents and cross-tenant grants) — a throw
|
|
264
|
+
* rolls back and nothing is deleted or bumped. Returned tenant ids are bumped too.
|
|
265
|
+
*/
|
|
266
|
+
beforeDelete) {
|
|
257
267
|
// Under the tree lock (review F3): a concurrent move cannot hang a child under this node
|
|
258
268
|
// between the children check and the delete.
|
|
259
269
|
await this.inTreeLock(async (conn) => {
|
|
@@ -261,6 +271,7 @@ class ScopeClosureService {
|
|
|
261
271
|
if (children.length > 0) {
|
|
262
272
|
throw new NodeHasChildrenError(nodeUuid);
|
|
263
273
|
}
|
|
274
|
+
const touched = (beforeDelete ? await beforeDelete(conn) : undefined) ?? [];
|
|
264
275
|
// The former-parent ancestry — the deleted node's ancestors (excluding self). They lose the
|
|
265
276
|
// node from their reach → bump so their sessions re-derive. Read BEFORE the delete.
|
|
266
277
|
const formerAncestryRows = (await conn.query(`SELECT ancestor_uuid FROM \`${AUTHZ}\`.\`scope_closure\`
|
|
@@ -270,9 +281,17 @@ class ScopeClosureService {
|
|
|
270
281
|
WHERE ancestor_uuid = ? OR descendant_uuid = ?`, [nodeUuid, nodeUuid]);
|
|
271
282
|
// Remove the company row itself.
|
|
272
283
|
await conn.query(`DELETE FROM \`${APP}\`.\`companies\` WHERE c_uuid = ?`, [nodeUuid]);
|
|
273
|
-
// Bump the former-parent ancestry so their cached subtrees re-derive at next refresh
|
|
274
|
-
|
|
275
|
-
|
|
284
|
+
// Bump the former-parent ancestry so their cached subtrees re-derive at next refresh, and the
|
|
285
|
+
// deleted node's OWN tenant (Phase 8 #3): its memberships and postings cascade away, so
|
|
286
|
+
// sessions cached in it must reload (and find nothing). A leaf has no descendants to bump.
|
|
287
|
+
// Plus the tenants `beforeDelete` touched (e.g. other tenants' agents that lost the node).
|
|
288
|
+
const bumpSet = new Set([
|
|
289
|
+
...formerAncestryRows.map((r) => r.ancestor_uuid),
|
|
290
|
+
nodeUuid,
|
|
291
|
+
...touched,
|
|
292
|
+
]);
|
|
293
|
+
for (const tenant of bumpSet) {
|
|
294
|
+
await this.bumper.bumpForTenant(tenant, conn);
|
|
276
295
|
}
|
|
277
296
|
if (inTransaction)
|
|
278
297
|
await inTransaction(conn);
|
|
@@ -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"}
|