@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,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* The record-tier authorization inputs for `@astram/identity`: the lean grant-read pool factory and
|
|
3
|
+
* the `RecordAuthorizationInputs` builder. `IdentityFeatureModule.forRoot({ extraObjects })` builds
|
|
4
|
+
* the inputs ONCE and uses them everywhere (security hardening Phase 9 #7):
|
|
5
|
+
* - provided app-wide under guard's `SHIELD_RECORD_AUTHORIZATION`, so `ShieldModule` builds the
|
|
6
|
+
* request path's decision authority from them (the framework's four record-tier adapters are
|
|
7
|
+
* built inside guard);
|
|
8
|
+
* - the module's own cross-tenant / policy authority, list scope and grant ceiling.
|
|
9
|
+
* One registry, one source of `extraObjects`: nothing can disagree.
|
|
7
10
|
*
|
|
8
11
|
* Modeled on astram3's `buildAuthorizationInputs` (`server/src/features/authz/authz.wiring.ts`),
|
|
9
|
-
* adapted to `@astram/identity`'s own registrations (`identityObjects`) and env prefix
|
|
10
|
-
* (`APP_DB_*`, per the Task-7 rename).
|
|
12
|
+
* adapted to `@astram/identity`'s own registrations (`identityObjects`) and env prefix (`APP_DB_*`).
|
|
11
13
|
*/
|
|
12
14
|
import { type Pool } from 'mariadb';
|
|
13
15
|
import { type RecordAuthorizationInputs } from '@astram/guard';
|
|
@@ -15,26 +17,19 @@ import type { ObjectTypeInput } from '@astram/engine';
|
|
|
15
17
|
/**
|
|
16
18
|
* A lean `mariadb` pool for the authz grant-reads (built from `APP_DB_*` env via guard's
|
|
17
19
|
* `loadDbConfig`). Kept small — these are pre-gate coarse-grant reads, not the main CRUD pool.
|
|
18
|
-
* Opens the pool at CALL time (never at import) — the
|
|
20
|
+
* Opens the pool at CALL time (never at import) — the caller owns closing it.
|
|
19
21
|
*/
|
|
20
22
|
export declare function createIdentityAuthzPool(): Pool;
|
|
21
23
|
/**
|
|
22
|
-
* Build the `RecordAuthorizationInputs`
|
|
23
|
-
*
|
|
24
|
-
* adapters internally; `@astram/identity` supplies only the app-schema ports:
|
|
24
|
+
* Build the `RecordAuthorizationInputs` over `pool`. `@astram/identity` supplies only the
|
|
25
|
+
* app-schema ports:
|
|
25
26
|
* - `permissionFacts` — coarse grants over `shield_authz.role_permission` +
|
|
26
27
|
* `company_membership`/`membership_posting`,
|
|
27
28
|
* - `sessionFacts` — the ADR-0011 ALS-backed per-request reader,
|
|
28
|
-
* - `registry` — bridged from `identityObjects` PLUS
|
|
29
|
+
* - `registry` — bridged from `identityObjects` PLUS the consumer's `extraObjects` (their
|
|
30
|
+
* `<module>:<type>:<action>` keys project into a principal's grants at gate 5; without them a
|
|
31
|
+
* consumer type is `resource_not_registered`),
|
|
29
32
|
* - `operationManifest` — empty (identity declares no trusted delegated operations).
|
|
30
|
-
*
|
|
31
|
-
* `extraObjects` (optional): a CONSUMER app that registers its OWN gated object types via
|
|
32
|
-
* `EngineModule.forFeature([...])` must ALSO pass those types here, so their actions enter the
|
|
33
|
-
* coarse-grant permission-facts registry (gate 5). Without this, a custom type's routes boot
|
|
34
|
-
* and list-scope correctly (gate 6) but their `<module>:<type>:<action>` permission keys are
|
|
35
|
-
* never projected into a principal's grants — so gate 5 denies them (deny-by-default). Passing
|
|
36
|
-
* the same object types the app registered with the engine closes that gap: the app's grants on
|
|
37
|
-
* those keys now project, and the framework still owns the four record-tier adapters + scope fn.
|
|
38
33
|
*/
|
|
39
|
-
export declare function
|
|
34
|
+
export declare function buildRecordAuthorizationInputs(pool: Pool, extraObjects?: readonly ObjectTypeInput[]): RecordAuthorizationInputs;
|
|
40
35
|
//# sourceMappingURL=record-authorization.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"record-authorization.d.ts","sourceRoot":"","sources":["../../../src/features/authz/record-authorization.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"record-authorization.d.ts","sourceRoot":"","sources":["../../../src/features/authz/record-authorization.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAgB,EAAE,KAAK,IAAI,EAAE,MAAM,SAAS,CAAC;AAC7C,OAAO,EAGL,KAAK,yBAAyB,EAC/B,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAKtD;;;;GAIG;AACH,wBAAgB,uBAAuB,IAAI,IAAI,CAU9C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,8BAA8B,CAC5C,IAAI,EAAE,IAAI,EACV,YAAY,GAAE,SAAS,eAAe,EAAO,GAC5C,yBAAyB,CAS3B"}
|
|
@@ -4,17 +4,19 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.createIdentityAuthzPool = createIdentityAuthzPool;
|
|
7
|
-
exports.
|
|
7
|
+
exports.buildRecordAuthorizationInputs = buildRecordAuthorizationInputs;
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* The record-tier authorization inputs for `@astram/identity`: the lean grant-read pool factory and
|
|
10
|
+
* the `RecordAuthorizationInputs` builder. `IdentityFeatureModule.forRoot({ extraObjects })` builds
|
|
11
|
+
* the inputs ONCE and uses them everywhere (security hardening Phase 9 #7):
|
|
12
|
+
* - provided app-wide under guard's `SHIELD_RECORD_AUTHORIZATION`, so `ShieldModule` builds the
|
|
13
|
+
* request path's decision authority from them (the framework's four record-tier adapters are
|
|
14
|
+
* built inside guard);
|
|
15
|
+
* - the module's own cross-tenant / policy authority, list scope and grant ceiling.
|
|
16
|
+
* One registry, one source of `extraObjects`: nothing can disagree.
|
|
14
17
|
*
|
|
15
18
|
* Modeled on astram3's `buildAuthorizationInputs` (`server/src/features/authz/authz.wiring.ts`),
|
|
16
|
-
* adapted to `@astram/identity`'s own registrations (`identityObjects`) and env prefix
|
|
17
|
-
* (`APP_DB_*`, per the Task-7 rename).
|
|
19
|
+
* adapted to `@astram/identity`'s own registrations (`identityObjects`) and env prefix (`APP_DB_*`).
|
|
18
20
|
*/
|
|
19
21
|
const mariadb_1 = __importDefault(require("mariadb"));
|
|
20
22
|
const guard_1 = require("@astram/guard");
|
|
@@ -24,7 +26,7 @@ const session_facts_reader_js_1 = require("./session-facts.reader.js");
|
|
|
24
26
|
/**
|
|
25
27
|
* A lean `mariadb` pool for the authz grant-reads (built from `APP_DB_*` env via guard's
|
|
26
28
|
* `loadDbConfig`). Kept small — these are pre-gate coarse-grant reads, not the main CRUD pool.
|
|
27
|
-
* Opens the pool at CALL time (never at import) — the
|
|
29
|
+
* Opens the pool at CALL time (never at import) — the caller owns closing it.
|
|
28
30
|
*/
|
|
29
31
|
function createIdentityAuthzPool() {
|
|
30
32
|
const db = (0, guard_1.loadDbConfig)();
|
|
@@ -38,24 +40,17 @@ function createIdentityAuthzPool() {
|
|
|
38
40
|
});
|
|
39
41
|
}
|
|
40
42
|
/**
|
|
41
|
-
* Build the `RecordAuthorizationInputs`
|
|
42
|
-
*
|
|
43
|
-
* adapters internally; `@astram/identity` supplies only the app-schema ports:
|
|
43
|
+
* Build the `RecordAuthorizationInputs` over `pool`. `@astram/identity` supplies only the
|
|
44
|
+
* app-schema ports:
|
|
44
45
|
* - `permissionFacts` — coarse grants over `shield_authz.role_permission` +
|
|
45
46
|
* `company_membership`/`membership_posting`,
|
|
46
47
|
* - `sessionFacts` — the ADR-0011 ALS-backed per-request reader,
|
|
47
|
-
* - `registry` — bridged from `identityObjects` PLUS
|
|
48
|
+
* - `registry` — bridged from `identityObjects` PLUS the consumer's `extraObjects` (their
|
|
49
|
+
* `<module>:<type>:<action>` keys project into a principal's grants at gate 5; without them a
|
|
50
|
+
* consumer type is `resource_not_registered`),
|
|
48
51
|
* - `operationManifest` — empty (identity declares no trusted delegated operations).
|
|
49
|
-
*
|
|
50
|
-
* `extraObjects` (optional): a CONSUMER app that registers its OWN gated object types via
|
|
51
|
-
* `EngineModule.forFeature([...])` must ALSO pass those types here, so their actions enter the
|
|
52
|
-
* coarse-grant permission-facts registry (gate 5). Without this, a custom type's routes boot
|
|
53
|
-
* and list-scope correctly (gate 6) but their `<module>:<type>:<action>` permission keys are
|
|
54
|
-
* never projected into a principal's grants — so gate 5 denies them (deny-by-default). Passing
|
|
55
|
-
* the same object types the app registered with the engine closes that gap: the app's grants on
|
|
56
|
-
* those keys now project, and the framework still owns the four record-tier adapters + scope fn.
|
|
57
52
|
*/
|
|
58
|
-
function
|
|
53
|
+
function buildRecordAuthorizationInputs(pool, extraObjects = []) {
|
|
59
54
|
const registry = (0, guard_1.buildIdentityModuleRegistry)([...objects_js_1.identityObjects, ...extraObjects]);
|
|
60
55
|
return {
|
|
61
56
|
pool,
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
* - move: the node is in the actor's subtree, is NOT the actor's own root node (a sub-admin can
|
|
11
11
|
* never re-home its whole tenant), and the new parent is in the actor's subtree and non-null
|
|
12
12
|
* (only root creates or promotes top-level tenants). A cycle → ScopeCycleError (409).
|
|
13
|
-
* - delete: the caller (CompanyService) has already read the node through the subtree wall
|
|
13
|
+
* - delete: the caller (CompanyService) has already read the node through the subtree wall; the
|
|
14
|
+
* node is not the actor's own root; the actor dominates every member and may administer every
|
|
15
|
+
* service account of the company (Phase 8 #3). The delete disables those agents, revokes their
|
|
16
|
+
* keys, drops the node from other agents' scopes and revokes cross-tenant grants naming it.
|
|
14
17
|
*
|
|
15
18
|
* Move and delete are audited ON their own transaction (a failed audit rolls them back). Names,
|
|
16
19
|
* never ids, in the audit detail (ADR-0012).
|
|
@@ -22,13 +25,34 @@ import { ScopeClosureService } from '../scope/scope-closure.service.js';
|
|
|
22
25
|
export interface HierarchyQueryable {
|
|
23
26
|
query(sql: string, params?: readonly unknown[]): Promise<unknown>;
|
|
24
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* The authority checks a company delete needs — over each member and each of the company's own
|
|
30
|
+
* service accounts. `GrantCeiling` satisfies it.
|
|
31
|
+
*/
|
|
32
|
+
export interface MemberDominance {
|
|
33
|
+
assertDominates(actor: Principal, targetUserId: string): Promise<void>;
|
|
34
|
+
assertMayRetireServiceAccounts(actor: Principal, accounts: ReadonlyArray<{
|
|
35
|
+
readonly tenantId: string;
|
|
36
|
+
readonly scopeSubtreeNodeIds: readonly string[];
|
|
37
|
+
readonly roleIds: readonly string[];
|
|
38
|
+
}>): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The actor's own root node (the active node its subtree hangs from). A non-root actor may not
|
|
42
|
+
* move, delete (F6) or change the status of (Phase 9 #13c) this node.
|
|
43
|
+
*/
|
|
44
|
+
export declare function ownRootOf(actor: Principal): string;
|
|
25
45
|
export declare class CompanyHierarchyService {
|
|
26
46
|
private readonly pool;
|
|
27
47
|
private readonly closure;
|
|
48
|
+
/** Phase 8 #3: required — a company delete must dominate every member (no silent skip). */
|
|
49
|
+
private readonly ceiling;
|
|
28
50
|
private readonly audit?;
|
|
29
51
|
private readonly now;
|
|
30
|
-
constructor(pool: HierarchyQueryable, closure: ScopeClosureService,
|
|
31
|
-
/**
|
|
52
|
+
constructor(pool: HierarchyQueryable, closure: ScopeClosureService,
|
|
53
|
+
/** Phase 8 #3: required — a company delete must dominate every member (no silent skip). */
|
|
54
|
+
ceiling: MemberDominance, audit?: AuditPort | undefined, now?: () => number);
|
|
55
|
+
/** The actor's own root node (see {@link ownRootOf}). */
|
|
32
56
|
private ownRoot;
|
|
33
57
|
/** Names of the given companies (unknown → absent). */
|
|
34
58
|
private names;
|
|
@@ -37,9 +61,52 @@ export declare class CompanyHierarchyService {
|
|
|
37
61
|
move(actor: Principal, nodeUuid: string, newParentUuid: string | null): Promise<void>;
|
|
38
62
|
/**
|
|
39
63
|
* Delete a company through `deleteNode` (refuses a node with children; closure rows + ancestor
|
|
40
|
-
* bump in one transaction). The caller has already checked reach (walled read).
|
|
64
|
+
* and own-node bump in one transaction). The caller has already checked reach (walled read).
|
|
65
|
+
*
|
|
66
|
+
* Phase 8 #3: the delete cascades every membership and posting of the company, stripping its
|
|
67
|
+
* members' authority there, so a non-root actor must DOMINATE every member (the actor itself is
|
|
68
|
+
* skipped). Inside the delete's transaction and tree lock, before anything is deleted: lock the
|
|
69
|
+
* company row (a new membership's FK check waits on it), read the members with a locking read
|
|
70
|
+
* (latest committed rows, no insert into the range until commit), then `assertDominates` each.
|
|
71
|
+
* Any refusal rolls the whole delete back (403). Root bypasses the dominance check.
|
|
72
|
+
*
|
|
73
|
+
* Phase 8 #3 (continued): nothing in the database ties a service account or a cross-tenant grant
|
|
74
|
+
* to its company (no FK), so, still before the company row goes and on the same transaction,
|
|
75
|
+
* {@link retireDependents} disables the company's agents and revokes their keys, removes the
|
|
76
|
+
* company from other agents' scopes, and revokes every cross-tenant grant naming it — audited on
|
|
77
|
+
* that transaction. One transaction: a failure anywhere leaves the company and everything
|
|
78
|
+
* depending on it as they were; a commit leaves a deleted company with no live agent or grant.
|
|
41
79
|
*/
|
|
42
80
|
remove(actor: Principal, nodeUuid: string): Promise<void>;
|
|
81
|
+
/**
|
|
82
|
+
* Retire what depends on a company being deleted (Phase 8 #3), on the delete's transaction after
|
|
83
|
+
* the company row lock and the member check, BEFORE the company row is deleted. Returns the
|
|
84
|
+
* tenants whose agents changed (the delete bumps them with the node and its ancestors).
|
|
85
|
+
*
|
|
86
|
+
* 1. Agents (`shield_app.service_account`) bound to the company, or whose scope names it, are
|
|
87
|
+
* read with a locking read.
|
|
88
|
+
* 2. The company's OWN agents: a non-root actor must be able to administer every one of them
|
|
89
|
+
* (`assertMayRetireServiceAccounts`, the ADR-0029 ceiling: scope and roles within its own) or
|
|
90
|
+
* the whole delete is refused (403). Then each is disabled and its keys revoked (the same
|
|
91
|
+
* `sak_revoked_at_ms` stamp `MariaDbApiKeyStore.revoke` writes, here on this transaction).
|
|
92
|
+
* 3. Other tenants' agents lose the company from their scope (reach over a deleted node only;
|
|
93
|
+
* the actor dominates that node). An agent whose ENTIRE scope was the company is disabled and
|
|
94
|
+
* its keys revoked instead — an empty scope would make the API-key path fall back to the
|
|
95
|
+
* agent's own tenant, a widening — and its scope is left as it was (a dead reference, never
|
|
96
|
+
* wider). These need no further authority check: they lose only reach over the deleted node.
|
|
97
|
+
* 4. Cross-tenant grants (`shield_authz.cross_tenant_grant`) where the company is the owning or
|
|
98
|
+
* the grantee tenant and that are not yet revoked are revoked exactly as
|
|
99
|
+
* `CrossTenantGrantWriter.revoke` does (status + `ctg_revoked_at`), and audited under both
|
|
100
|
+
* tenants. The writer itself cannot join this transaction (it writes on its own pool, and
|
|
101
|
+
* audits best-effort after the write); either side may revoke (ADR-0009), and the actor's
|
|
102
|
+
* authority over the deleted side is the delete itself.
|
|
103
|
+
* Every change is audited on this transaction (names; ids only in structured attributes).
|
|
104
|
+
*/
|
|
105
|
+
private retireDependents;
|
|
106
|
+
/** Step 4 of {@link retireDependents}: revoke and audit the grants naming the company. */
|
|
107
|
+
private revokeGrantsNaming;
|
|
108
|
+
/** Lock the company, then require dominance over each of its members (see `remove`). */
|
|
109
|
+
private assertMayDeleteMembers;
|
|
43
110
|
/** A company was just created: give it closure rows now and bump its ancestors (D5). */
|
|
44
111
|
attached(nodeUuid: string): Promise<void>;
|
|
45
112
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"company-hierarchy.service.d.ts","sourceRoot":"","sources":["../../../src/features/company/company-hierarchy.service.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"company-hierarchy.service.d.ts","sourceRoot":"","sources":["../../../src/features/company/company-hierarchy.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,mBAAmB,EAAkB,MAAM,mCAAmC,CAAC;AAWxF,6FAA6F;AAC7F,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAED;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,eAAe,CAAC,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvE,8BAA8B,CAC5B,KAAK,EAAE,SAAS,EAChB,QAAQ,EAAE,aAAa,CAAC;QACtB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;QAC1B,QAAQ,CAAC,mBAAmB,EAAE,SAAS,MAAM,EAAE,CAAC;QAChD,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;KACrC,CAAC,GACD,OAAO,CAAC,IAAI,CAAC,CAAC;CAClB;AAuCD;;;GAGG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAElD;AAED,qBAAa,uBAAuB;IAEhC,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,2FAA2F;IAC3F,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC;IACvB,OAAO,CAAC,QAAQ,CAAC,GAAG;gBALH,IAAI,EAAE,kBAAkB,EACxB,OAAO,EAAE,mBAAmB;IAC7C,2FAA2F;IAC1E,OAAO,EAAE,eAAe,EACxB,KAAK,CAAC,EAAE,SAAS,YAAA,EACjB,GAAG,GAAE,MAAM,MAAiB;IAG/C,yDAAyD;IACzD,OAAO,CAAC,OAAO;IAIf,uDAAuD;YACzC,KAAK;YAUL,WAAW;IAqBzB,iGAAiG;IAC3F,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAkD3F;;;;;;;;;;;;;;;;;OAiBG;IACG,MAAM,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAmB/D;;;;;;;;;;;;;;;;;;;;;;;OAuBG;YACW,gBAAgB;IAiF9B,0FAA0F;YAC5E,kBAAkB;IAmDhC,wFAAwF;YAC1E,sBAAsB;IA+BpC,wFAAwF;IAClF,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAGhD"}
|
|
@@ -1,23 +1,50 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.CompanyHierarchyService = void 0;
|
|
4
|
+
exports.ownRootOf = ownRootOf;
|
|
4
5
|
const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
|
|
6
|
+
const lock_service_accounts_js_1 = require("../service-account/lock-service-accounts.js");
|
|
5
7
|
const identity_errors_js_1 = require("../shared/identity-errors.js");
|
|
6
8
|
const APP = 'shield_app';
|
|
9
|
+
const AUTHZ = 'shield_authz';
|
|
10
|
+
/** A JSON-array column (the driver returns an array OR a string) → its string items; else []. */
|
|
11
|
+
function jsonIdArray(v) {
|
|
12
|
+
let arr = v;
|
|
13
|
+
if (typeof v === 'string') {
|
|
14
|
+
try {
|
|
15
|
+
arr = JSON.parse(v);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
arr = [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return Array.isArray(arr) ? arr.filter((x) => typeof x === 'string') : [];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The actor's own root node (the active node its subtree hangs from). A non-root actor may not
|
|
25
|
+
* move, delete (F6) or change the status of (Phase 9 #13c) this node.
|
|
26
|
+
*/
|
|
27
|
+
function ownRootOf(actor) {
|
|
28
|
+
return actor.activeScopeNodeId ?? actor.tenantId;
|
|
29
|
+
}
|
|
7
30
|
class CompanyHierarchyService {
|
|
8
31
|
pool;
|
|
9
32
|
closure;
|
|
33
|
+
ceiling;
|
|
10
34
|
audit;
|
|
11
35
|
now;
|
|
12
|
-
constructor(pool, closure,
|
|
36
|
+
constructor(pool, closure,
|
|
37
|
+
/** Phase 8 #3: required — a company delete must dominate every member (no silent skip). */
|
|
38
|
+
ceiling, audit, now = Date.now) {
|
|
13
39
|
this.pool = pool;
|
|
14
40
|
this.closure = closure;
|
|
41
|
+
this.ceiling = ceiling;
|
|
15
42
|
this.audit = audit;
|
|
16
43
|
this.now = now;
|
|
17
44
|
}
|
|
18
|
-
/** The actor's own root node (
|
|
45
|
+
/** The actor's own root node (see {@link ownRootOf}). */
|
|
19
46
|
ownRoot(actor) {
|
|
20
|
-
return actor
|
|
47
|
+
return ownRootOf(actor);
|
|
21
48
|
}
|
|
22
49
|
/** Names of the given companies (unknown → absent). */
|
|
23
50
|
async names(q, ids) {
|
|
@@ -27,14 +54,14 @@ class CompanyHierarchyService {
|
|
|
27
54
|
const rows = (await q.query(`SELECT c_uuid, c_name FROM \`${APP}\`.\`companies\` WHERE c_uuid IN (${unique.map(() => '?').join(', ')})`, unique));
|
|
28
55
|
return new Map(rows.map((r) => [r.c_uuid, r.c_name]));
|
|
29
56
|
}
|
|
30
|
-
async recordAudit(conn, actor, action, attributes) {
|
|
57
|
+
async recordAudit(conn, actor, action, attributes, tenantId = actor.tenantId) {
|
|
31
58
|
if (!this.audit)
|
|
32
59
|
return;
|
|
33
60
|
await this.audit.record({
|
|
34
61
|
type: 'admin_action',
|
|
35
62
|
occurredAtMs: this.now(),
|
|
36
63
|
actorId: actor.id,
|
|
37
|
-
tenantId
|
|
64
|
+
tenantId,
|
|
38
65
|
action: { key: action },
|
|
39
66
|
attributes: { eventType: 'company_hierarchy', ...attributes },
|
|
40
67
|
}, (sql, params) => conn.query(sql, params));
|
|
@@ -81,7 +108,21 @@ class CompanyHierarchyService {
|
|
|
81
108
|
}
|
|
82
109
|
/**
|
|
83
110
|
* Delete a company through `deleteNode` (refuses a node with children; closure rows + ancestor
|
|
84
|
-
* bump in one transaction). The caller has already checked reach (walled read).
|
|
111
|
+
* and own-node bump in one transaction). The caller has already checked reach (walled read).
|
|
112
|
+
*
|
|
113
|
+
* Phase 8 #3: the delete cascades every membership and posting of the company, stripping its
|
|
114
|
+
* members' authority there, so a non-root actor must DOMINATE every member (the actor itself is
|
|
115
|
+
* skipped). Inside the delete's transaction and tree lock, before anything is deleted: lock the
|
|
116
|
+
* company row (a new membership's FK check waits on it), read the members with a locking read
|
|
117
|
+
* (latest committed rows, no insert into the range until commit), then `assertDominates` each.
|
|
118
|
+
* Any refusal rolls the whole delete back (403). Root bypasses the dominance check.
|
|
119
|
+
*
|
|
120
|
+
* Phase 8 #3 (continued): nothing in the database ties a service account or a cross-tenant grant
|
|
121
|
+
* to its company (no FK), so, still before the company row goes and on the same transaction,
|
|
122
|
+
* {@link retireDependents} disables the company's agents and revokes their keys, removes the
|
|
123
|
+
* company from other agents' scopes, and revokes every cross-tenant grant naming it — audited on
|
|
124
|
+
* that transaction. One transaction: a failure anywhere leaves the company and everything
|
|
125
|
+
* depending on it as they were; a commit leaves a deleted company with no live agent or grant.
|
|
85
126
|
*/
|
|
86
127
|
async remove(actor, nodeUuid) {
|
|
87
128
|
// Security review F6: the same own-root guard as `move` — a sub-admin never deletes the
|
|
@@ -89,8 +130,156 @@ class CompanyHierarchyService {
|
|
|
89
130
|
if (actor.isRoot !== true && nodeUuid === this.ownRoot(actor)) {
|
|
90
131
|
throw new identity_errors_js_1.IdentityForbiddenError('You cannot delete the organisation your access is rooted at.');
|
|
91
132
|
}
|
|
92
|
-
const name = (await this.names(this.pool, [nodeUuid])).get(nodeUuid);
|
|
93
|
-
await this.closure.deleteNode(nodeUuid, (conn) => this.recordAudit(conn, actor, 'company.deleted', { company: name
|
|
133
|
+
const name = (await this.names(this.pool, [nodeUuid])).get(nodeUuid) ?? '(unknown)';
|
|
134
|
+
await this.closure.deleteNode(nodeUuid, (conn) => this.recordAudit(conn, actor, 'company.deleted', { company: name }), async (conn) => {
|
|
135
|
+
await this.assertMayDeleteMembers(conn, actor, nodeUuid);
|
|
136
|
+
return this.retireDependents(conn, actor, nodeUuid, name);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Retire what depends on a company being deleted (Phase 8 #3), on the delete's transaction after
|
|
141
|
+
* the company row lock and the member check, BEFORE the company row is deleted. Returns the
|
|
142
|
+
* tenants whose agents changed (the delete bumps them with the node and its ancestors).
|
|
143
|
+
*
|
|
144
|
+
* 1. Agents (`shield_app.service_account`) bound to the company, or whose scope names it, are
|
|
145
|
+
* read with a locking read.
|
|
146
|
+
* 2. The company's OWN agents: a non-root actor must be able to administer every one of them
|
|
147
|
+
* (`assertMayRetireServiceAccounts`, the ADR-0029 ceiling: scope and roles within its own) or
|
|
148
|
+
* the whole delete is refused (403). Then each is disabled and its keys revoked (the same
|
|
149
|
+
* `sak_revoked_at_ms` stamp `MariaDbApiKeyStore.revoke` writes, here on this transaction).
|
|
150
|
+
* 3. Other tenants' agents lose the company from their scope (reach over a deleted node only;
|
|
151
|
+
* the actor dominates that node). An agent whose ENTIRE scope was the company is disabled and
|
|
152
|
+
* its keys revoked instead — an empty scope would make the API-key path fall back to the
|
|
153
|
+
* agent's own tenant, a widening — and its scope is left as it was (a dead reference, never
|
|
154
|
+
* wider). These need no further authority check: they lose only reach over the deleted node.
|
|
155
|
+
* 4. Cross-tenant grants (`shield_authz.cross_tenant_grant`) where the company is the owning or
|
|
156
|
+
* the grantee tenant and that are not yet revoked are revoked exactly as
|
|
157
|
+
* `CrossTenantGrantWriter.revoke` does (status + `ctg_revoked_at`), and audited under both
|
|
158
|
+
* tenants. The writer itself cannot join this transaction (it writes on its own pool, and
|
|
159
|
+
* audits best-effort after the write); either side may revoke (ADR-0009), and the actor's
|
|
160
|
+
* authority over the deleted side is the delete itself.
|
|
161
|
+
* Every change is audited on this transaction (names; ids only in structured attributes).
|
|
162
|
+
*/
|
|
163
|
+
async retireDependents(conn, actor, nodeUuid, companyName) {
|
|
164
|
+
// Phase 9 #11: lock only the matching agents, never the whole table. An agent created in the
|
|
165
|
+
// company, or given it in scope, after the candidate read is not seen: the outcome equals
|
|
166
|
+
// "delete, then that edit" (the API-key path joins `companies`, so an agent of a deleted
|
|
167
|
+
// tenant cannot authenticate; a deleted node in a scope reaches nothing).
|
|
168
|
+
const agents = await (0, lock_service_accounts_js_1.lockServiceAccountsWhere)(conn, 'sa_id, sa_tenant_id, sa_label, sa_scope_subtree, sa_role_ids, sa_updated_at_ms', {
|
|
169
|
+
sql: 'sa_tenant_id = ? OR JSON_CONTAINS(sa_scope_subtree, JSON_QUOTE(?))',
|
|
170
|
+
params: [nodeUuid, nodeUuid],
|
|
171
|
+
});
|
|
172
|
+
const own = agents.filter((a) => a.sa_tenant_id === nodeUuid);
|
|
173
|
+
const others = agents.filter((a) => a.sa_tenant_id !== nodeUuid);
|
|
174
|
+
// Root bypasses inside; a refusal (403, names no agent) rolls the whole delete back.
|
|
175
|
+
await this.ceiling.assertMayRetireServiceAccounts(actor, own.map((a) => ({
|
|
176
|
+
tenantId: a.sa_tenant_id,
|
|
177
|
+
scopeSubtreeNodeIds: jsonIdArray(a.sa_scope_subtree),
|
|
178
|
+
roleIds: jsonIdArray(a.sa_role_ids),
|
|
179
|
+
})));
|
|
180
|
+
const nowMs = this.now();
|
|
181
|
+
const touched = new Set();
|
|
182
|
+
const editStamp = (a) => Math.max(nowMs, (a.sa_updated_at_ms == null ? 0 : Number(a.sa_updated_at_ms)) + 1);
|
|
183
|
+
const retire = async (a, reason) => {
|
|
184
|
+
await conn.query(`UPDATE \`${APP}\`.\`service_account\` SET sa_status = 'disabled', sa_updated_at_ms = ? WHERE sa_id = ?`, [editStamp(a), a.sa_id]);
|
|
185
|
+
const res = (await conn.query(`UPDATE \`${AUTHZ}\`.\`service_account_key\`
|
|
186
|
+
SET sak_revoked_at_ms = ?
|
|
187
|
+
WHERE sak_account_id = ? AND sak_revoked_at_ms IS NULL`, [nowMs, a.sa_id]));
|
|
188
|
+
touched.add(a.sa_tenant_id);
|
|
189
|
+
await this.recordAudit(conn, actor, 'service_account.disabled', {
|
|
190
|
+
eventType: 'service_account',
|
|
191
|
+
accountId: a.sa_id,
|
|
192
|
+
account: a.sa_label,
|
|
193
|
+
company: companyName,
|
|
194
|
+
reason,
|
|
195
|
+
keysRevoked: Number(res?.affectedRows ?? 0),
|
|
196
|
+
});
|
|
197
|
+
};
|
|
198
|
+
for (const a of own)
|
|
199
|
+
await retire(a, 'company_deleted');
|
|
200
|
+
for (const a of others) {
|
|
201
|
+
const remaining = jsonIdArray(a.sa_scope_subtree).filter((n) => n !== nodeUuid);
|
|
202
|
+
if (remaining.length === 0) {
|
|
203
|
+
await retire(a, 'scope_company_deleted');
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
await conn.query(`UPDATE \`${APP}\`.\`service_account\` SET sa_scope_subtree = ?, sa_updated_at_ms = ? WHERE sa_id = ?`, [JSON.stringify(remaining), editStamp(a), a.sa_id]);
|
|
207
|
+
touched.add(a.sa_tenant_id);
|
|
208
|
+
await this.recordAudit(conn, actor, 'service_account.updated', {
|
|
209
|
+
eventType: 'service_account',
|
|
210
|
+
accountId: a.sa_id,
|
|
211
|
+
account: a.sa_label,
|
|
212
|
+
fields: ['scopeSubtreeNodeIds'],
|
|
213
|
+
scopeRemoved: [companyName],
|
|
214
|
+
reason: 'company_deleted',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
await this.revokeGrantsNaming(conn, actor, nodeUuid, companyName);
|
|
218
|
+
return [...touched];
|
|
219
|
+
}
|
|
220
|
+
/** Step 4 of {@link retireDependents}: revoke and audit the grants naming the company. */
|
|
221
|
+
async revokeGrantsNaming(conn, actor, nodeUuid, companyName) {
|
|
222
|
+
const grants = (await conn.query(`SELECT ctg_uuid, ctg_owning_tenant_uuid, ctg_grantee_tenant_uuid, ctg_grantee_kind,
|
|
223
|
+
ctg_grantee_uuid, ctg_type_key, ctg_scope, ctg_allowed_actions
|
|
224
|
+
FROM \`${AUTHZ}\`.\`cross_tenant_grant\`
|
|
225
|
+
WHERE (ctg_owning_tenant_uuid = ? OR ctg_grantee_tenant_uuid = ?)
|
|
226
|
+
AND ctg_status <> 'revoked'
|
|
227
|
+
ORDER BY ctg_uuid
|
|
228
|
+
FOR UPDATE`, [nodeUuid, nodeUuid]));
|
|
229
|
+
if (grants.length === 0)
|
|
230
|
+
return;
|
|
231
|
+
const revokedAt = new Date(this.now());
|
|
232
|
+
const otherSides = grants.map((g) => g.ctg_owning_tenant_uuid === nodeUuid ? g.ctg_grantee_tenant_uuid : g.ctg_owning_tenant_uuid);
|
|
233
|
+
const names = await this.names(conn, otherSides);
|
|
234
|
+
for (const [i, g] of grants.entries()) {
|
|
235
|
+
await conn.query(`UPDATE \`${AUTHZ}\`.\`cross_tenant_grant\`
|
|
236
|
+
SET ctg_status = 'revoked', ctg_revoked_at = ?
|
|
237
|
+
WHERE ctg_uuid = ? AND ctg_status <> 'revoked'`, [revokedAt, g.ctg_uuid]);
|
|
238
|
+
const other = otherSides[i];
|
|
239
|
+
const attributes = {
|
|
240
|
+
eventType: 'cross_tenant_grant',
|
|
241
|
+
grantUuid: g.ctg_uuid,
|
|
242
|
+
typeKey: g.ctg_type_key,
|
|
243
|
+
scope: g.ctg_scope === 'record' ? 'record' : 'object_type',
|
|
244
|
+
granteeKind: g.ctg_grantee_kind === 'role' ? 'role' : 'user',
|
|
245
|
+
granteeUuid: g.ctg_grantee_uuid,
|
|
246
|
+
allowedActions: jsonIdArray(g.ctg_allowed_actions),
|
|
247
|
+
owningTenantId: g.ctg_owning_tenant_uuid,
|
|
248
|
+
granteeTenantId: g.ctg_grantee_tenant_uuid,
|
|
249
|
+
reason: 'company_deleted',
|
|
250
|
+
company: companyName,
|
|
251
|
+
otherCompany: names.get(other) ?? '(deleted organisation)',
|
|
252
|
+
};
|
|
253
|
+
// Audit on BOTH tenants (ADR-0009), one row each, as the writer's audit bridge files it.
|
|
254
|
+
for (const tenantId of new Set([g.ctg_owning_tenant_uuid, g.ctg_grantee_tenant_uuid])) {
|
|
255
|
+
await this.recordAudit(conn, actor, 'grant.revoked', attributes, tenantId);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** Lock the company, then require dominance over each of its members (see `remove`). */
|
|
260
|
+
async assertMayDeleteMembers(conn, actor, nodeUuid) {
|
|
261
|
+
await conn.query(`SELECT c_uuid FROM \`${APP}\`.\`companies\` WHERE c_uuid = ? FOR UPDATE`, [
|
|
262
|
+
nodeUuid,
|
|
263
|
+
]);
|
|
264
|
+
if (actor.isRoot === true)
|
|
265
|
+
return;
|
|
266
|
+
const rows = (await conn.query(`SELECT cm_user_uuid AS member FROM \`${APP}\`.\`company_membership\`
|
|
267
|
+
WHERE cm_company_uuid = ? FOR UPDATE`, [nodeUuid]));
|
|
268
|
+
const members = [...new Set(rows.map((r) => r.member))].sort();
|
|
269
|
+
for (const member of members) {
|
|
270
|
+
if (member === actor.id)
|
|
271
|
+
continue; // leaving your own membership behind stays allowed
|
|
272
|
+
try {
|
|
273
|
+
await this.ceiling.assertDominates(actor, member);
|
|
274
|
+
}
|
|
275
|
+
catch (cause) {
|
|
276
|
+
if (cause instanceof identity_errors_js_1.IdentityForbiddenError || cause instanceof identity_errors_js_1.IdentityNotFoundError) {
|
|
277
|
+
// Names no member (who belongs to the company is not the caller's to learn from a refusal).
|
|
278
|
+
throw new identity_errors_js_1.IdentityForbiddenError('You cannot delete this company: one of its members is outside your authority.');
|
|
279
|
+
}
|
|
280
|
+
throw cause;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
94
283
|
}
|
|
95
284
|
/** A company was just created: give it closure rows now and bump its ancestors (D5). */
|
|
96
285
|
async attached(nodeUuid) {
|
|
@@ -41,7 +41,7 @@ export declare class CompanyController {
|
|
|
41
41
|
* Update a company's name/status — gated on `identity:company:update`. Any other key
|
|
42
42
|
* (parentUuid, type, …) is ignored: use `PUT companies/:uuid/parent` to move a company.
|
|
43
43
|
*/
|
|
44
|
-
updateCompany(uuid: string, body: {
|
|
44
|
+
updateCompany(req: AuthedRequest, uuid: string, body: {
|
|
45
45
|
name?: string;
|
|
46
46
|
status?: string;
|
|
47
47
|
}): Promise<{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"company.controller.d.ts","sourceRoot":"","sources":["../../../src/features/company/company.controller.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAkB,KAAK,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAC5E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAGvD,qBAEa,iBAAiB;IAChB,OAAO,CAAC,QAAQ,CAAC,SAAS;gBAAT,SAAS,EAAE,cAAc;IAEtD;;;;OAIG;IAGG,aAAa,CAAU,KAAK,EAAE,YAAY;;;;;;IAMhD;;;OAGG;IAGG,aAAa,CAAQ,GAAG,EAAE,aAAa,EAAU,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE;;;IAK9E,2DAA2D;IAG3D,UAAU,CAAgB,IAAI,EAAE,MAAM;IAItC;;;;;OAKG;IAGG,WAAW,CACR,GAAG,EAAE,aAAa,EACV,IAAI,EAAE,MAAM,EACnB,IAAI,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;;;IAa9C;;;OAGG;IAGG,aAAa,
|
|
1
|
+
{"version":3,"file":"company.controller.d.ts","sourceRoot":"","sources":["../../../src/features/company/company.controller.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAkB,KAAK,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAC5E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAGvD,qBAEa,iBAAiB;IAChB,OAAO,CAAC,QAAQ,CAAC,SAAS;gBAAT,SAAS,EAAE,cAAc;IAEtD;;;;OAIG;IAGG,aAAa,CAAU,KAAK,EAAE,YAAY;;;;;;IAMhD;;;OAGG;IAGG,aAAa,CAAQ,GAAG,EAAE,aAAa,EAAU,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE;;;IAK9E,2DAA2D;IAG3D,UAAU,CAAgB,IAAI,EAAE,MAAM;IAItC;;;;;OAKG;IAGG,WAAW,CACR,GAAG,EAAE,aAAa,EACV,IAAI,EAAE,MAAM,EACnB,IAAI,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;;;IAa9C;;;OAGG;IAGG,aAAa,CACV,GAAG,EAAE,aAAa,EACV,IAAI,EAAE,MAAM,EACnB,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE;;;IASlD,iGAAiG;IAG3F,aAAa,CAAQ,GAAG,EAAE,aAAa,EAAiB,IAAI,EAAE,MAAM;;;CAI3E"}
|
|
@@ -64,8 +64,11 @@ let CompanyController = class CompanyController {
|
|
|
64
64
|
* Update a company's name/status — gated on `identity:company:update`. Any other key
|
|
65
65
|
* (parentUuid, type, …) is ignored: use `PUT companies/:uuid/parent` to move a company.
|
|
66
66
|
*/
|
|
67
|
-
async updateCompany(uuid, body) {
|
|
68
|
-
await this.companies.updateCompany(uuid, {
|
|
67
|
+
async updateCompany(req, uuid, body) {
|
|
68
|
+
await this.companies.updateCompany(req.principal, uuid, {
|
|
69
|
+
name: body?.name,
|
|
70
|
+
status: body?.status,
|
|
71
|
+
});
|
|
69
72
|
return { success: true };
|
|
70
73
|
}
|
|
71
74
|
/** Delete a company — gated on `identity:company:delete`. A company with sub-companies → 409. */
|
|
@@ -113,10 +116,11 @@ __decorate([
|
|
|
113
116
|
__decorate([
|
|
114
117
|
(0, common_1.Put)('companies/:uuid'),
|
|
115
118
|
(0, guard_1.Classified)({ typeKey: 'company', action: 'update' }),
|
|
116
|
-
__param(0, (0, common_1.
|
|
117
|
-
__param(1, (0, common_1.
|
|
119
|
+
__param(0, (0, common_1.Req)()),
|
|
120
|
+
__param(1, (0, common_1.Param)('uuid')),
|
|
121
|
+
__param(2, (0, common_1.Body)()),
|
|
118
122
|
__metadata("design:type", Function),
|
|
119
|
-
__metadata("design:paramtypes", [String, Object]),
|
|
123
|
+
__metadata("design:paramtypes", [Object, String, Object]),
|
|
120
124
|
__metadata("design:returntype", Promise)
|
|
121
125
|
], CompanyController.prototype, "updateCompany", null);
|
|
122
126
|
__decorate([
|
|
@@ -26,8 +26,15 @@ export declare class CompanyService {
|
|
|
26
26
|
listCompanies(params?: ListParams): Promise<import("@astram/engine").RecordPage>;
|
|
27
27
|
/** Read a single company. */
|
|
28
28
|
getCompany(uuid: string): Promise<import("@astram/engine").RecordView | null>;
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Update a company's name/status. Any other key (parentUuid, type, …) is dropped.
|
|
31
|
+
*
|
|
32
|
+
* Phase 9 #13c: a non-root actor may not change the STATUS of the organisation its access is
|
|
33
|
+
* rooted at (the same own-root guard as move and delete, F6): deactivating it would act on the
|
|
34
|
+
* whole tenant above the actor's authority. Re-sending the current status (the company form
|
|
35
|
+
* always sends both fields) stays allowed, so the own root can still be renamed.
|
|
36
|
+
*/
|
|
37
|
+
updateCompany(actor: Principal, uuid: string, patch: Readonly<Record<string, unknown>>): Promise<void>;
|
|
31
38
|
/** Move a company under a new parent (hierarchy rules + cycle check, see the hierarchy service). */
|
|
32
39
|
moveCompany(actor: Principal, uuid: string, parentUuid: string | null): Promise<void>;
|
|
33
40
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"company.service.d.ts","sourceRoot":"","sources":["../../../src/features/company/company.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAkB,KAAK,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1E,OAAO,EAAE,uBAAuB,
|
|
1
|
+
{"version":3,"file":"company.service.d.ts","sourceRoot":"","sources":["../../../src/features/company/company.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAkB,KAAK,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1E,OAAO,EAAE,uBAAuB,EAAa,MAAM,gCAAgC,CAAC;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). */
|