@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.
Files changed (39) hide show
  1. package/dist/features/authz/grant-admin.service.d.ts +24 -1
  2. package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
  3. package/dist/features/authz/grant-admin.service.js +88 -1
  4. package/dist/features/authz/grant-ceiling.d.ts +49 -1
  5. package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
  6. package/dist/features/authz/grant-ceiling.js +118 -10
  7. package/dist/features/authz/permission-version.bumper.d.ts +7 -0
  8. package/dist/features/authz/permission-version.bumper.d.ts.map +1 -1
  9. package/dist/features/authz/permission-version.bumper.js +19 -10
  10. package/dist/features/authz/record-authorization.d.ts +12 -0
  11. package/dist/features/authz/record-authorization.d.ts.map +1 -1
  12. package/dist/features/authz/record-authorization.js +49 -0
  13. package/dist/features/company/company-hierarchy.service.d.ts +65 -3
  14. package/dist/features/company/company-hierarchy.service.d.ts.map +1 -1
  15. package/dist/features/company/company-hierarchy.service.js +183 -6
  16. package/dist/features/identity-feature.module.d.ts +2 -0
  17. package/dist/features/identity-feature.module.d.ts.map +1 -1
  18. package/dist/features/identity-feature.module.js +19 -9
  19. package/dist/features/membership/posting-version-bump.d.ts +7 -0
  20. package/dist/features/membership/posting-version-bump.d.ts.map +1 -1
  21. package/dist/features/role/role.service.d.ts +25 -2
  22. package/dist/features/role/role.service.d.ts.map +1 -1
  23. package/dist/features/role/role.service.js +57 -4
  24. package/dist/features/scope/scope-closure.service.d.ts +11 -1
  25. package/dist/features/scope/scope-closure.service.d.ts.map +1 -1
  26. package/dist/features/scope/scope-closure.service.js +23 -4
  27. package/dist/features/service-account/service-account.service.d.ts +15 -0
  28. package/dist/features/service-account/service-account.service.d.ts.map +1 -1
  29. package/dist/features/service-account/service-account.service.js +17 -1
  30. package/dist/features/service-account/service-account.store.d.ts +15 -0
  31. package/dist/features/service-account/service-account.store.d.ts.map +1 -1
  32. package/dist/features/service-account/service-account.store.js +29 -0
  33. package/dist/index.d.ts +7 -5
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +7 -6
  36. package/dist/internal.d.ts +10 -0
  37. package/dist/internal.d.ts.map +1 -0
  38. package/dist/internal.js +13 -0
  39. 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, audit?: AuditPort | undefined, now?: () => number);
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;;;;;;;;;;;;;;;;GAgBG;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,qBAAa,uBAAuB;IAEhC,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC;IACvB,OAAO,CAAC,QAAQ,CAAC,GAAG;gBAHH,IAAI,EAAE,kBAAkB,EACxB,OAAO,EAAE,mBAAmB,EAC5B,KAAK,CAAC,EAAE,SAAS,YAAA,EACjB,GAAG,GAAE,MAAM,MAAiB;IAG/C,0EAA0E;IAC1E,OAAO,CAAC,OAAO;IAIf,uDAAuD;YACzC,KAAK;YAUL,WAAW;IAoBzB,iGAAiG;IAC3F,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAkD3F;;;OAGG;IACG,MAAM,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAc/D,wFAAwF;IAClF,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAGhD"}
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, audit, now = Date.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) {
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: actor.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 ?? '(unknown)' }));
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;AA6FxB;;;;;;;;;;GAUG;AACH,qBACa,qBAAqB;IAChC;;;;;;;;;;;;;OAaG;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;CAscvB"}
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
- // The ceiling's key universe includes the consumer's `extraObjects` (the SAME registry shape
124
- // `buildIdentityRecordAuthorization(pool, extraObjects)` gives the decision path): otherwise a
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, new engine_1.MariaDbAuditSink({}));
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
- const crossTenantAuthority = (0, guard_1.buildRecordAuthorization)((0, record_authorization_js_1.buildIdentityRecordAuthorization)(authzPool));
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.buildIdentityRecordAuthorization)(authzPool));
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;CACnD;AAED,eAAO,MAAM,oBAAoB,eAAiC,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
- constructor(records: RecordsService, ceiling: GrantCeiling);
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
- /** Delete a role — only a role the actor may administer (binding rule). */
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;AAC9C,OAAO,EAAkB,KAAK,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1E,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAKzD;;;;;;;;;GASG;AACH,qBACa,WAAW;IAEpB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBADP,OAAO,EAAE,cAAc,EACvB,OAAO,EAAE,YAAY;IAGxC,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,2EAA2E;IACrE,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAIhE"}
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
- constructor(records, ceiling) {
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
- /** Delete a role — only a role the actor may administer (binding rule). */
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
- await this.records.delete('role', uuid);
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>): 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
  /**