@astram/identity 0.5.0 → 0.7.0

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