@astram/identity 0.5.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/features/authz/grant-admin.service.d.ts +24 -1
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.service.js +88 -1
- package/dist/features/authz/grant-ceiling.d.ts +49 -1
- package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
- package/dist/features/authz/grant-ceiling.js +118 -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 +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/company/company-hierarchy.service.d.ts +65 -3
- package/dist/features/company/company-hierarchy.service.d.ts.map +1 -1
- package/dist/features/company/company-hierarchy.service.js +183 -6
- 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 +19 -9
- 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/role/role.service.d.ts +25 -2
- package/dist/features/role/role.service.d.ts.map +1 -1
- package/dist/features/role/role.service.js +57 -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/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/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/package.json +14 -3
|
@@ -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,12 +25,28 @@ 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
|
+
}
|
|
25
40
|
export declare class CompanyHierarchyService {
|
|
26
41
|
private readonly pool;
|
|
27
42
|
private readonly closure;
|
|
43
|
+
/** Phase 8 #3: required — a company delete must dominate every member (no silent skip). */
|
|
44
|
+
private readonly ceiling;
|
|
28
45
|
private readonly audit?;
|
|
29
46
|
private readonly now;
|
|
30
|
-
constructor(pool: HierarchyQueryable, closure: ScopeClosureService,
|
|
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);
|
|
31
50
|
/** The actor's own root node (the active node its subtree hangs from). */
|
|
32
51
|
private ownRoot;
|
|
33
52
|
/** Names of the given companies (unknown → absent). */
|
|
@@ -37,9 +56,52 @@ export declare class CompanyHierarchyService {
|
|
|
37
56
|
move(actor: Principal, nodeUuid: string, newParentUuid: string | null): Promise<void>;
|
|
38
57
|
/**
|
|
39
58
|
* 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).
|
|
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.
|
|
41
74
|
*/
|
|
42
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;
|
|
43
105
|
/** A company was just created: give it closure rows now and bump its ancestors (D5). */
|
|
44
106
|
attached(nodeUuid: string): Promise<void>;
|
|
45
107
|
}
|
|
@@ -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;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"}
|
|
@@ -4,14 +4,32 @@ exports.CompanyHierarchyService = void 0;
|
|
|
4
4
|
const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
|
|
5
5
|
const identity_errors_js_1 = require("../shared/identity-errors.js");
|
|
6
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
|
+
}
|
|
7
21
|
class CompanyHierarchyService {
|
|
8
22
|
pool;
|
|
9
23
|
closure;
|
|
24
|
+
ceiling;
|
|
10
25
|
audit;
|
|
11
26
|
now;
|
|
12
|
-
constructor(pool, closure,
|
|
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) {
|
|
13
30
|
this.pool = pool;
|
|
14
31
|
this.closure = closure;
|
|
32
|
+
this.ceiling = ceiling;
|
|
15
33
|
this.audit = audit;
|
|
16
34
|
this.now = now;
|
|
17
35
|
}
|
|
@@ -27,14 +45,14 @@ class CompanyHierarchyService {
|
|
|
27
45
|
const rows = (await q.query(`SELECT c_uuid, c_name FROM \`${APP}\`.\`companies\` WHERE c_uuid IN (${unique.map(() => '?').join(', ')})`, unique));
|
|
28
46
|
return new Map(rows.map((r) => [r.c_uuid, r.c_name]));
|
|
29
47
|
}
|
|
30
|
-
async recordAudit(conn, actor, action, attributes) {
|
|
48
|
+
async recordAudit(conn, actor, action, attributes, tenantId = actor.tenantId) {
|
|
31
49
|
if (!this.audit)
|
|
32
50
|
return;
|
|
33
51
|
await this.audit.record({
|
|
34
52
|
type: 'admin_action',
|
|
35
53
|
occurredAtMs: this.now(),
|
|
36
54
|
actorId: actor.id,
|
|
37
|
-
tenantId
|
|
55
|
+
tenantId,
|
|
38
56
|
action: { key: action },
|
|
39
57
|
attributes: { eventType: 'company_hierarchy', ...attributes },
|
|
40
58
|
}, (sql, params) => conn.query(sql, params));
|
|
@@ -81,7 +99,21 @@ class CompanyHierarchyService {
|
|
|
81
99
|
}
|
|
82
100
|
/**
|
|
83
101
|
* 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).
|
|
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.
|
|
85
117
|
*/
|
|
86
118
|
async remove(actor, nodeUuid) {
|
|
87
119
|
// Security review F6: the same own-root guard as `move` — a sub-admin never deletes the
|
|
@@ -89,8 +121,153 @@ class CompanyHierarchyService {
|
|
|
89
121
|
if (actor.isRoot !== true && nodeUuid === this.ownRoot(actor)) {
|
|
90
122
|
throw new identity_errors_js_1.IdentityForbiddenError('You cannot delete the organisation your access is rooted at.');
|
|
91
123
|
}
|
|
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
|
|
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
|
+
}
|
|
94
271
|
}
|
|
95
272
|
/** A company was just created: give it closure rows now and bump its ancestors (D5). */
|
|
96
273
|
async attached(nodeUuid) {
|
|
@@ -25,6 +25,8 @@ export declare class IdentityFeatureModule {
|
|
|
25
25
|
* the framework identity types, so a consumer's own OWNED type (e.g. `note`) is NOT
|
|
26
26
|
* policy-authorable and NOT shareable ("type '…' is not registered — cannot share").
|
|
27
27
|
* Passing them here lets an owner share their own records + author policies over them.
|
|
28
|
+
* Phase 8 #4: if the action keys of these objects differ from the ones given to
|
|
29
|
+
* `buildIdentityRecordAuthorization`, the boot fails with `IdentityRegistryMismatchError`.
|
|
28
30
|
*/
|
|
29
31
|
static forRoot({ rootIds, extraObjects, }?: {
|
|
30
32
|
rootIds?: readonly string[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"identity-feature.module.d.ts","sourceRoot":"","sources":["../../src/features/identity-feature.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpE,OAAO,EAYL,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"identity-feature.module.d.ts","sourceRoot":"","sources":["../../src/features/identity-feature.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpE,OAAO,EAYL,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AA8FxB;;;;;;;;;;GAUG;AACH,qBACa,qBAAqB;IAChC;;;;;;;;;;;;;;;OAeG;IACH,MAAM,CAAC,OAAO,CAAC,EACb,OAAO,EACP,YAAiB,GAClB,GAAE;QACD,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;QAC5B,YAAY,CAAC,EAAE,SAAS,eAAe,EAAE,CAAC;KACtC,GAAG,aAAa;CA8cvB"}
|
|
@@ -97,8 +97,18 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
97
97
|
* the framework identity types, so a consumer's own OWNED type (e.g. `note`) is NOT
|
|
98
98
|
* policy-authorable and NOT shareable ("type '…' is not registered — cannot share").
|
|
99
99
|
* Passing them here lets an owner share their own records + author policies over them.
|
|
100
|
+
* Phase 8 #4: if the action keys of these objects differ from the ones given to
|
|
101
|
+
* `buildIdentityRecordAuthorization`, the boot fails with `IdentityRegistryMismatchError`.
|
|
100
102
|
*/
|
|
101
103
|
static forRoot({ rootIds, extraObjects = [], } = {}) {
|
|
104
|
+
// The ceiling's key universe includes the consumer's `extraObjects` (the SAME registry shape
|
|
105
|
+
// `buildIdentityRecordAuthorization(pool, extraObjects)` gives the decision path): otherwise a
|
|
106
|
+
// role holding a consumer-type key could never be assigned or granted by a non-root admin who
|
|
107
|
+
// holds that key (review M5) — the security-hardening role-assignment ceiling makes that
|
|
108
|
+
// reachable from every posting edit. Phase 8 #4: the two registries must have the SAME action
|
|
109
|
+
// keys — checked here, before anything is opened; a mismatch fails the boot.
|
|
110
|
+
const ceilingRegistry = (0, guard_1.buildIdentityModuleRegistry)([...objects_js_1.identityObjects, ...extraObjects]);
|
|
111
|
+
(0, record_authorization_js_1.noteIdentityCeilingRegistry)(ceilingRegistry);
|
|
102
112
|
// ONE shared shield_authz pool for the whole grant-admin stack (ADR-0011): the
|
|
103
113
|
// permission-version bumper, the AccessBundleStore, the GrantAdminService, and the two
|
|
104
114
|
// reconcile jobs all run over this single pool (don't open several). Opened once at
|
|
@@ -109,6 +119,8 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
109
119
|
bumpTenant: (tenantUuid) => bumper.bumpForTenant(tenantUuid, authzPool),
|
|
110
120
|
// A user disabled/re-enabled: every membership tenant (autocommit pool → published at once).
|
|
111
121
|
bumpForUser: (userUuid) => bumper.bumpForUser(userUuid, authzPool),
|
|
122
|
+
// A role delete (Phase 8 #2): its holder tenants, read before the delete cascades postings.
|
|
123
|
+
tenantsForRole: (roleUuid) => bumper.tenantsForRole(roleUuid, authzPool),
|
|
112
124
|
};
|
|
113
125
|
// The core ModuleRegistry the catalog reconcile job needs, built from the identity
|
|
114
126
|
// object registrations (guard's bridge reflects decorated classes → registry). Built BEFORE
|
|
@@ -120,12 +132,8 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
120
132
|
// resolved over the SAME authz pool + registry the object-type gate uses (one source of
|
|
121
133
|
// truth for "what a principal holds"). ALWAYS wired here, so production always enforces.
|
|
122
134
|
const accessBundleStore = new access_bundle_store_js_1.AccessBundleStore(authzPool);
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
// role holding a consumer-type key could never be assigned or granted by a non-root admin who
|
|
126
|
-
// holds that key (review M5) — the security-hardening role-assignment ceiling makes that
|
|
127
|
-
// reachable from every posting edit. `rootIds` marks platform roots as never dominated.
|
|
128
|
-
const ceilingRegistry = (0, guard_1.buildIdentityModuleRegistry)([...objects_js_1.identityObjects, ...extraObjects]);
|
|
135
|
+
// (`ceilingRegistry` is built and parity-checked at the top of forRoot.) `rootIds` marks
|
|
136
|
+
// platform roots as never dominated.
|
|
129
137
|
// rootIds is passed EXPLICITLY (review F5): `null` states "not configured". The dominance
|
|
130
138
|
// check still refuses any user flagged `u_is_platform_superuser`, so an unconfigured list
|
|
131
139
|
// never lets a sub-admin take over a platform administrator.
|
|
@@ -146,7 +154,8 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
146
154
|
const scopeClosureService = new scope_closure_service_js_1.ScopeClosureService(authzPool, bumper);
|
|
147
155
|
// Company hierarchy (security hardening #2): the ONLY path that moves/deletes/attaches a company
|
|
148
156
|
// node — through the closure service above — audited on the move/delete transaction.
|
|
149
|
-
const companyHierarchyService = new company_hierarchy_service_js_1.CompanyHierarchyService(authzPool, scopeClosureService,
|
|
157
|
+
const companyHierarchyService = new company_hierarchy_service_js_1.CompanyHierarchyService(authzPool, scopeClosureService, grantCeiling, // Phase 8 #3: a company delete must dominate every member
|
|
158
|
+
new engine_1.MariaDbAuditSink({}));
|
|
150
159
|
// Audit viewer (Phase 5): the AuditReader + LabelResolver over the SAME shared authzPool
|
|
151
160
|
// (the reader queries shield_audit + the resolver/actor-lookup query shield_app, both
|
|
152
161
|
// reachable by that pool). `auditNameToId` is the actor-name -> uuid reverse lookup for the
|
|
@@ -165,7 +174,8 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
165
174
|
// authz.module, the anti-pattern this replaces). A SECOND DecisionAuthority is built over the
|
|
166
175
|
// same record-authorization inputs (matches astram3): the route guard's own decide() is the
|
|
167
176
|
// first; this one backs the writer's server-side re-verification.
|
|
168
|
-
|
|
177
|
+
// (The non-recording builder: these internal authorities are not the consumer's decision path.)
|
|
178
|
+
const crossTenantAuthority = (0, guard_1.buildRecordAuthorization)((0, record_authorization_js_1.buildRecordAuthorizationInputs)(authzPool));
|
|
169
179
|
// Record-level LIST SCOPE (Task 3.1, Invariant #8): the engine-facing fn the engine's
|
|
170
180
|
// `list()` ANDs into BOTH the data SELECT and the COUNT — the in-query visible-set filter.
|
|
171
181
|
// Built HERE, over the SAME shared authzPool (guard constructs the un-overridable scope
|
|
@@ -173,7 +183,7 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
173
183
|
// out-of-the-box into `EngineModule.forRoot({ listScope })` below (ADR-0003 injection seam —
|
|
174
184
|
// the engine never imports guard). Fail-closed: a store outage propagates and `list()` fails
|
|
175
185
|
// rather than returning unscoped rows.
|
|
176
|
-
const listScope = (0, guard_1.buildListScopeFn)((0, record_authorization_js_1.
|
|
186
|
+
const listScope = (0, guard_1.buildListScopeFn)((0, record_authorization_js_1.buildRecordAuthorizationInputs)(authzPool));
|
|
177
187
|
// The writer's `GrantAuthorizeFn`: decide()-backed, fail-closed (no try/catch — a throw
|
|
178
188
|
// propagates and the writer maps it to a retryable 503). isRoot ← the SAME rootIds the
|
|
179
189
|
// consumer passes to ShieldModule.forRoot (root-correctness for control-plane grants).
|
|
@@ -10,6 +10,13 @@ export interface PostingVersionBump {
|
|
|
10
10
|
* the account itself was disabled or re-enabled. Returns the bumped tenants; throws on failure.
|
|
11
11
|
*/
|
|
12
12
|
bumpForUser?(userUuid: string): Promise<string[]>;
|
|
13
|
+
/**
|
|
14
|
+
* OPTIONAL (Phase 8 #2): the tenants whose cached grants change when the role goes away — every
|
|
15
|
+
* tenant where it is actively posted, plus every service account's tenant holding it. Read
|
|
16
|
+
* BEFORE a role delete (the delete cascades the postings); bump each with `bumpTenant` after the
|
|
17
|
+
* commit. Throws on failure.
|
|
18
|
+
*/
|
|
19
|
+
tenantsForRole?(roleUuid: string): Promise<string[]>;
|
|
13
20
|
}
|
|
14
21
|
export declare const POSTING_VERSION_BUMP: unique symbol;
|
|
15
22
|
//# sourceMappingURL=posting-version-bump.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"posting-version-bump.d.ts","sourceRoot":"","sources":["../../../src/features/membership/posting-version-bump.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C;;;OAGG;IACH,WAAW,CAAC,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"posting-version-bump.d.ts","sourceRoot":"","sources":["../../../src/features/membership/posting-version-bump.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C;;;OAGG;IACH,WAAW,CAAC,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAClD;;;;;OAKG;IACH,cAAc,CAAC,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CACtD;AAED,eAAO,MAAM,oBAAoB,eAAiC,CAAC"}
|
|
@@ -2,6 +2,8 @@ import { RecordsService, type RecordId } from '@astram/engine';
|
|
|
2
2
|
import type { Principal } from '@astram/core';
|
|
3
3
|
import { type ListParams } from '../shared/list-query.js';
|
|
4
4
|
import { GrantCeiling } from '../authz/grant-ceiling.js';
|
|
5
|
+
import { GrantAdminService } from '../authz/grant-admin.service.js';
|
|
6
|
+
import { type PostingVersionBump } from '../membership/posting-version-bump.js';
|
|
5
7
|
/**
|
|
6
8
|
* RoleService — role CRUD on the paved road, via the framework's RecordsService
|
|
7
9
|
* facade. The facade supplies the acting identity (the request principal) for the pipe;
|
|
@@ -15,7 +17,9 @@ import { GrantCeiling } from '../authz/grant-ceiling.js';
|
|
|
15
17
|
export declare class RoleService {
|
|
16
18
|
private readonly records;
|
|
17
19
|
private readonly ceiling;
|
|
18
|
-
|
|
20
|
+
private readonly versionBump?;
|
|
21
|
+
private readonly grants?;
|
|
22
|
+
constructor(records: RecordsService, ceiling: GrantCeiling, versionBump?: (PostingVersionBump | null) | undefined, grants?: (GrantAdminService | null) | undefined);
|
|
19
23
|
/** Create a role. */
|
|
20
24
|
createRole(name: string): Promise<RecordId>;
|
|
21
25
|
/** List roles (tenant-scoped by the pipe), with paging/search/sort. */
|
|
@@ -26,7 +30,26 @@ export declare class RoleService {
|
|
|
26
30
|
updateRole(actor: Principal, uuid: string, patch: {
|
|
27
31
|
name?: unknown;
|
|
28
32
|
}): Promise<void>;
|
|
29
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Delete a role — only a role the actor may administer (binding rule) AND whose every holder the
|
|
35
|
+
* actor dominates (Phase 8 #2: the delete cascades the holders' postings, stripping their
|
|
36
|
+
* authority). In ONE transaction: lock the role row (the posting and grant paths lock the same
|
|
37
|
+
* row first), re-check the binding rule and holder dominance against what is committed now, read
|
|
38
|
+
* the holder tenants (before the cascade removes the postings), then delete. After the commit,
|
|
39
|
+
* every holder tenant's permission version is bumped so cached roles drop at the next request.
|
|
40
|
+
* The bump runs after the commit, never before: an earlier bump would let a session reload the
|
|
41
|
+
* pre-delete roles under the new version. A bump failure throws (503) rather than leave holders'
|
|
42
|
+
* cached grants silently stale.
|
|
43
|
+
*
|
|
44
|
+
* The role's `role_permission` rows (shield_authz, no FK) and its place in every service
|
|
45
|
+
* account's `sa_role_ids` (read live by the API-key store) do not cascade, so before the delete,
|
|
46
|
+
* still under the role lock, `GrantAdminService.purgeRole` removes both in its own committed
|
|
47
|
+
* authz transaction — checking first that the actor may change each holding account, and
|
|
48
|
+
* bumping every tenant that used the role (account tenants included). Order is fail-closed: a
|
|
49
|
+
* failure after the purge leaves a role with no grants and no agents, never live grants or an
|
|
50
|
+
* agent carrying a deleted role. A re-created role gets a fresh uuid (the engine mints it), so
|
|
51
|
+
* it can never pick up anything left behind.
|
|
52
|
+
*/
|
|
30
53
|
deleteRole(actor: Principal, uuid: string): Promise<void>;
|
|
31
54
|
}
|
|
32
55
|
//# sourceMappingURL=role.service.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"role.service.d.ts","sourceRoot":"","sources":["../../../src/features/role/role.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"role.service.d.ts","sourceRoot":"","sources":["../../../src/features/role/role.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,OAAO,EAAkB,KAAK,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1E,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iCAAiC,CAAC;AACpE,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,uCAAuC,CAAC;AAK/C;;;;;;;;;GASG;AACH,qBACa,WAAW;IAEpB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAKxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAK7B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;gBAXP,OAAO,EAAE,cAAc,EACvB,OAAO,EAAE,YAAY,EAKrB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA,EAKvC,MAAM,CAAC,GAAE,iBAAiB,GAAG,IAAI,aAAA;IAGpD,qBAAqB;IACrB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAI3C,uEAAuE;IACvE,SAAS,CAAC,MAAM,CAAC,EAAE,UAAU;IAI7B,0BAA0B;IAC1B,OAAO,CAAC,IAAI,EAAE,MAAM;IAIpB,8FAA8F;IACxF,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE;QAAE,IAAI,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAO1F;;;;;;;;;;;;;;;;;;;OAmBG;IACG,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAoBhE"}
|
|
@@ -8,12 +8,18 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
8
8
|
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
9
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
10
|
};
|
|
11
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
11
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
15
|
exports.RoleService = void 0;
|
|
13
16
|
const common_1 = require("@nestjs/common");
|
|
14
17
|
const engine_1 = require("@astram/engine");
|
|
18
|
+
const core_1 = require("@astram/core");
|
|
15
19
|
const list_query_js_1 = require("../shared/list-query.js");
|
|
16
20
|
const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
|
|
21
|
+
const grant_admin_service_js_1 = require("../authz/grant-admin.service.js");
|
|
22
|
+
const posting_version_bump_js_1 = require("../membership/posting-version-bump.js");
|
|
17
23
|
const ROLE_SEARCH_FIELDS = ['name'];
|
|
18
24
|
const ROLE_SORT_FIELDS = ['name', 'createdAt'];
|
|
19
25
|
/**
|
|
@@ -29,9 +35,13 @@ const ROLE_SORT_FIELDS = ['name', 'createdAt'];
|
|
|
29
35
|
let RoleService = class RoleService {
|
|
30
36
|
records;
|
|
31
37
|
ceiling;
|
|
32
|
-
|
|
38
|
+
versionBump;
|
|
39
|
+
grants;
|
|
40
|
+
constructor(records, ceiling, versionBump, grants) {
|
|
33
41
|
this.records = records;
|
|
34
42
|
this.ceiling = ceiling;
|
|
43
|
+
this.versionBump = versionBump;
|
|
44
|
+
this.grants = grants;
|
|
35
45
|
}
|
|
36
46
|
/** Create a role. */
|
|
37
47
|
createRole(name) {
|
|
@@ -53,15 +63,58 @@ let RoleService = class RoleService {
|
|
|
53
63
|
fields.name = patch.name;
|
|
54
64
|
await this.records.update('role', uuid, { fields });
|
|
55
65
|
}
|
|
56
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Delete a role — only a role the actor may administer (binding rule) AND whose every holder the
|
|
68
|
+
* actor dominates (Phase 8 #2: the delete cascades the holders' postings, stripping their
|
|
69
|
+
* authority). In ONE transaction: lock the role row (the posting and grant paths lock the same
|
|
70
|
+
* row first), re-check the binding rule and holder dominance against what is committed now, read
|
|
71
|
+
* the holder tenants (before the cascade removes the postings), then delete. After the commit,
|
|
72
|
+
* every holder tenant's permission version is bumped so cached roles drop at the next request.
|
|
73
|
+
* The bump runs after the commit, never before: an earlier bump would let a session reload the
|
|
74
|
+
* pre-delete roles under the new version. A bump failure throws (503) rather than leave holders'
|
|
75
|
+
* cached grants silently stale.
|
|
76
|
+
*
|
|
77
|
+
* The role's `role_permission` rows (shield_authz, no FK) and its place in every service
|
|
78
|
+
* account's `sa_role_ids` (read live by the API-key store) do not cascade, so before the delete,
|
|
79
|
+
* still under the role lock, `GrantAdminService.purgeRole` removes both in its own committed
|
|
80
|
+
* authz transaction — checking first that the actor may change each holding account, and
|
|
81
|
+
* bumping every tenant that used the role (account tenants included). Order is fail-closed: a
|
|
82
|
+
* failure after the purge leaves a role with no grants and no agents, never live grants or an
|
|
83
|
+
* agent carrying a deleted role. A re-created role gets a fresh uuid (the engine mints it), so
|
|
84
|
+
* it can never pick up anything left behind.
|
|
85
|
+
*/
|
|
57
86
|
async deleteRole(actor, uuid) {
|
|
58
87
|
await this.ceiling.assertRoleAdministrable(actor, uuid);
|
|
59
|
-
|
|
88
|
+
const grants = this.grants;
|
|
89
|
+
if (!grants)
|
|
90
|
+
throw new core_1.AuthorizationUnavailableError('role grant purge is not configured');
|
|
91
|
+
const tenants = await this.records.transaction(async (tx) => {
|
|
92
|
+
await tx.lockForUpdate('role', uuid);
|
|
93
|
+
await this.ceiling.assertRoleAdministrable(actor, uuid);
|
|
94
|
+
await this.ceiling.assertDominatesRoleHolders(actor, uuid);
|
|
95
|
+
const affected = (await this.versionBump?.tenantsForRole?.(uuid)) ?? [];
|
|
96
|
+
await grants.purgeRole(uuid, actor);
|
|
97
|
+
await tx.delete('role', uuid);
|
|
98
|
+
return affected;
|
|
99
|
+
});
|
|
100
|
+
if (!this.versionBump)
|
|
101
|
+
return;
|
|
102
|
+
try {
|
|
103
|
+
for (const tenant of tenants)
|
|
104
|
+
await this.versionBump.bumpTenant(tenant);
|
|
105
|
+
}
|
|
106
|
+
catch (cause) {
|
|
107
|
+
throw new core_1.AuthorizationUnavailableError('permission version bump failed', cause);
|
|
108
|
+
}
|
|
60
109
|
}
|
|
61
110
|
};
|
|
62
111
|
exports.RoleService = RoleService;
|
|
63
112
|
exports.RoleService = RoleService = __decorate([
|
|
64
113
|
(0, common_1.Injectable)(),
|
|
114
|
+
__param(2, (0, common_1.Optional)()),
|
|
115
|
+
__param(2, (0, common_1.Inject)(posting_version_bump_js_1.POSTING_VERSION_BUMP)),
|
|
116
|
+
__param(3, (0, common_1.Optional)()),
|
|
117
|
+
__param(3, (0, common_1.Inject)(grant_admin_service_js_1.GrantAdminService)),
|
|
65
118
|
__metadata("design:paramtypes", [engine_1.RecordsService,
|
|
66
|
-
grant_ceiling_js_1.GrantCeiling])
|
|
119
|
+
grant_ceiling_js_1.GrantCeiling, Object, Object])
|
|
67
120
|
], RoleService);
|
|
@@ -88,6 +88,10 @@ export declare class ScopeClosureService {
|
|
|
88
88
|
* subtree, so those admins' cached session subtrees are stale (they'd keep the deleted node in
|
|
89
89
|
* their set) — bump them so they re-derive at the next refresh (same discipline as moveNode's
|
|
90
90
|
* former-parent bump; without it, a parent admin's list/decide would reference a gone node).
|
|
91
|
+
* Also bumps the deleted node's own tenant (Phase 8 #3). `beforeDelete` runs first, on the same
|
|
92
|
+
* transaction, so a caller can lock the node and refuse (rollback) before anything changes; it
|
|
93
|
+
* may also retire rows that depend on the node and return the tenants those changes touched,
|
|
94
|
+
* which are bumped with the rest.
|
|
91
95
|
*
|
|
92
96
|
* v1 SCOPE NOTE: this refuses a node with CHILDREN. Broader resource-orphan cleanup (rows in
|
|
93
97
|
* the many tenant-scoped app tables that reference the deleted node) is out of scope for v1 and
|
|
@@ -95,7 +99,13 @@ export declare class ScopeClosureService {
|
|
|
95
99
|
*/
|
|
96
100
|
deleteNode(nodeUuid: string,
|
|
97
101
|
/** Runs on the delete's transaction after the bump (e.g. the audit row) — a throw rolls back. */
|
|
98
|
-
inTransaction?: (conn: ScopeConn) => Promise<void
|
|
102
|
+
inTransaction?: (conn: ScopeConn) => Promise<void>,
|
|
103
|
+
/**
|
|
104
|
+
* Runs on the delete's transaction BEFORE anything is deleted (Phase 8 #3: lock the node, check
|
|
105
|
+
* authority over its members and agents, retire its agents and cross-tenant grants) — a throw
|
|
106
|
+
* rolls back and nothing is deleted or bumped. Returned tenant ids are bumped too.
|
|
107
|
+
*/
|
|
108
|
+
beforeDelete?: (conn: ScopeConn) => Promise<void | readonly string[]>): Promise<void>;
|
|
99
109
|
/** Read the node forest's parent edges (`c_uuid` → `c_parent_uuid`) as `ScopeEdge[]`. */
|
|
100
110
|
private readEdges;
|
|
101
111
|
/**
|