@astram/identity 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/company-membership.object.d.ts.map +1 -1
- package/dist/company-membership.object.js +3 -2
- package/dist/company.object.d.ts.map +1 -1
- package/dist/company.object.js +8 -3
- package/dist/features/access/index.d.ts.map +1 -1
- package/dist/features/access/index.js +1 -5
- package/dist/features/audit/audit-read.service.d.ts.map +1 -1
- package/dist/features/audit/audit-read.service.js +13 -6
- package/dist/features/audit/audit.controller.d.ts.map +1 -1
- package/dist/features/audit/audit.controller.js +2 -0
- package/dist/features/audit/mariadb-audit-reader.d.ts.map +1 -1
- package/dist/features/authz/access-bundle-reconcile.job.d.ts.map +1 -1
- package/dist/features/authz/catalog-reconcile.job.d.ts.map +1 -1
- package/dist/features/authz/catalog-reconcile.job.js +1 -3
- package/dist/features/authz/db-permission-facts.adapter.d.ts +16 -0
- package/dist/features/authz/db-permission-facts.adapter.d.ts.map +1 -1
- package/dist/features/authz/db-permission-facts.adapter.js +46 -2
- package/dist/features/authz/grant-admin.controller.d.ts +8 -5
- package/dist/features/authz/grant-admin.controller.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.controller.js +20 -18
- package/dist/features/authz/grant-admin.service.d.ts +72 -16
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.service.js +232 -86
- package/dist/features/authz/grant-ceiling.d.ts +100 -1
- package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
- package/dist/features/authz/grant-ceiling.js +264 -2
- package/dist/features/authz/mariadb-catalog-store.d.ts.map +1 -1
- package/dist/features/authz/mariadb-catalog-store.js +15 -3
- package/dist/features/authz/permission-version.bumper.d.ts +52 -1
- package/dist/features/authz/permission-version.bumper.d.ts.map +1 -1
- package/dist/features/authz/permission-version.bumper.js +119 -20
- package/dist/features/authz/record-authorization.d.ts +12 -0
- package/dist/features/authz/record-authorization.d.ts.map +1 -1
- package/dist/features/authz/record-authorization.js +49 -0
- package/dist/features/authz/role-binding.d.ts +36 -0
- package/dist/features/authz/role-binding.d.ts.map +1 -0
- package/dist/features/authz/role-binding.js +53 -0
- package/dist/features/company/company-hierarchy.service.d.ts +108 -0
- package/dist/features/company/company-hierarchy.service.d.ts.map +1 -0
- package/dist/features/company/company-hierarchy.service.js +277 -0
- package/dist/features/company/company.controller.d.ts +23 -5
- package/dist/features/company/company.controller.d.ts.map +1 -1
- package/dist/features/company/company.controller.js +47 -12
- package/dist/features/company/company.service.d.ts +28 -12
- package/dist/features/company/company.service.d.ts.map +1 -1
- package/dist/features/company/company.service.js +61 -14
- package/dist/features/cross-tenant/cross-tenant-grant.controller.d.ts.map +1 -1
- package/dist/features/cross-tenant/cross-tenant-grant.controller.js +2 -0
- package/dist/features/identity-feature.module.d.ts +2 -0
- package/dist/features/identity-feature.module.d.ts.map +1 -1
- package/dist/features/identity-feature.module.js +72 -5
- package/dist/features/membership/membership.controller.d.ts +6 -5
- package/dist/features/membership/membership.controller.d.ts.map +1 -1
- package/dist/features/membership/membership.controller.js +34 -22
- package/dist/features/membership/membership.service.d.ts +20 -14
- package/dist/features/membership/membership.service.d.ts.map +1 -1
- package/dist/features/membership/membership.service.js +62 -29
- package/dist/features/membership/posting-version-bump.d.ts +12 -0
- package/dist/features/membership/posting-version-bump.d.ts.map +1 -1
- package/dist/features/membership/posting.service.d.ts +20 -24
- package/dist/features/membership/posting.service.d.ts.map +1 -1
- package/dist/features/membership/posting.service.js +101 -64
- package/dist/features/org-structure/org-catalog.controller.d.ts.map +1 -1
- package/dist/features/org-structure/org-catalog.controller.js +2 -0
- package/dist/features/org-structure/org-catalog.service.d.ts.map +1 -1
- package/dist/features/org-structure/org-catalog.service.js +4 -1
- package/dist/features/policy/policy.controller.d.ts.map +1 -1
- package/dist/features/policy/policy.controller.js +2 -0
- package/dist/features/role/role.controller.d.ts +3 -2
- package/dist/features/role/role.controller.d.ts.map +1 -1
- package/dist/features/role/role.controller.js +15 -10
- package/dist/features/role/role.service.d.ts +39 -8
- package/dist/features/role/role.service.d.ts.map +1 -1
- package/dist/features/role/role.service.js +77 -10
- package/dist/features/scope/scope-closure.service.d.ts +54 -4
- package/dist/features/scope/scope-closure.service.d.ts.map +1 -1
- package/dist/features/scope/scope-closure.service.js +237 -85
- package/dist/features/service-account/service-account.controller.d.ts +3 -2
- package/dist/features/service-account/service-account.controller.d.ts.map +1 -1
- package/dist/features/service-account/service-account.controller.js +29 -15
- package/dist/features/service-account/service-account.service.d.ts +29 -7
- package/dist/features/service-account/service-account.service.d.ts.map +1 -1
- package/dist/features/service-account/service-account.service.js +73 -12
- 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/share/share.controller.d.ts.map +1 -1
- package/dist/features/share/share.controller.js +2 -0
- package/dist/features/share/share.service.d.ts.map +1 -1
- package/dist/features/shared/identity-error.interceptor.d.ts +24 -0
- package/dist/features/shared/identity-error.interceptor.d.ts.map +1 -0
- package/dist/features/shared/identity-error.interceptor.js +82 -0
- package/dist/features/shared/identity-errors.d.ts +23 -0
- package/dist/features/shared/identity-errors.d.ts.map +1 -0
- package/dist/features/shared/identity-errors.js +41 -0
- package/dist/features/user/session-revocation-check.d.ts +11 -0
- package/dist/features/user/session-revocation-check.d.ts.map +1 -0
- package/dist/features/user/session-revocation-check.js +21 -0
- package/dist/features/user/user.controller.d.ts +11 -6
- package/dist/features/user/user.controller.d.ts.map +1 -1
- package/dist/features/user/user.controller.js +35 -17
- package/dist/features/user/user.service.d.ts +55 -14
- package/dist/features/user/user.service.d.ts.map +1 -1
- package/dist/features/user/user.service.js +143 -16
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -6
- package/dist/internal.d.ts +10 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +13 -0
- package/dist/membership-posting.object.d.ts.map +1 -1
- package/dist/membership-posting.object.js +4 -2
- package/dist/objects.d.ts.map +1 -1
- package/dist/objects.js +13 -3
- package/dist/role.object.d.ts.map +1 -1
- package/dist/role.object.js +3 -0
- package/dist/user.object.d.ts.map +1 -1
- package/dist/user.object.js +3 -0
- package/migrations/1710000000019-AddSessionMustReenroll.cjs +35 -0
- package/package.json +15 -4
- package/dist/auth-facts-keys.js.map +0 -1
- package/dist/branch.object.js.map +0 -1
- package/dist/company-branch.object.js.map +0 -1
- package/dist/company-department.object.js.map +0 -1
- package/dist/company-membership.object.js.map +0 -1
- package/dist/company.object.js.map +0 -1
- package/dist/cross-tenant-grant.object.js.map +0 -1
- package/dist/department.object.js.map +0 -1
- package/dist/features/access/access-admin.access.js.map +0 -1
- package/dist/features/access/company-admin.access.js.map +0 -1
- package/dist/features/access/index.js.map +0 -1
- package/dist/features/access/user-management.access.js.map +0 -1
- package/dist/features/audit/audit-read.service.js.map +0 -1
- package/dist/features/audit/audit.controller.js.map +0 -1
- package/dist/features/audit/directory-label-resolver.js.map +0 -1
- package/dist/features/audit/mariadb-audit-reader.js.map +0 -1
- package/dist/features/authz/access-bundle-reconcile.job.js.map +0 -1
- package/dist/features/authz/access-bundle-store.js.map +0 -1
- package/dist/features/authz/catalog-bootstrap.provider.js.map +0 -1
- package/dist/features/authz/catalog-reconcile.job.js.map +0 -1
- package/dist/features/authz/db-permission-facts.adapter.js.map +0 -1
- package/dist/features/authz/grant-admin.controller.js.map +0 -1
- package/dist/features/authz/grant-admin.service.js.map +0 -1
- package/dist/features/authz/grant-ceiling.js.map +0 -1
- package/dist/features/authz/mariadb-catalog-store.js.map +0 -1
- package/dist/features/authz/permission-version.bumper.js.map +0 -1
- package/dist/features/authz/record-authorization.js.map +0 -1
- package/dist/features/authz/session-facts.reader.js.map +0 -1
- package/dist/features/company/company.controller.js.map +0 -1
- package/dist/features/company/company.service.js.map +0 -1
- package/dist/features/cross-tenant/cross-tenant-grant-read.service.js.map +0 -1
- package/dist/features/cross-tenant/cross-tenant-grant.controller.js.map +0 -1
- package/dist/features/cross-tenant/grant-authorize.js.map +0 -1
- package/dist/features/identity-feature.module.js.map +0 -1
- package/dist/features/membership/membership-checker.js.map +0 -1
- package/dist/features/membership/membership.controller.js.map +0 -1
- package/dist/features/membership/membership.service.js.map +0 -1
- package/dist/features/membership/posting-version-bump.js.map +0 -1
- package/dist/features/membership/posting.service.js.map +0 -1
- package/dist/features/org-structure/org-catalog.controller.js.map +0 -1
- package/dist/features/org-structure/org-catalog.service.js.map +0 -1
- package/dist/features/policy/policy.controller.js.map +0 -1
- package/dist/features/policy/policy.service.js.map +0 -1
- package/dist/features/role/role.controller.js.map +0 -1
- package/dist/features/role/role.service.js.map +0 -1
- package/dist/features/scope/scope-closure.service.js.map +0 -1
- package/dist/features/scope/scope-subtree.resolver.js.map +0 -1
- package/dist/features/share/share.controller.js.map +0 -1
- package/dist/features/share/share.service.js.map +0 -1
- package/dist/features/shared/http.js.map +0 -1
- package/dist/features/shared/list-query.js.map +0 -1
- package/dist/features/shared/named-option.js.map +0 -1
- package/dist/features/shared/record-resolver.js.map +0 -1
- package/dist/features/user/user.controller.js.map +0 -1
- package/dist/features/user/user.service.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/membership-posting.object.js.map +0 -1
- package/dist/migrations-dir.js.map +0 -1
- package/dist/objects.js.map +0 -1
- package/dist/record-share.object.js.map +0 -1
- package/dist/role.object.js.map +0 -1
- package/dist/schema.js.map +0 -1
- package/dist/shared-resource.object.js.map +0 -1
- package/dist/user.object.js.map +0 -1
|
@@ -3,8 +3,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.IdentityRegistryMismatchError = void 0;
|
|
6
7
|
exports.createIdentityAuthzPool = createIdentityAuthzPool;
|
|
7
8
|
exports.buildIdentityRecordAuthorization = buildIdentityRecordAuthorization;
|
|
9
|
+
exports.buildRecordAuthorizationInputs = buildRecordAuthorizationInputs;
|
|
10
|
+
exports.noteIdentityCeilingRegistry = noteIdentityCeilingRegistry;
|
|
8
11
|
/**
|
|
9
12
|
* Assemble the record-tier authorization wiring for `@astram/identity`: the lean grant-read
|
|
10
13
|
* pool factory + the `recordAuthorization` inputs the consumer passes to
|
|
@@ -56,6 +59,15 @@ function createIdentityAuthzPool() {
|
|
|
56
59
|
* those keys now project, and the framework still owns the four record-tier adapters + scope fn.
|
|
57
60
|
*/
|
|
58
61
|
function buildIdentityRecordAuthorization(pool, extraObjects = []) {
|
|
62
|
+
const inputs = buildRecordAuthorizationInputs(pool, extraObjects);
|
|
63
|
+
noteRegistry('decision', inputs.registry);
|
|
64
|
+
return inputs;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The inputs builder WITHOUT the boot parity record — for the identity module's own internal
|
|
68
|
+
* authorities (they are not the decision path the consumer hands `ShieldModule`).
|
|
69
|
+
*/
|
|
70
|
+
function buildRecordAuthorizationInputs(pool, extraObjects = []) {
|
|
59
71
|
const registry = (0, guard_1.buildIdentityModuleRegistry)([...objects_js_1.identityObjects, ...extraObjects]);
|
|
60
72
|
return {
|
|
61
73
|
pool,
|
|
@@ -65,3 +77,40 @@ function buildIdentityRecordAuthorization(pool, extraObjects = []) {
|
|
|
65
77
|
operationManifest: [],
|
|
66
78
|
};
|
|
67
79
|
}
|
|
80
|
+
const recordedKeySets = {
|
|
81
|
+
decision: new Map(),
|
|
82
|
+
ceiling: new Map(),
|
|
83
|
+
};
|
|
84
|
+
/** The boot error: the two registries' action-key sets differ (names the first differing keys). */
|
|
85
|
+
class IdentityRegistryMismatchError extends Error {
|
|
86
|
+
constructor(onlyDecision, onlyCeiling) {
|
|
87
|
+
const sample = (keys) => keys.length === 0 ? 'none' : keys.slice(0, 10).join(', ') + (keys.length > 10 ? ', …' : '');
|
|
88
|
+
super('IdentityFeatureModule.forRoot({ extraObjects }) and buildIdentityRecordAuthorization(pool, ' +
|
|
89
|
+
'extraObjects) were given different object types. Pass the SAME array to both. ' +
|
|
90
|
+
`Only in the decision path: ${sample(onlyDecision)}. ` +
|
|
91
|
+
`Only in IdentityFeatureModule: ${sample(onlyCeiling)}.`);
|
|
92
|
+
this.name = 'IdentityRegistryMismatchError';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
exports.IdentityRegistryMismatchError = IdentityRegistryMismatchError;
|
|
96
|
+
/** Record one side's registry and throw if it disagrees with any recorded registry of the other. */
|
|
97
|
+
function noteRegistry(role, registry) {
|
|
98
|
+
const keys = [...new Set(registry.listActions().map((a) => a.permissionKey))].sort();
|
|
99
|
+
const other = recordedKeySets[role === 'decision' ? 'ceiling' : 'decision'];
|
|
100
|
+
for (const otherKeys of other.values()) {
|
|
101
|
+
const mine = new Set(keys);
|
|
102
|
+
const theirs = new Set(otherKeys);
|
|
103
|
+
const onlyMine = keys.filter((k) => !theirs.has(k));
|
|
104
|
+
const onlyTheirs = otherKeys.filter((k) => !mine.has(k));
|
|
105
|
+
if (onlyMine.length > 0 || onlyTheirs.length > 0) {
|
|
106
|
+
throw role === 'decision'
|
|
107
|
+
? new IdentityRegistryMismatchError(onlyMine, onlyTheirs)
|
|
108
|
+
: new IdentityRegistryMismatchError(onlyTheirs, onlyMine);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
recordedKeySets[role].set(keys.join('\n'), keys);
|
|
112
|
+
}
|
|
113
|
+
/** Called by `IdentityFeatureModule.forRoot` with the grant ceiling's registry (Phase 8 #4). */
|
|
114
|
+
function noteIdentityCeilingRegistry(registry) {
|
|
115
|
+
noteRegistry('ceiling', registry);
|
|
116
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role binding — where a GLOBAL role (`shield_app.roles`) is in use, relative to a caller's subtree.
|
|
3
|
+
*
|
|
4
|
+
* Roles are a global catalog, so one `role_permission` edit changes every tenant that uses the role
|
|
5
|
+
* (review finding #1). Until roles are tenant-owned (D1 follow-up), "may this caller administer the
|
|
6
|
+
* role" is decided by WHERE the role is bound:
|
|
7
|
+
* - a human binding: a `membership_posting` on a `company_membership` of company X;
|
|
8
|
+
* - a machine binding: a `service_account` of tenant X whose `sa_role_ids` holds the role.
|
|
9
|
+
* `inside` = some binding's node is in the caller's subtree; `outside` = some binding's node is not.
|
|
10
|
+
*
|
|
11
|
+
* ONE definition, shared by grant administration (the `subtreeExists` read predicate below and the
|
|
12
|
+
* write rule), role update/delete, and posting creation, via {@link GrantCeiling}. All node ids and
|
|
13
|
+
* the role id are `?`-bound (never interpolated).
|
|
14
|
+
*/
|
|
15
|
+
import type { Queryable } from './permission-version.bumper.js';
|
|
16
|
+
/** Where a role is bound relative to a set of subtree nodes. */
|
|
17
|
+
export interface RoleBinding {
|
|
18
|
+
readonly inside: boolean;
|
|
19
|
+
readonly outside: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The shared EXISTS-subtree fragment for a non-root caller's READS (Task 4.2): a role row `r`
|
|
23
|
+
* qualifies only if it is bound (`membership_posting → company_membership`) to a node in the
|
|
24
|
+
* subtree. The caller splices `params` where `sql` sits in the surrounding query.
|
|
25
|
+
*/
|
|
26
|
+
export declare function subtreeExists(nodeIds: readonly string[]): {
|
|
27
|
+
sql: string;
|
|
28
|
+
params: string[];
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Read where `roleUuid` is bound relative to `nodeIds` (human postings + service accounts) in ONE
|
|
32
|
+
* query. An empty `nodeIds` is treated as "nothing is inside" (every binding is outside) — fail
|
|
33
|
+
* closed, never an `IN ()` syntax error.
|
|
34
|
+
*/
|
|
35
|
+
export declare function readRoleBinding(q: Queryable, roleUuid: string, nodeIds: readonly string[]): Promise<RoleBinding>;
|
|
36
|
+
//# sourceMappingURL=role-binding.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"role-binding.d.ts","sourceRoot":"","sources":["../../../src/features/authz/role-binding.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gCAAgC,CAAC;AAIhE,gEAAgE;AAChE,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC3B;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAQ3F;AASD;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,CAAC,EAAE,SAAS,EACZ,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,SAAS,MAAM,EAAE,GACzB,OAAO,CAAC,WAAW,CAAC,CAwBtB"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.subtreeExists = subtreeExists;
|
|
4
|
+
exports.readRoleBinding = readRoleBinding;
|
|
5
|
+
const APP = 'shield_app';
|
|
6
|
+
/**
|
|
7
|
+
* The shared EXISTS-subtree fragment for a non-root caller's READS (Task 4.2): a role row `r`
|
|
8
|
+
* qualifies only if it is bound (`membership_posting → company_membership`) to a node in the
|
|
9
|
+
* subtree. The caller splices `params` where `sql` sits in the surrounding query.
|
|
10
|
+
*/
|
|
11
|
+
function subtreeExists(nodeIds) {
|
|
12
|
+
const ph = nodeIds.map(() => '?').join(', ');
|
|
13
|
+
const sql = ` AND EXISTS (` +
|
|
14
|
+
`SELECT 1 FROM \`${APP}\`.\`membership_posting\` mp` +
|
|
15
|
+
` JOIN \`${APP}\`.\`company_membership\` cm ON cm.cm_uuid = mp.mp_membership_uuid` +
|
|
16
|
+
` WHERE mp.mp_role_uuid = r.r_uuid AND cm.cm_company_uuid IN (${ph}))`;
|
|
17
|
+
return { sql, params: [...nodeIds] };
|
|
18
|
+
}
|
|
19
|
+
/** Coerce a driver aggregate (number | bigint | string | null) to a boolean. */
|
|
20
|
+
function truthy(v) {
|
|
21
|
+
if (v === null || v === undefined)
|
|
22
|
+
return false;
|
|
23
|
+
if (typeof v === 'bigint')
|
|
24
|
+
return v !== 0n;
|
|
25
|
+
return Number(v) > 0;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Read where `roleUuid` is bound relative to `nodeIds` (human postings + service accounts) in ONE
|
|
29
|
+
* query. An empty `nodeIds` is treated as "nothing is inside" (every binding is outside) — fail
|
|
30
|
+
* closed, never an `IN ()` syntax error.
|
|
31
|
+
*/
|
|
32
|
+
async function readRoleBinding(q, roleUuid, nodeIds) {
|
|
33
|
+
const ids = nodeIds.length > 0 ? [...nodeIds] : [];
|
|
34
|
+
const inList = ids.length > 0 ? ids.map(() => '?').join(', ') : 'NULL';
|
|
35
|
+
// `x IN (NULL)` is NULL (never true) and `x NOT IN (NULL)` is NULL too, so for an empty list the
|
|
36
|
+
// outside test is written as a plain existence check instead.
|
|
37
|
+
const insideExpr = ids.length > 0 ? `b.node IN (${inList})` : '0';
|
|
38
|
+
const outsideExpr = ids.length > 0 ? `b.node NOT IN (${inList})` : '1';
|
|
39
|
+
const rows = (await q.query(`SELECT COALESCE(MAX(${insideExpr}), 0) AS bound_inside,
|
|
40
|
+
COALESCE(MAX(${outsideExpr}), 0) AS bound_outside
|
|
41
|
+
FROM (
|
|
42
|
+
SELECT cm.cm_company_uuid AS node
|
|
43
|
+
FROM \`${APP}\`.\`membership_posting\` mp
|
|
44
|
+
JOIN \`${APP}\`.\`company_membership\` cm ON cm.cm_uuid = mp.mp_membership_uuid
|
|
45
|
+
WHERE mp.mp_role_uuid = ?
|
|
46
|
+
UNION ALL
|
|
47
|
+
SELECT sa.sa_tenant_id AS node
|
|
48
|
+
FROM \`${APP}\`.\`service_account\` sa
|
|
49
|
+
WHERE JSON_CONTAINS(sa.sa_role_ids, JSON_QUOTE(?))
|
|
50
|
+
) b`, [...ids, ...ids, roleUuid, roleUuid]));
|
|
51
|
+
const row = rows[0];
|
|
52
|
+
return { inside: truthy(row?.bound_inside), outside: truthy(row?.bound_outside) };
|
|
53
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CompanyHierarchyService — the ONLY way a company's place in the tree changes (security hardening
|
|
3
|
+
* #2, D5). The generic company patch can no longer move a company (`parentUuid`/`type` are
|
|
4
|
+
* immutable at the engine, ADR-0034, and the patch is whitelisted); every move, delete and new-node
|
|
5
|
+
* attach goes through `ScopeClosureService`, which owns the cycle check, the closure rebuild and
|
|
6
|
+
* the permission-version bump (ADR-0027 E-1/E-2).
|
|
7
|
+
*
|
|
8
|
+
* Authority (non-root; root bypasses), decided from the session's cached subtree — the SAME
|
|
9
|
+
* `scopeSubtreeNodeIds` the engine's write wall uses:
|
|
10
|
+
* - move: the node is in the actor's subtree, is NOT the actor's own root node (a sub-admin can
|
|
11
|
+
* never re-home its whole tenant), and the new parent is in the actor's subtree and non-null
|
|
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; 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.
|
|
17
|
+
*
|
|
18
|
+
* Move and delete are audited ON their own transaction (a failed audit rolls them back). Names,
|
|
19
|
+
* never ids, in the audit detail (ADR-0012).
|
|
20
|
+
*/
|
|
21
|
+
import type { Principal } from '@astram/core';
|
|
22
|
+
import type { AuditPort } from '@astram/engine';
|
|
23
|
+
import { ScopeClosureService } from '../scope/scope-closure.service.js';
|
|
24
|
+
/** The read surface used for existence/name lookups (the shared authz pool satisfies it). */
|
|
25
|
+
export interface HierarchyQueryable {
|
|
26
|
+
query(sql: string, params?: readonly unknown[]): Promise<unknown>;
|
|
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
|
+
export declare class CompanyHierarchyService {
|
|
41
|
+
private readonly pool;
|
|
42
|
+
private readonly closure;
|
|
43
|
+
/** Phase 8 #3: required — a company delete must dominate every member (no silent skip). */
|
|
44
|
+
private readonly ceiling;
|
|
45
|
+
private readonly audit?;
|
|
46
|
+
private readonly now;
|
|
47
|
+
constructor(pool: HierarchyQueryable, closure: ScopeClosureService,
|
|
48
|
+
/** Phase 8 #3: required — a company delete must dominate every member (no silent skip). */
|
|
49
|
+
ceiling: MemberDominance, audit?: AuditPort | undefined, now?: () => number);
|
|
50
|
+
/** The actor's own root node (the active node its subtree hangs from). */
|
|
51
|
+
private ownRoot;
|
|
52
|
+
/** Names of the given companies (unknown → absent). */
|
|
53
|
+
private names;
|
|
54
|
+
private recordAudit;
|
|
55
|
+
/** Re-parent `nodeUuid` under `newParentUuid` (null = make it a top-level tenant; root only). */
|
|
56
|
+
move(actor: Principal, nodeUuid: string, newParentUuid: string | null): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Delete a company through `deleteNode` (refuses a node with children; closure rows + ancestor
|
|
59
|
+
* and own-node bump in one transaction). The caller has already checked reach (walled read).
|
|
60
|
+
*
|
|
61
|
+
* Phase 8 #3: the delete cascades every membership and posting of the company, stripping its
|
|
62
|
+
* members' authority there, so a non-root actor must DOMINATE every member (the actor itself is
|
|
63
|
+
* skipped). Inside the delete's transaction and tree lock, before anything is deleted: lock the
|
|
64
|
+
* company row (a new membership's FK check waits on it), read the members with a locking read
|
|
65
|
+
* (latest committed rows, no insert into the range until commit), then `assertDominates` each.
|
|
66
|
+
* Any refusal rolls the whole delete back (403). Root bypasses the dominance check.
|
|
67
|
+
*
|
|
68
|
+
* Phase 8 #3 (continued): nothing in the database ties a service account or a cross-tenant grant
|
|
69
|
+
* to its company (no FK), so, still before the company row goes and on the same transaction,
|
|
70
|
+
* {@link retireDependents} disables the company's agents and revokes their keys, removes the
|
|
71
|
+
* company from other agents' scopes, and revokes every cross-tenant grant naming it — audited on
|
|
72
|
+
* that transaction. One transaction: a failure anywhere leaves the company and everything
|
|
73
|
+
* depending on it as they were; a commit leaves a deleted company with no live agent or grant.
|
|
74
|
+
*/
|
|
75
|
+
remove(actor: Principal, nodeUuid: string): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Retire what depends on a company being deleted (Phase 8 #3), on the delete's transaction after
|
|
78
|
+
* the company row lock and the member check, BEFORE the company row is deleted. Returns the
|
|
79
|
+
* tenants whose agents changed (the delete bumps them with the node and its ancestors).
|
|
80
|
+
*
|
|
81
|
+
* 1. Agents (`shield_app.service_account`) bound to the company, or whose scope names it, are
|
|
82
|
+
* read with a locking read.
|
|
83
|
+
* 2. The company's OWN agents: a non-root actor must be able to administer every one of them
|
|
84
|
+
* (`assertMayRetireServiceAccounts`, the ADR-0029 ceiling: scope and roles within its own) or
|
|
85
|
+
* the whole delete is refused (403). Then each is disabled and its keys revoked (the same
|
|
86
|
+
* `sak_revoked_at_ms` stamp `MariaDbApiKeyStore.revoke` writes, here on this transaction).
|
|
87
|
+
* 3. Other tenants' agents lose the company from their scope (reach over a deleted node only;
|
|
88
|
+
* the actor dominates that node). An agent whose ENTIRE scope was the company is disabled and
|
|
89
|
+
* its keys revoked instead — an empty scope would make the API-key path fall back to the
|
|
90
|
+
* agent's own tenant, a widening — and its scope is left as it was (a dead reference, never
|
|
91
|
+
* wider). These need no further authority check: they lose only reach over the deleted node.
|
|
92
|
+
* 4. Cross-tenant grants (`shield_authz.cross_tenant_grant`) where the company is the owning or
|
|
93
|
+
* the grantee tenant and that are not yet revoked are revoked exactly as
|
|
94
|
+
* `CrossTenantGrantWriter.revoke` does (status + `ctg_revoked_at`), and audited under both
|
|
95
|
+
* tenants. The writer itself cannot join this transaction (it writes on its own pool, and
|
|
96
|
+
* audits best-effort after the write); either side may revoke (ADR-0009), and the actor's
|
|
97
|
+
* authority over the deleted side is the delete itself.
|
|
98
|
+
* Every change is audited on this transaction (names; ids only in structured attributes).
|
|
99
|
+
*/
|
|
100
|
+
private retireDependents;
|
|
101
|
+
/** Step 4 of {@link retireDependents}: revoke and audit the grants naming the company. */
|
|
102
|
+
private revokeGrantsNaming;
|
|
103
|
+
/** Lock the company, then require dominance over each of its members (see `remove`). */
|
|
104
|
+
private assertMayDeleteMembers;
|
|
105
|
+
/** A company was just created: give it closure rows now and bump its ancestors (D5). */
|
|
106
|
+
attached(nodeUuid: string): Promise<void>;
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=company-hierarchy.service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
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;AAUxF,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,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,0EAA0E;IAC1E,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;IA6E9B,0FAA0F;YAC5E,kBAAkB;IAmDhC,wFAAwF;YAC1E,sBAAsB;IA+BpC,wFAAwF;IAClF,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAGhD"}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CompanyHierarchyService = void 0;
|
|
4
|
+
const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
|
|
5
|
+
const identity_errors_js_1 = require("../shared/identity-errors.js");
|
|
6
|
+
const APP = 'shield_app';
|
|
7
|
+
const AUTHZ = 'shield_authz';
|
|
8
|
+
/** A JSON-array column (the driver returns an array OR a string) → its string items; else []. */
|
|
9
|
+
function jsonIdArray(v) {
|
|
10
|
+
let arr = v;
|
|
11
|
+
if (typeof v === 'string') {
|
|
12
|
+
try {
|
|
13
|
+
arr = JSON.parse(v);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
arr = [];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return Array.isArray(arr) ? arr.filter((x) => typeof x === 'string') : [];
|
|
20
|
+
}
|
|
21
|
+
class CompanyHierarchyService {
|
|
22
|
+
pool;
|
|
23
|
+
closure;
|
|
24
|
+
ceiling;
|
|
25
|
+
audit;
|
|
26
|
+
now;
|
|
27
|
+
constructor(pool, closure,
|
|
28
|
+
/** Phase 8 #3: required — a company delete must dominate every member (no silent skip). */
|
|
29
|
+
ceiling, audit, now = Date.now) {
|
|
30
|
+
this.pool = pool;
|
|
31
|
+
this.closure = closure;
|
|
32
|
+
this.ceiling = ceiling;
|
|
33
|
+
this.audit = audit;
|
|
34
|
+
this.now = now;
|
|
35
|
+
}
|
|
36
|
+
/** The actor's own root node (the active node its subtree hangs from). */
|
|
37
|
+
ownRoot(actor) {
|
|
38
|
+
return actor.activeScopeNodeId ?? actor.tenantId;
|
|
39
|
+
}
|
|
40
|
+
/** Names of the given companies (unknown → absent). */
|
|
41
|
+
async names(q, ids) {
|
|
42
|
+
const unique = [...new Set(ids)];
|
|
43
|
+
if (unique.length === 0)
|
|
44
|
+
return new Map();
|
|
45
|
+
const rows = (await q.query(`SELECT c_uuid, c_name FROM \`${APP}\`.\`companies\` WHERE c_uuid IN (${unique.map(() => '?').join(', ')})`, unique));
|
|
46
|
+
return new Map(rows.map((r) => [r.c_uuid, r.c_name]));
|
|
47
|
+
}
|
|
48
|
+
async recordAudit(conn, actor, action, attributes, tenantId = actor.tenantId) {
|
|
49
|
+
if (!this.audit)
|
|
50
|
+
return;
|
|
51
|
+
await this.audit.record({
|
|
52
|
+
type: 'admin_action',
|
|
53
|
+
occurredAtMs: this.now(),
|
|
54
|
+
actorId: actor.id,
|
|
55
|
+
tenantId,
|
|
56
|
+
action: { key: action },
|
|
57
|
+
attributes: { eventType: 'company_hierarchy', ...attributes },
|
|
58
|
+
}, (sql, params) => conn.query(sql, params));
|
|
59
|
+
}
|
|
60
|
+
/** Re-parent `nodeUuid` under `newParentUuid` (null = make it a top-level tenant; root only). */
|
|
61
|
+
async move(actor, nodeUuid, newParentUuid) {
|
|
62
|
+
if (typeof nodeUuid !== 'string' || nodeUuid.length === 0) {
|
|
63
|
+
throw new identity_errors_js_1.IdentityBadRequestError('A company is required.');
|
|
64
|
+
}
|
|
65
|
+
if (newParentUuid !== null &&
|
|
66
|
+
(typeof newParentUuid !== 'string' || newParentUuid.length === 0)) {
|
|
67
|
+
throw new identity_errors_js_1.IdentityBadRequestError('parentUuid must be a company or null.');
|
|
68
|
+
}
|
|
69
|
+
if (actor.isRoot !== true) {
|
|
70
|
+
const subtree = new Set((0, grant_ceiling_js_1.subtreeOf)(actor));
|
|
71
|
+
if (!subtree.has(nodeUuid))
|
|
72
|
+
throw new identity_errors_js_1.IdentityNotFoundError('Company not found.');
|
|
73
|
+
if (nodeUuid === this.ownRoot(actor)) {
|
|
74
|
+
throw new identity_errors_js_1.IdentityForbiddenError('You cannot move the organisation your access is rooted at.');
|
|
75
|
+
}
|
|
76
|
+
if (newParentUuid === null || !subtree.has(newParentUuid)) {
|
|
77
|
+
throw new identity_errors_js_1.IdentityForbiddenError('You can only move a company under another company you manage.');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const names = await this.names(this.pool, [
|
|
81
|
+
nodeUuid,
|
|
82
|
+
...(newParentUuid ? [newParentUuid] : []),
|
|
83
|
+
]);
|
|
84
|
+
if (!names.has(nodeUuid))
|
|
85
|
+
throw new identity_errors_js_1.IdentityNotFoundError('Company not found.');
|
|
86
|
+
if (newParentUuid !== null && !names.has(newParentUuid)) {
|
|
87
|
+
throw new identity_errors_js_1.IdentityNotFoundError('Parent company not found.');
|
|
88
|
+
}
|
|
89
|
+
const oldParentRows = (await this.pool.query(`SELECT c_parent_uuid FROM \`${APP}\`.\`companies\` WHERE c_uuid = ? LIMIT 1`, [nodeUuid]));
|
|
90
|
+
const oldParent = oldParentRows[0]?.c_parent_uuid ?? null;
|
|
91
|
+
const oldParentName = oldParent
|
|
92
|
+
? (await this.names(this.pool, [oldParent])).get(oldParent)
|
|
93
|
+
: undefined;
|
|
94
|
+
await this.closure.moveNode(nodeUuid, newParentUuid, (conn) => this.recordAudit(conn, actor, 'company.moved', {
|
|
95
|
+
company: names.get(nodeUuid),
|
|
96
|
+
from: oldParent ? (oldParentName ?? '(deleted organisation)') : '(top level)',
|
|
97
|
+
to: newParentUuid ? names.get(newParentUuid) : '(top level)',
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Delete a company through `deleteNode` (refuses a node with children; closure rows + ancestor
|
|
102
|
+
* and own-node bump in one transaction). The caller has already checked reach (walled read).
|
|
103
|
+
*
|
|
104
|
+
* Phase 8 #3: the delete cascades every membership and posting of the company, stripping its
|
|
105
|
+
* members' authority there, so a non-root actor must DOMINATE every member (the actor itself is
|
|
106
|
+
* skipped). Inside the delete's transaction and tree lock, before anything is deleted: lock the
|
|
107
|
+
* company row (a new membership's FK check waits on it), read the members with a locking read
|
|
108
|
+
* (latest committed rows, no insert into the range until commit), then `assertDominates` each.
|
|
109
|
+
* Any refusal rolls the whole delete back (403). Root bypasses the dominance check.
|
|
110
|
+
*
|
|
111
|
+
* Phase 8 #3 (continued): nothing in the database ties a service account or a cross-tenant grant
|
|
112
|
+
* to its company (no FK), so, still before the company row goes and on the same transaction,
|
|
113
|
+
* {@link retireDependents} disables the company's agents and revokes their keys, removes the
|
|
114
|
+
* company from other agents' scopes, and revokes every cross-tenant grant naming it — audited on
|
|
115
|
+
* that transaction. One transaction: a failure anywhere leaves the company and everything
|
|
116
|
+
* depending on it as they were; a commit leaves a deleted company with no live agent or grant.
|
|
117
|
+
*/
|
|
118
|
+
async remove(actor, nodeUuid) {
|
|
119
|
+
// Security review F6: the same own-root guard as `move` — a sub-admin never deletes the
|
|
120
|
+
// organisation its access is rooted at.
|
|
121
|
+
if (actor.isRoot !== true && nodeUuid === this.ownRoot(actor)) {
|
|
122
|
+
throw new identity_errors_js_1.IdentityForbiddenError('You cannot delete the organisation your access is rooted at.');
|
|
123
|
+
}
|
|
124
|
+
const name = (await this.names(this.pool, [nodeUuid])).get(nodeUuid) ?? '(unknown)';
|
|
125
|
+
await this.closure.deleteNode(nodeUuid, (conn) => this.recordAudit(conn, actor, 'company.deleted', { company: name }), async (conn) => {
|
|
126
|
+
await this.assertMayDeleteMembers(conn, actor, nodeUuid);
|
|
127
|
+
return this.retireDependents(conn, actor, nodeUuid, name);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Retire what depends on a company being deleted (Phase 8 #3), on the delete's transaction after
|
|
132
|
+
* the company row lock and the member check, BEFORE the company row is deleted. Returns the
|
|
133
|
+
* tenants whose agents changed (the delete bumps them with the node and its ancestors).
|
|
134
|
+
*
|
|
135
|
+
* 1. Agents (`shield_app.service_account`) bound to the company, or whose scope names it, are
|
|
136
|
+
* read with a locking read.
|
|
137
|
+
* 2. The company's OWN agents: a non-root actor must be able to administer every one of them
|
|
138
|
+
* (`assertMayRetireServiceAccounts`, the ADR-0029 ceiling: scope and roles within its own) or
|
|
139
|
+
* the whole delete is refused (403). Then each is disabled and its keys revoked (the same
|
|
140
|
+
* `sak_revoked_at_ms` stamp `MariaDbApiKeyStore.revoke` writes, here on this transaction).
|
|
141
|
+
* 3. Other tenants' agents lose the company from their scope (reach over a deleted node only;
|
|
142
|
+
* the actor dominates that node). An agent whose ENTIRE scope was the company is disabled and
|
|
143
|
+
* its keys revoked instead — an empty scope would make the API-key path fall back to the
|
|
144
|
+
* agent's own tenant, a widening — and its scope is left as it was (a dead reference, never
|
|
145
|
+
* wider). These need no further authority check: they lose only reach over the deleted node.
|
|
146
|
+
* 4. Cross-tenant grants (`shield_authz.cross_tenant_grant`) where the company is the owning or
|
|
147
|
+
* the grantee tenant and that are not yet revoked are revoked exactly as
|
|
148
|
+
* `CrossTenantGrantWriter.revoke` does (status + `ctg_revoked_at`), and audited under both
|
|
149
|
+
* tenants. The writer itself cannot join this transaction (it writes on its own pool, and
|
|
150
|
+
* audits best-effort after the write); either side may revoke (ADR-0009), and the actor's
|
|
151
|
+
* authority over the deleted side is the delete itself.
|
|
152
|
+
* Every change is audited on this transaction (names; ids only in structured attributes).
|
|
153
|
+
*/
|
|
154
|
+
async retireDependents(conn, actor, nodeUuid, companyName) {
|
|
155
|
+
const agents = (await conn.query(`SELECT sa_id, sa_tenant_id, sa_label, sa_scope_subtree, sa_role_ids, sa_updated_at_ms
|
|
156
|
+
FROM \`${APP}\`.\`service_account\`
|
|
157
|
+
WHERE sa_tenant_id = ? OR JSON_CONTAINS(sa_scope_subtree, JSON_QUOTE(?))
|
|
158
|
+
ORDER BY sa_id
|
|
159
|
+
FOR UPDATE`, [nodeUuid, nodeUuid]));
|
|
160
|
+
const own = agents.filter((a) => a.sa_tenant_id === nodeUuid);
|
|
161
|
+
const others = agents.filter((a) => a.sa_tenant_id !== nodeUuid);
|
|
162
|
+
// Root bypasses inside; a refusal (403, names no agent) rolls the whole delete back.
|
|
163
|
+
await this.ceiling.assertMayRetireServiceAccounts(actor, own.map((a) => ({
|
|
164
|
+
tenantId: a.sa_tenant_id,
|
|
165
|
+
scopeSubtreeNodeIds: jsonIdArray(a.sa_scope_subtree),
|
|
166
|
+
roleIds: jsonIdArray(a.sa_role_ids),
|
|
167
|
+
})));
|
|
168
|
+
const nowMs = this.now();
|
|
169
|
+
const touched = new Set();
|
|
170
|
+
const editStamp = (a) => Math.max(nowMs, (a.sa_updated_at_ms == null ? 0 : Number(a.sa_updated_at_ms)) + 1);
|
|
171
|
+
const retire = async (a, reason) => {
|
|
172
|
+
await conn.query(`UPDATE \`${APP}\`.\`service_account\` SET sa_status = 'disabled', sa_updated_at_ms = ? WHERE sa_id = ?`, [editStamp(a), a.sa_id]);
|
|
173
|
+
const res = (await conn.query(`UPDATE \`${AUTHZ}\`.\`service_account_key\`
|
|
174
|
+
SET sak_revoked_at_ms = ?
|
|
175
|
+
WHERE sak_account_id = ? AND sak_revoked_at_ms IS NULL`, [nowMs, a.sa_id]));
|
|
176
|
+
touched.add(a.sa_tenant_id);
|
|
177
|
+
await this.recordAudit(conn, actor, 'service_account.disabled', {
|
|
178
|
+
eventType: 'service_account',
|
|
179
|
+
accountId: a.sa_id,
|
|
180
|
+
account: a.sa_label,
|
|
181
|
+
company: companyName,
|
|
182
|
+
reason,
|
|
183
|
+
keysRevoked: Number(res?.affectedRows ?? 0),
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
for (const a of own)
|
|
187
|
+
await retire(a, 'company_deleted');
|
|
188
|
+
for (const a of others) {
|
|
189
|
+
const remaining = jsonIdArray(a.sa_scope_subtree).filter((n) => n !== nodeUuid);
|
|
190
|
+
if (remaining.length === 0) {
|
|
191
|
+
await retire(a, 'scope_company_deleted');
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
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]);
|
|
195
|
+
touched.add(a.sa_tenant_id);
|
|
196
|
+
await this.recordAudit(conn, actor, 'service_account.updated', {
|
|
197
|
+
eventType: 'service_account',
|
|
198
|
+
accountId: a.sa_id,
|
|
199
|
+
account: a.sa_label,
|
|
200
|
+
fields: ['scopeSubtreeNodeIds'],
|
|
201
|
+
scopeRemoved: [companyName],
|
|
202
|
+
reason: 'company_deleted',
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
await this.revokeGrantsNaming(conn, actor, nodeUuid, companyName);
|
|
206
|
+
return [...touched];
|
|
207
|
+
}
|
|
208
|
+
/** Step 4 of {@link retireDependents}: revoke and audit the grants naming the company. */
|
|
209
|
+
async revokeGrantsNaming(conn, actor, nodeUuid, companyName) {
|
|
210
|
+
const grants = (await conn.query(`SELECT ctg_uuid, ctg_owning_tenant_uuid, ctg_grantee_tenant_uuid, ctg_grantee_kind,
|
|
211
|
+
ctg_grantee_uuid, ctg_type_key, ctg_scope, ctg_allowed_actions
|
|
212
|
+
FROM \`${AUTHZ}\`.\`cross_tenant_grant\`
|
|
213
|
+
WHERE (ctg_owning_tenant_uuid = ? OR ctg_grantee_tenant_uuid = ?)
|
|
214
|
+
AND ctg_status <> 'revoked'
|
|
215
|
+
ORDER BY ctg_uuid
|
|
216
|
+
FOR UPDATE`, [nodeUuid, nodeUuid]));
|
|
217
|
+
if (grants.length === 0)
|
|
218
|
+
return;
|
|
219
|
+
const revokedAt = new Date(this.now());
|
|
220
|
+
const otherSides = grants.map((g) => g.ctg_owning_tenant_uuid === nodeUuid ? g.ctg_grantee_tenant_uuid : g.ctg_owning_tenant_uuid);
|
|
221
|
+
const names = await this.names(conn, otherSides);
|
|
222
|
+
for (const [i, g] of grants.entries()) {
|
|
223
|
+
await conn.query(`UPDATE \`${AUTHZ}\`.\`cross_tenant_grant\`
|
|
224
|
+
SET ctg_status = 'revoked', ctg_revoked_at = ?
|
|
225
|
+
WHERE ctg_uuid = ? AND ctg_status <> 'revoked'`, [revokedAt, g.ctg_uuid]);
|
|
226
|
+
const other = otherSides[i];
|
|
227
|
+
const attributes = {
|
|
228
|
+
eventType: 'cross_tenant_grant',
|
|
229
|
+
grantUuid: g.ctg_uuid,
|
|
230
|
+
typeKey: g.ctg_type_key,
|
|
231
|
+
scope: g.ctg_scope === 'record' ? 'record' : 'object_type',
|
|
232
|
+
granteeKind: g.ctg_grantee_kind === 'role' ? 'role' : 'user',
|
|
233
|
+
granteeUuid: g.ctg_grantee_uuid,
|
|
234
|
+
allowedActions: jsonIdArray(g.ctg_allowed_actions),
|
|
235
|
+
owningTenantId: g.ctg_owning_tenant_uuid,
|
|
236
|
+
granteeTenantId: g.ctg_grantee_tenant_uuid,
|
|
237
|
+
reason: 'company_deleted',
|
|
238
|
+
company: companyName,
|
|
239
|
+
otherCompany: names.get(other) ?? '(deleted organisation)',
|
|
240
|
+
};
|
|
241
|
+
// Audit on BOTH tenants (ADR-0009), one row each, as the writer's audit bridge files it.
|
|
242
|
+
for (const tenantId of new Set([g.ctg_owning_tenant_uuid, g.ctg_grantee_tenant_uuid])) {
|
|
243
|
+
await this.recordAudit(conn, actor, 'grant.revoked', attributes, tenantId);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/** Lock the company, then require dominance over each of its members (see `remove`). */
|
|
248
|
+
async assertMayDeleteMembers(conn, actor, nodeUuid) {
|
|
249
|
+
await conn.query(`SELECT c_uuid FROM \`${APP}\`.\`companies\` WHERE c_uuid = ? FOR UPDATE`, [
|
|
250
|
+
nodeUuid,
|
|
251
|
+
]);
|
|
252
|
+
if (actor.isRoot === true)
|
|
253
|
+
return;
|
|
254
|
+
const rows = (await conn.query(`SELECT cm_user_uuid AS member FROM \`${APP}\`.\`company_membership\`
|
|
255
|
+
WHERE cm_company_uuid = ? FOR UPDATE`, [nodeUuid]));
|
|
256
|
+
const members = [...new Set(rows.map((r) => r.member))].sort();
|
|
257
|
+
for (const member of members) {
|
|
258
|
+
if (member === actor.id)
|
|
259
|
+
continue; // leaving your own membership behind stays allowed
|
|
260
|
+
try {
|
|
261
|
+
await this.ceiling.assertDominates(actor, member);
|
|
262
|
+
}
|
|
263
|
+
catch (cause) {
|
|
264
|
+
if (cause instanceof identity_errors_js_1.IdentityForbiddenError || cause instanceof identity_errors_js_1.IdentityNotFoundError) {
|
|
265
|
+
// Names no member (who belongs to the company is not the caller's to learn from a refusal).
|
|
266
|
+
throw new identity_errors_js_1.IdentityForbiddenError('You cannot delete this company: one of its members is outside your authority.');
|
|
267
|
+
}
|
|
268
|
+
throw cause;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** A company was just created: give it closure rows now and bump its ancestors (D5). */
|
|
273
|
+
async attached(nodeUuid) {
|
|
274
|
+
await this.closure.attachNode(nodeUuid);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
exports.CompanyHierarchyService = CompanyHierarchyService;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CompanyService } from './company.service.js';
|
|
2
2
|
import { type ListQueryDto } from '../shared/list-query.js';
|
|
3
|
+
import type { AuthedRequest } from '../shared/http.js';
|
|
3
4
|
export declare class CompanyController {
|
|
4
5
|
private readonly companies;
|
|
5
6
|
constructor(companies: CompanyService);
|
|
@@ -14,23 +15,40 @@ export declare class CompanyController {
|
|
|
14
15
|
page: number;
|
|
15
16
|
limit: number;
|
|
16
17
|
}>;
|
|
17
|
-
/**
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Create a company — gated on `identity:company:create`. A non-root caller's company is created
|
|
20
|
+
* UNDER the caller's own organisation (D5); only root creates a top-level tenant.
|
|
21
|
+
*/
|
|
22
|
+
createCompany(req: AuthedRequest, body: {
|
|
19
23
|
name?: string;
|
|
20
24
|
}): Promise<{
|
|
21
25
|
id: import("packages/engine/dist/types.js").RecordId;
|
|
22
26
|
}>;
|
|
23
27
|
/** Read one company — gated on `identity:company:read`. */
|
|
24
28
|
getCompany(uuid: string): Promise<import("packages/engine/dist/types.js").RecordView | null>;
|
|
25
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Move a company under a new parent — gated on `identity:company:update`. Body
|
|
31
|
+
* `{ parentUuid: string | null }`. Non-root: the company and the new parent must both be in the
|
|
32
|
+
* caller's subtree, the company is not the caller's own root, and the parent is not null. A move
|
|
33
|
+
* that would form a cycle → 409. Declared BEFORE `companies/:uuid` (static sub-path first).
|
|
34
|
+
*/
|
|
35
|
+
moveCompany(req: AuthedRequest, uuid: string, body: {
|
|
36
|
+
parentUuid?: string | null;
|
|
37
|
+
}): Promise<{
|
|
38
|
+
success: boolean;
|
|
39
|
+
}>;
|
|
40
|
+
/**
|
|
41
|
+
* Update a company's name/status — gated on `identity:company:update`. Any other key
|
|
42
|
+
* (parentUuid, type, …) is ignored: use `PUT companies/:uuid/parent` to move a company.
|
|
43
|
+
*/
|
|
26
44
|
updateCompany(uuid: string, body: {
|
|
27
45
|
name?: string;
|
|
28
46
|
status?: string;
|
|
29
47
|
}): Promise<{
|
|
30
48
|
success: boolean;
|
|
31
49
|
}>;
|
|
32
|
-
/** Delete a company — gated on `identity:company:delete`. */
|
|
33
|
-
deleteCompany(uuid: string): Promise<{
|
|
50
|
+
/** Delete a company — gated on `identity:company:delete`. A company with sub-companies → 409. */
|
|
51
|
+
deleteCompany(req: AuthedRequest, uuid: string): Promise<{
|
|
34
52
|
success: boolean;
|
|
35
53
|
}>;
|
|
36
54
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"company.controller.d.ts","sourceRoot":"","sources":["../../../src/features/company/company.controller.ts"],"names":[],"mappings":"
|
|
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,CACF,IAAI,EAAE,MAAM,EACnB,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE;;;IAMlD,iGAAiG;IAG3F,aAAa,CAAQ,GAAG,EAAE,aAAa,EAAiB,IAAI,EAAE,MAAM;;;CAI3E"}
|