@astram/identity 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/company-membership.object.d.ts.map +1 -1
- package/dist/company-membership.object.js +3 -2
- package/dist/company.object.d.ts.map +1 -1
- package/dist/company.object.js +8 -3
- package/dist/features/access/index.d.ts.map +1 -1
- package/dist/features/access/index.js +1 -5
- package/dist/features/audit/audit-read.service.d.ts.map +1 -1
- package/dist/features/audit/audit-read.service.js +13 -6
- package/dist/features/audit/audit.controller.d.ts.map +1 -1
- package/dist/features/audit/audit.controller.js +2 -0
- package/dist/features/audit/mariadb-audit-reader.d.ts.map +1 -1
- package/dist/features/authz/access-bundle-reconcile.job.d.ts.map +1 -1
- package/dist/features/authz/catalog-reconcile.job.d.ts.map +1 -1
- package/dist/features/authz/catalog-reconcile.job.js +1 -3
- package/dist/features/authz/db-permission-facts.adapter.d.ts +16 -0
- package/dist/features/authz/db-permission-facts.adapter.d.ts.map +1 -1
- package/dist/features/authz/db-permission-facts.adapter.js +46 -2
- package/dist/features/authz/grant-admin.controller.d.ts +8 -5
- package/dist/features/authz/grant-admin.controller.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.controller.js +20 -18
- package/dist/features/authz/grant-admin.service.d.ts +72 -16
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.service.js +232 -86
- package/dist/features/authz/grant-ceiling.d.ts +100 -1
- package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
- package/dist/features/authz/grant-ceiling.js +264 -2
- package/dist/features/authz/mariadb-catalog-store.d.ts.map +1 -1
- package/dist/features/authz/mariadb-catalog-store.js +15 -3
- package/dist/features/authz/permission-version.bumper.d.ts +52 -1
- package/dist/features/authz/permission-version.bumper.d.ts.map +1 -1
- package/dist/features/authz/permission-version.bumper.js +119 -20
- package/dist/features/authz/record-authorization.d.ts +12 -0
- package/dist/features/authz/record-authorization.d.ts.map +1 -1
- package/dist/features/authz/record-authorization.js +49 -0
- package/dist/features/authz/role-binding.d.ts +36 -0
- package/dist/features/authz/role-binding.d.ts.map +1 -0
- package/dist/features/authz/role-binding.js +53 -0
- package/dist/features/company/company-hierarchy.service.d.ts +108 -0
- package/dist/features/company/company-hierarchy.service.d.ts.map +1 -0
- package/dist/features/company/company-hierarchy.service.js +277 -0
- package/dist/features/company/company.controller.d.ts +23 -5
- package/dist/features/company/company.controller.d.ts.map +1 -1
- package/dist/features/company/company.controller.js +47 -12
- package/dist/features/company/company.service.d.ts +28 -12
- package/dist/features/company/company.service.d.ts.map +1 -1
- package/dist/features/company/company.service.js +61 -14
- package/dist/features/cross-tenant/cross-tenant-grant.controller.d.ts.map +1 -1
- package/dist/features/cross-tenant/cross-tenant-grant.controller.js +2 -0
- package/dist/features/identity-feature.module.d.ts +2 -0
- package/dist/features/identity-feature.module.d.ts.map +1 -1
- package/dist/features/identity-feature.module.js +72 -5
- package/dist/features/membership/membership.controller.d.ts +6 -5
- package/dist/features/membership/membership.controller.d.ts.map +1 -1
- package/dist/features/membership/membership.controller.js +34 -22
- package/dist/features/membership/membership.service.d.ts +20 -14
- package/dist/features/membership/membership.service.d.ts.map +1 -1
- package/dist/features/membership/membership.service.js +62 -29
- package/dist/features/membership/posting-version-bump.d.ts +12 -0
- package/dist/features/membership/posting-version-bump.d.ts.map +1 -1
- package/dist/features/membership/posting.service.d.ts +20 -24
- package/dist/features/membership/posting.service.d.ts.map +1 -1
- package/dist/features/membership/posting.service.js +101 -64
- package/dist/features/org-structure/org-catalog.controller.d.ts.map +1 -1
- package/dist/features/org-structure/org-catalog.controller.js +2 -0
- package/dist/features/org-structure/org-catalog.service.d.ts.map +1 -1
- package/dist/features/org-structure/org-catalog.service.js +4 -1
- package/dist/features/policy/policy.controller.d.ts.map +1 -1
- package/dist/features/policy/policy.controller.js +2 -0
- package/dist/features/role/role.controller.d.ts +3 -2
- package/dist/features/role/role.controller.d.ts.map +1 -1
- package/dist/features/role/role.controller.js +15 -10
- package/dist/features/role/role.service.d.ts +39 -8
- package/dist/features/role/role.service.d.ts.map +1 -1
- package/dist/features/role/role.service.js +77 -10
- package/dist/features/scope/scope-closure.service.d.ts +54 -4
- package/dist/features/scope/scope-closure.service.d.ts.map +1 -1
- package/dist/features/scope/scope-closure.service.js +237 -85
- package/dist/features/service-account/service-account.controller.d.ts +3 -2
- package/dist/features/service-account/service-account.controller.d.ts.map +1 -1
- package/dist/features/service-account/service-account.controller.js +29 -15
- package/dist/features/service-account/service-account.service.d.ts +29 -7
- package/dist/features/service-account/service-account.service.d.ts.map +1 -1
- package/dist/features/service-account/service-account.service.js +73 -12
- package/dist/features/service-account/service-account.store.d.ts +15 -0
- package/dist/features/service-account/service-account.store.d.ts.map +1 -1
- package/dist/features/service-account/service-account.store.js +29 -0
- package/dist/features/share/share.controller.d.ts.map +1 -1
- package/dist/features/share/share.controller.js +2 -0
- package/dist/features/share/share.service.d.ts.map +1 -1
- package/dist/features/shared/identity-error.interceptor.d.ts +24 -0
- package/dist/features/shared/identity-error.interceptor.d.ts.map +1 -0
- package/dist/features/shared/identity-error.interceptor.js +82 -0
- package/dist/features/shared/identity-errors.d.ts +23 -0
- package/dist/features/shared/identity-errors.d.ts.map +1 -0
- package/dist/features/shared/identity-errors.js +41 -0
- package/dist/features/user/session-revocation-check.d.ts +11 -0
- package/dist/features/user/session-revocation-check.d.ts.map +1 -0
- package/dist/features/user/session-revocation-check.js +21 -0
- package/dist/features/user/user.controller.d.ts +11 -6
- package/dist/features/user/user.controller.d.ts.map +1 -1
- package/dist/features/user/user.controller.js +35 -17
- package/dist/features/user/user.service.d.ts +55 -14
- package/dist/features/user/user.service.d.ts.map +1 -1
- package/dist/features/user/user.service.js +143 -16
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -6
- package/dist/internal.d.ts +10 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +13 -0
- package/dist/membership-posting.object.d.ts.map +1 -1
- package/dist/membership-posting.object.js +4 -2
- package/dist/objects.d.ts.map +1 -1
- package/dist/objects.js +13 -3
- package/dist/role.object.d.ts.map +1 -1
- package/dist/role.object.js +3 -0
- package/dist/user.object.d.ts.map +1 -1
- package/dist/user.object.js +3 -0
- package/migrations/1710000000019-AddSessionMustReenroll.cjs +35 -0
- package/package.json +15 -4
- package/dist/auth-facts-keys.js.map +0 -1
- package/dist/branch.object.js.map +0 -1
- package/dist/company-branch.object.js.map +0 -1
- package/dist/company-department.object.js.map +0 -1
- package/dist/company-membership.object.js.map +0 -1
- package/dist/company.object.js.map +0 -1
- package/dist/cross-tenant-grant.object.js.map +0 -1
- package/dist/department.object.js.map +0 -1
- package/dist/features/access/access-admin.access.js.map +0 -1
- package/dist/features/access/company-admin.access.js.map +0 -1
- package/dist/features/access/index.js.map +0 -1
- package/dist/features/access/user-management.access.js.map +0 -1
- package/dist/features/audit/audit-read.service.js.map +0 -1
- package/dist/features/audit/audit.controller.js.map +0 -1
- package/dist/features/audit/directory-label-resolver.js.map +0 -1
- package/dist/features/audit/mariadb-audit-reader.js.map +0 -1
- package/dist/features/authz/access-bundle-reconcile.job.js.map +0 -1
- package/dist/features/authz/access-bundle-store.js.map +0 -1
- package/dist/features/authz/catalog-bootstrap.provider.js.map +0 -1
- package/dist/features/authz/catalog-reconcile.job.js.map +0 -1
- package/dist/features/authz/db-permission-facts.adapter.js.map +0 -1
- package/dist/features/authz/grant-admin.controller.js.map +0 -1
- package/dist/features/authz/grant-admin.service.js.map +0 -1
- package/dist/features/authz/grant-ceiling.js.map +0 -1
- package/dist/features/authz/mariadb-catalog-store.js.map +0 -1
- package/dist/features/authz/permission-version.bumper.js.map +0 -1
- package/dist/features/authz/record-authorization.js.map +0 -1
- package/dist/features/authz/session-facts.reader.js.map +0 -1
- package/dist/features/company/company.controller.js.map +0 -1
- package/dist/features/company/company.service.js.map +0 -1
- package/dist/features/cross-tenant/cross-tenant-grant-read.service.js.map +0 -1
- package/dist/features/cross-tenant/cross-tenant-grant.controller.js.map +0 -1
- package/dist/features/cross-tenant/grant-authorize.js.map +0 -1
- package/dist/features/identity-feature.module.js.map +0 -1
- package/dist/features/membership/membership-checker.js.map +0 -1
- package/dist/features/membership/membership.controller.js.map +0 -1
- package/dist/features/membership/membership.service.js.map +0 -1
- package/dist/features/membership/posting-version-bump.js.map +0 -1
- package/dist/features/membership/posting.service.js.map +0 -1
- package/dist/features/org-structure/org-catalog.controller.js.map +0 -1
- package/dist/features/org-structure/org-catalog.service.js.map +0 -1
- package/dist/features/policy/policy.controller.js.map +0 -1
- package/dist/features/policy/policy.service.js.map +0 -1
- package/dist/features/role/role.controller.js.map +0 -1
- package/dist/features/role/role.service.js.map +0 -1
- package/dist/features/scope/scope-closure.service.js.map +0 -1
- package/dist/features/scope/scope-subtree.resolver.js.map +0 -1
- package/dist/features/share/share.controller.js.map +0 -1
- package/dist/features/share/share.service.js.map +0 -1
- package/dist/features/shared/http.js.map +0 -1
- package/dist/features/shared/list-query.js.map +0 -1
- package/dist/features/shared/named-option.js.map +0 -1
- package/dist/features/shared/record-resolver.js.map +0 -1
- package/dist/features/user/user.controller.js.map +0 -1
- package/dist/features/user/user.service.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/membership-posting.object.js.map +0 -1
- package/dist/migrations-dir.js.map +0 -1
- package/dist/objects.js.map +0 -1
- package/dist/record-share.object.js.map +0 -1
- package/dist/role.object.js.map +0 -1
- package/dist/schema.js.map +0 -1
- package/dist/shared-resource.object.js.map +0 -1
- package/dist/user.object.js.map +0 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ScopeEdge } from '@astram/guard';
|
|
1
2
|
/**
|
|
2
3
|
* The minimal query surface a transaction connection must expose. A `mariadb`
|
|
3
4
|
* `PoolConnection` satisfies it structurally; kept framework-agnostic (mirrors `Queryable`
|
|
@@ -14,11 +15,17 @@ export interface ScopePool {
|
|
|
14
15
|
commit(): Promise<void>;
|
|
15
16
|
rollback(): Promise<void>;
|
|
16
17
|
release(): Promise<void>;
|
|
18
|
+
/** Drop the session instead of pooling it (used if a named lock could not be released). */
|
|
19
|
+
destroy?(): void;
|
|
17
20
|
}>;
|
|
18
21
|
}
|
|
19
22
|
/** The version-bump surface moveNode needs — satisfied by `PermissionVersionBumper`. */
|
|
20
23
|
export interface ScopeVersionBumper {
|
|
21
24
|
bumpForTenant(tenantUuid: string, conn: ScopeConn): Promise<void>;
|
|
25
|
+
/** ADR-0035: publish this transaction's bumped versions to Redis — called after `commit()`. */
|
|
26
|
+
publishCommitted?(conn: ScopeConn): Promise<void>;
|
|
27
|
+
/** ADR-0035: drop this transaction's bumped versions — called after `rollback()`. */
|
|
28
|
+
discardPending?(conn: ScopeConn): void;
|
|
22
29
|
}
|
|
23
30
|
export declare class ScopeClosureService {
|
|
24
31
|
private readonly pool;
|
|
@@ -27,13 +34,32 @@ export declare class ScopeClosureService {
|
|
|
27
34
|
/**
|
|
28
35
|
* Rebuild `scope_closure` from the current `companies.c_parent_uuid` edges. Idempotent
|
|
29
36
|
* (delete-then-insert in a tx → a second run over the same tree writes the identical set).
|
|
30
|
-
*
|
|
31
|
-
*
|
|
37
|
+
* A CYCLIC component is quarantined (no rows for it; logged loudly) so the rest of the forest
|
|
38
|
+
* still reconciles. FAIL-LOUD for the rest: an orphaned/too-deep edge makes `buildClosureRows`
|
|
39
|
+
* throw and that propagates (the tx rolls back — never a partial closure). Returns a summary.
|
|
32
40
|
*/
|
|
33
41
|
reconcileClosure(): Promise<{
|
|
34
42
|
nodes: number;
|
|
35
43
|
rows: number;
|
|
44
|
+
quarantined: number;
|
|
36
45
|
}>;
|
|
46
|
+
/**
|
|
47
|
+
* A node was just CREATED (security hardening D5): rebuild the closure so it has rows at once
|
|
48
|
+
* (not at the next hourly reconcile), and bump every ANCESTOR's permission version — their
|
|
49
|
+
* reachable subtree just grew — so their sessions pick the new node up at the next refresh.
|
|
50
|
+
* One transaction; fail-loud like `moveNode` (a corrupt tree aborts the rebuild). A node inside
|
|
51
|
+
* a quarantined cycle gets no rows and bumps nothing.
|
|
52
|
+
*/
|
|
53
|
+
attachNode(nodeUuid: string): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Run `fn` in ONE transaction while holding the tree's named lock (security review F3). Every
|
|
56
|
+
* hierarchy writer (move, delete, attach, reconcile) takes the SAME `GET_LOCK` on its own
|
|
57
|
+
* connection BEFORE `BEGIN`, so they run one at a time and each transaction's first read sees
|
|
58
|
+
* every move committed before it. Without it, two concurrent moves each pass the cycle check
|
|
59
|
+
* against the other's pre-move tree and together form a cycle. A lock that cannot be taken in
|
|
60
|
+
* time fails closed (503, nothing written). The lock is released after commit/rollback.
|
|
61
|
+
*/
|
|
62
|
+
private inTreeLock;
|
|
37
63
|
/**
|
|
38
64
|
* Re-parent `nodeUuid` under `newParentUuid` (or null for a root). Cycle-checked FIRST via the
|
|
39
65
|
* closure: if `newParentUuid` is already a descendant of `nodeUuid`, throw `ScopeCycleError`
|
|
@@ -51,7 +77,9 @@ export declare class ScopeClosureService {
|
|
|
51
77
|
* HIGH stale-subtree-after-move leak: a parent admin kept reaching a moved-away tenant.
|
|
52
78
|
* Nodes on neither side keep their exact set and are not bumped.
|
|
53
79
|
*/
|
|
54
|
-
moveNode(nodeUuid: string, newParentUuid: string | null
|
|
80
|
+
moveNode(nodeUuid: string, newParentUuid: string | null,
|
|
81
|
+
/** Runs on the move's transaction after the bump (e.g. the audit row) — a throw rolls back. */
|
|
82
|
+
inTransaction?: (conn: ScopeConn) => Promise<void>): Promise<void>;
|
|
55
83
|
/**
|
|
56
84
|
* Delete `nodeUuid`, FAIL-CLOSED. Refuses (throws) if the node has ANY child company — never
|
|
57
85
|
* silently orphans/promotes a sub-org (the `...016` FK RESTRICT enforces this at the DB too; the
|
|
@@ -60,12 +88,24 @@ export declare class ScopeClosureService {
|
|
|
60
88
|
* subtree, so those admins' cached session subtrees are stale (they'd keep the deleted node in
|
|
61
89
|
* their set) — bump them so they re-derive at the next refresh (same discipline as moveNode's
|
|
62
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.
|
|
63
95
|
*
|
|
64
96
|
* v1 SCOPE NOTE: this refuses a node with CHILDREN. Broader resource-orphan cleanup (rows in
|
|
65
97
|
* the many tenant-scoped app tables that reference the deleted node) is out of scope for v1 and
|
|
66
98
|
* is tracked as a follow-up; deleteNode v1 only guarantees the HIERARCHY stays consistent.
|
|
67
99
|
*/
|
|
68
|
-
deleteNode(nodeUuid: string
|
|
100
|
+
deleteNode(nodeUuid: string,
|
|
101
|
+
/** Runs on the delete's transaction after the bump (e.g. the audit row) — a throw rolls back. */
|
|
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>;
|
|
69
109
|
/** Read the node forest's parent edges (`c_uuid` → `c_parent_uuid`) as `ScopeEdge[]`. */
|
|
70
110
|
private readEdges;
|
|
71
111
|
/**
|
|
@@ -75,6 +115,16 @@ export declare class ScopeClosureService {
|
|
|
75
115
|
*/
|
|
76
116
|
private rewriteClosure;
|
|
77
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Split the edge set into the CLEAN part and the nodes QUARANTINED because their parent chain
|
|
120
|
+
* reaches a cycle (the cycle's members and everything below them). PURE. Orphans and depth are
|
|
121
|
+
* left to `buildClosureRows` (still fail-loud): only a cycle is quarantined, because a cycle is
|
|
122
|
+
* the corruption a tenant admin could once create through the generic company patch (#2).
|
|
123
|
+
*/
|
|
124
|
+
export declare function quarantineCycles(edges: readonly ScopeEdge[]): {
|
|
125
|
+
clean: ScopeEdge[];
|
|
126
|
+
quarantined: string[];
|
|
127
|
+
};
|
|
78
128
|
/** deleteNode fail-closed guard: the node still has child companies (re-parent/cascade first). */
|
|
79
129
|
export declare class NodeHasChildrenError extends Error {
|
|
80
130
|
constructor(nodeUuid: string);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scope-closure.service.d.ts","sourceRoot":"","sources":["../../../src/features/scope/scope-closure.service.ts"],"names":[],"mappings":"AAqCA;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAED,iGAAiG;AACjG,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAClE,aAAa,IAAI,OAAO,CACtB,SAAS,GAAG;QACV,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QAClC,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QACxB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QAC1B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"scope-closure.service.d.ts","sourceRoot":"","sources":["../../../src/features/scope/scope-closure.service.ts"],"names":[],"mappings":"AAqCA,OAAO,EAAqC,KAAK,SAAS,EAAE,MAAM,eAAe,CAAC;AAOlF;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAED,iGAAiG;AACjG,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAClE,aAAa,IAAI,OAAO,CACtB,SAAS,GAAG;QACV,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QAClC,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QACxB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QAC1B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QACzB,2FAA2F;QAC3F,OAAO,CAAC,IAAI,IAAI,CAAC;KAClB,CACF,CAAC;CACH;AAOD,wFAAwF;AACxF,MAAM,WAAW,kBAAkB;IACjC,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE,+FAA+F;IAC/F,gBAAgB,CAAC,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClD,qFAAqF;IACrF,cAAc,CAAC,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAAC;CACxC;AAED,qBAAa,mBAAmB;IAE5B,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,MAAM;gBADN,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,kBAAkB;IAG7C;;;;;;OAMG;IACG,gBAAgB,IAAI,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IAsBvF;;;;;;OAMG;IACG,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAcjD;;;;;;;OAOG;YACW,UAAU;IAwCxB;;;;;;;;;;;;;;;;OAgBG;IACG,QAAQ,CACZ,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,MAAM,GAAG,IAAI;IAC5B,+FAA+F;IAC/F,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC,GACjD,OAAO,CAAC,IAAI,CAAC;IAiFhB;;;;;;;;;;;;;;;;OAgBG;IACG,UAAU,CACd,QAAQ,EAAE,MAAM;IAChB,iGAAiG;IACjG,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC;IAClD;;;;OAIG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,GAAG,SAAS,MAAM,EAAE,CAAC,GACpE,OAAO,CAAC,IAAI,CAAC;IA6ChB,yFAAyF;YAC3E,SAAS;IAOvB;;;;OAIG;YACW,cAAc;CAiB7B;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,SAAS,EAAE,GAAG;IAC7D,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB,CA8BA;AAED,kGAAkG;AAClG,qBAAa,oBAAqB,SAAQ,KAAK;gBACjC,QAAQ,EAAE,MAAM;CAQ7B"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.NodeHasChildrenError = exports.ScopeClosureService = void 0;
|
|
4
|
+
exports.quarantineCycles = quarantineCycles;
|
|
4
5
|
// astram-framework/packages/identity/src/features/scope/scope-closure.service.ts
|
|
5
6
|
/**
|
|
6
7
|
* `ScopeClosureService` — the LIVE, I/O half of Task 1.2 (Phase 1 of the scoped-hierarchy
|
|
@@ -20,6 +21,12 @@ exports.NodeHasChildrenError = exports.ScopeClosureService = void 0;
|
|
|
20
21
|
* the moved subtree so active sessions re-derive their (now-changed) subtree WITHOUT re-login
|
|
21
22
|
* (ADR-0011). A failed rebuild rolls the whole move back — atomic.
|
|
22
23
|
*
|
|
24
|
+
* • `attachNode(node)` — a node was just created: rebuild + bump its ancestors (security
|
|
25
|
+
* hardening D5), so the creator's subtree includes it without waiting for the hourly reconcile.
|
|
26
|
+
*
|
|
27
|
+
* Callers: `CompanyHierarchyService` (move / delete / attach, with an in-transaction audit hook)
|
|
28
|
+
* and the boot/hourly reconcile. A generic company patch can no longer move a node (ADR-0034).
|
|
29
|
+
*
|
|
23
30
|
* • `deleteNode(node)` — remove a node (E-2), FAIL-CLOSED: it DENIES (throws) if the node has
|
|
24
31
|
* children, never letting the DB's old `ON DELETE SET NULL` silently promote a sub-org to a
|
|
25
32
|
* root (a hierarchy-boundary violation). The `...016` migration also flips the FK to
|
|
@@ -31,11 +38,16 @@ exports.NodeHasChildrenError = exports.ScopeClosureService = void 0;
|
|
|
31
38
|
* invalidation. All SQL values are `?`-bound (never interpolated). `companies` lives in
|
|
32
39
|
* `shield_app`; `scope_closure` lives in `shield_authz` — the shared authz pool reaches both.
|
|
33
40
|
*/
|
|
41
|
+
const core_1 = require("@astram/core");
|
|
34
42
|
const guard_1 = require("@astram/guard");
|
|
35
43
|
/** Schema the node tree (`companies`) lives in. */
|
|
36
44
|
const APP = 'shield_app';
|
|
37
45
|
/** Schema the closure table lives in (ADR-0011 / Task 1.1). */
|
|
38
46
|
const AUTHZ = 'shield_authz';
|
|
47
|
+
/** The named lock every hierarchy writer holds (security review F3). */
|
|
48
|
+
const TREE_LOCK = 'shield_scope_closure';
|
|
49
|
+
/** Seconds to wait for it before failing closed. */
|
|
50
|
+
const TREE_LOCK_TIMEOUT_S = 10;
|
|
39
51
|
class ScopeClosureService {
|
|
40
52
|
pool;
|
|
41
53
|
bumper;
|
|
@@ -46,27 +58,99 @@ class ScopeClosureService {
|
|
|
46
58
|
/**
|
|
47
59
|
* Rebuild `scope_closure` from the current `companies.c_parent_uuid` edges. Idempotent
|
|
48
60
|
* (delete-then-insert in a tx → a second run over the same tree writes the identical set).
|
|
49
|
-
*
|
|
50
|
-
*
|
|
61
|
+
* A CYCLIC component is quarantined (no rows for it; logged loudly) so the rest of the forest
|
|
62
|
+
* still reconciles. FAIL-LOUD for the rest: an orphaned/too-deep edge makes `buildClosureRows`
|
|
63
|
+
* throw and that propagates (the tx rolls back — never a partial closure). Returns a summary.
|
|
51
64
|
*/
|
|
52
65
|
async reconcileClosure() {
|
|
53
|
-
|
|
54
|
-
|
|
66
|
+
// Security hardening #2: a cyclic component (and everything hanging under it) is QUARANTINED —
|
|
67
|
+
// left out of the closure, so its nodes get no rows (their admins fall back to the self node,
|
|
68
|
+
// non-widening) — instead of one corrupt component aborting the rebuild for EVERY tenant.
|
|
69
|
+
// Other corruption (orphan / too deep) still throws and propagates (fail-loud).
|
|
70
|
+
const { clean, quarantined, rows } = await this.inTreeLock(async (conn) => {
|
|
71
|
+
const split = quarantineCycles(await this.readEdges(conn));
|
|
72
|
+
const built = (0, guard_1.buildClosureRows)(split.clean);
|
|
73
|
+
await this.rewriteClosure(conn, built);
|
|
74
|
+
return { ...split, rows: built };
|
|
75
|
+
});
|
|
76
|
+
if (quarantined.length > 0) {
|
|
77
|
+
console.error(`[scope] closure: QUARANTINED ${quarantined.length} node(s) in or under a parent cycle — ` +
|
|
78
|
+
`they have no closure rows until the cycle is repaired`);
|
|
79
|
+
}
|
|
80
|
+
console.log(`[scope] closure: ${clean.length} nodes, ${rows.length} rows`);
|
|
81
|
+
return { nodes: clean.length, rows: rows.length, quarantined: quarantined.length };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A node was just CREATED (security hardening D5): rebuild the closure so it has rows at once
|
|
85
|
+
* (not at the next hourly reconcile), and bump every ANCESTOR's permission version — their
|
|
86
|
+
* reachable subtree just grew — so their sessions pick the new node up at the next refresh.
|
|
87
|
+
* One transaction; fail-loud like `moveNode` (a corrupt tree aborts the rebuild). A node inside
|
|
88
|
+
* a quarantined cycle gets no rows and bumps nothing.
|
|
89
|
+
*/
|
|
90
|
+
async attachNode(nodeUuid) {
|
|
91
|
+
await this.inTreeLock(async (conn) => {
|
|
92
|
+
const { clean } = quarantineCycles(await this.readEdges(conn));
|
|
93
|
+
const rows = (0, guard_1.buildClosureRows)(clean);
|
|
94
|
+
await this.rewriteClosure(conn, rows);
|
|
95
|
+
const ancestors = rows
|
|
96
|
+
.filter((r) => r.descendant === nodeUuid && r.ancestor !== nodeUuid)
|
|
97
|
+
.map((r) => r.ancestor);
|
|
98
|
+
for (const tenantUuid of new Set(ancestors)) {
|
|
99
|
+
await this.bumper.bumpForTenant(tenantUuid, conn);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Run `fn` in ONE transaction while holding the tree's named lock (security review F3). Every
|
|
105
|
+
* hierarchy writer (move, delete, attach, reconcile) takes the SAME `GET_LOCK` on its own
|
|
106
|
+
* connection BEFORE `BEGIN`, so they run one at a time and each transaction's first read sees
|
|
107
|
+
* every move committed before it. Without it, two concurrent moves each pass the cycle check
|
|
108
|
+
* against the other's pre-move tree and together form a cycle. A lock that cannot be taken in
|
|
109
|
+
* time fails closed (503, nothing written). The lock is released after commit/rollback.
|
|
110
|
+
*/
|
|
111
|
+
async inTreeLock(fn) {
|
|
55
112
|
const conn = await this.pool.getConnection();
|
|
113
|
+
let locked = false;
|
|
56
114
|
try {
|
|
115
|
+
const got = (await conn.query('SELECT GET_LOCK(?, ?) AS got', [
|
|
116
|
+
TREE_LOCK,
|
|
117
|
+
TREE_LOCK_TIMEOUT_S,
|
|
118
|
+
]));
|
|
119
|
+
if (!Array.isArray(got) || Number(got[0]?.got) !== 1) {
|
|
120
|
+
throw new core_1.AuthorizationUnavailableError('scope hierarchy is busy; try again');
|
|
121
|
+
}
|
|
122
|
+
locked = true;
|
|
57
123
|
await conn.beginTransaction();
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
124
|
+
let out;
|
|
125
|
+
try {
|
|
126
|
+
out = await fn(conn);
|
|
127
|
+
await conn.commit();
|
|
128
|
+
}
|
|
129
|
+
catch (cause) {
|
|
130
|
+
await conn.rollback();
|
|
131
|
+
this.bumper.discardPending?.(conn);
|
|
132
|
+
throw cause;
|
|
133
|
+
}
|
|
134
|
+
// ADR-0035: the committed version bumps reach Redis (best-effort; never fails the write).
|
|
135
|
+
await this.bumper.publishCommitted?.(conn);
|
|
136
|
+
return out;
|
|
64
137
|
}
|
|
65
138
|
finally {
|
|
66
|
-
|
|
139
|
+
let releasedLock = !locked;
|
|
140
|
+
if (locked) {
|
|
141
|
+
try {
|
|
142
|
+
await conn.query('SELECT RELEASE_LOCK(?)', [TREE_LOCK]);
|
|
143
|
+
releasedLock = true;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// handled below: a pooled session that may still hold the lock is destroyed, not reused
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!releasedLock && typeof conn.destroy === 'function')
|
|
150
|
+
conn.destroy();
|
|
151
|
+
else
|
|
152
|
+
await conn.release();
|
|
67
153
|
}
|
|
68
|
-
console.log(`[scope] closure: ${edges.length} nodes, ${rows.length} rows`);
|
|
69
|
-
return { nodes: edges.length, rows: rows.length };
|
|
70
154
|
}
|
|
71
155
|
/**
|
|
72
156
|
* Re-parent `nodeUuid` under `newParentUuid` (or null for a root). Cycle-checked FIRST via the
|
|
@@ -85,62 +169,74 @@ class ScopeClosureService {
|
|
|
85
169
|
* HIGH stale-subtree-after-move leak: a parent admin kept reaching a moved-away tenant.
|
|
86
170
|
* Nodes on neither side keep their exact set and are not bumped.
|
|
87
171
|
*/
|
|
88
|
-
async moveNode(nodeUuid, newParentUuid
|
|
172
|
+
async moveNode(nodeUuid, newParentUuid,
|
|
173
|
+
/** Runs on the move's transaction after the bump (e.g. the audit row) — a throw rolls back. */
|
|
174
|
+
inTransaction) {
|
|
89
175
|
if (newParentUuid === nodeUuid) {
|
|
90
176
|
// A node cannot be its own parent (a trivial 1-cycle).
|
|
91
177
|
throw new guard_1.ScopeCycleError(nodeUuid);
|
|
92
178
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
179
|
+
// Review F3: every read below runs INSIDE the tree lock + transaction, so a concurrent move
|
|
180
|
+
// that committed first is visible to the cycle check (two moves can no longer each pass the
|
|
181
|
+
// check against the other's pre-move tree).
|
|
182
|
+
await this.inTreeLock(async (conn) => {
|
|
183
|
+
if (newParentUuid !== null) {
|
|
184
|
+
// Cycle guard: the new parent must NOT be at/under the moved node. A closure hit means
|
|
185
|
+
// newParent is a descendant of node → re-parenting node under it would form a cycle.
|
|
186
|
+
const cyclic = (await conn.query(`SELECT 1 FROM \`${AUTHZ}\`.\`scope_closure\`
|
|
97
187
|
WHERE ancestor_uuid = ? AND descendant_uuid = ? LIMIT 1`, [nodeUuid, newParentUuid]));
|
|
98
|
-
|
|
99
|
-
|
|
188
|
+
if (cyclic.length > 0) {
|
|
189
|
+
throw new guard_1.ScopeCycleError(nodeUuid);
|
|
190
|
+
}
|
|
100
191
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const formerAncestryRows = (await this.pool.query(`SELECT ancestor_uuid FROM \`${AUTHZ}\`.\`scope_closure\`
|
|
192
|
+
// The moved subtree — the nodes whose ancestry changes (node + all descendants). Read
|
|
193
|
+
// BEFORE the update (the move doesn't change the subtree's internal membership).
|
|
194
|
+
const subtreeRows = (await conn.query(`SELECT descendant_uuid FROM \`${AUTHZ}\`.\`scope_closure\` WHERE ancestor_uuid = ?`, [nodeUuid]));
|
|
195
|
+
// The FORMER-PARENT ANCESTRY — the nodes ABOVE `node` in the OLD tree (its ancestors,
|
|
196
|
+
// excluding self). They just LOST `node`'s subtree from their reach, so their admins' cached
|
|
197
|
+
// session subtrees are now stale (they'd otherwise keep reaching the moved-away tenant — the
|
|
198
|
+
// HIGH stale-subtree-after-move leak). Read BEFORE the update (the pre-move closure has the
|
|
199
|
+
// old ancestry). Bump these too so those sessions re-derive their (now-smaller) subtree at
|
|
200
|
+
// their next refresh (ADR-0011 bound — no forced re-login).
|
|
201
|
+
const formerAncestryRows = (await conn.query(`SELECT ancestor_uuid FROM \`${AUTHZ}\`.\`scope_closure\`
|
|
112
202
|
WHERE descendant_uuid = ? AND ancestor_uuid <> ?`, [nodeUuid, nodeUuid]));
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
203
|
+
// Fall back to the node itself if the closure has no self-row yet (pre-first-reconcile).
|
|
204
|
+
const movedSubtree = subtreeRows.length > 0 ? subtreeRows.map((r) => r.descendant_uuid) : [nodeUuid];
|
|
205
|
+
// The full bump set: the moved subtree (their ancestry grew/changed) UNION the former-parent
|
|
206
|
+
// ancestry (they lost the moved subtree). Deduped.
|
|
207
|
+
const affected = [
|
|
208
|
+
...new Set([...movedSubtree, ...formerAncestryRows.map((r) => r.ancestor_uuid)]),
|
|
209
|
+
];
|
|
210
|
+
{
|
|
211
|
+
// 1) Re-parent the edge.
|
|
212
|
+
await conn.query(`UPDATE \`${APP}\`.\`companies\` SET c_parent_uuid = ? WHERE c_uuid = ?`, [
|
|
213
|
+
newParentUuid,
|
|
214
|
+
nodeUuid,
|
|
215
|
+
]);
|
|
216
|
+
// 2) Rebuild the whole closure from the now-updated edges (full rebuild — safe + simple;
|
|
217
|
+
// a targeted subtree rebuild is a later optimization). Re-reads edges over the SAME tx
|
|
218
|
+
// connection so it sees the UPDATE. Fail-loud propagates → the whole move rolls back.
|
|
219
|
+
// A pre-existing cycle elsewhere is quarantined (not this move's problem); but if THIS
|
|
220
|
+
// node lands in a cycle (the closure pre-check can miss one through quarantined nodes,
|
|
221
|
+
// which have no closure rows), refuse and roll back.
|
|
222
|
+
const { clean, quarantined } = quarantineCycles(await this.readEdges(conn));
|
|
223
|
+
if (quarantined.includes(nodeUuid))
|
|
224
|
+
throw new guard_1.ScopeCycleError(nodeUuid);
|
|
225
|
+
const rows = (0, guard_1.buildClosureRows)(clean);
|
|
226
|
+
await this.rewriteClosure(conn, rows);
|
|
227
|
+
// 3) Session invalidation: bump each moved-subtree tenant so its cached subtree re-derives.
|
|
228
|
+
// The NEW ancestry gained the subtree too: bump it so its sessions see the move.
|
|
229
|
+
const newAncestry = rows
|
|
230
|
+
.filter((r) => r.descendant === nodeUuid && r.ancestor !== nodeUuid)
|
|
231
|
+
.map((r) => r.ancestor);
|
|
232
|
+
for (const tenantUuid of new Set([...affected, ...newAncestry])) {
|
|
233
|
+
await this.bumper.bumpForTenant(tenantUuid, conn);
|
|
234
|
+
}
|
|
235
|
+
// 4) Caller hook on the SAME transaction (audit) — a throw rolls the whole move back.
|
|
236
|
+
if (inTransaction)
|
|
237
|
+
await inTransaction(conn);
|
|
134
238
|
}
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
catch (cause) {
|
|
138
|
-
await conn.rollback();
|
|
139
|
-
throw cause;
|
|
140
|
-
}
|
|
141
|
-
finally {
|
|
142
|
-
await conn.release();
|
|
143
|
-
}
|
|
239
|
+
});
|
|
144
240
|
}
|
|
145
241
|
/**
|
|
146
242
|
* Delete `nodeUuid`, FAIL-CLOSED. Refuses (throws) if the node has ANY child company — never
|
|
@@ -150,41 +246,56 @@ class ScopeClosureService {
|
|
|
150
246
|
* subtree, so those admins' cached session subtrees are stale (they'd keep the deleted node in
|
|
151
247
|
* their set) — bump them so they re-derive at the next refresh (same discipline as moveNode's
|
|
152
248
|
* former-parent bump; without it, a parent admin's list/decide would reference a gone node).
|
|
249
|
+
* Also bumps the deleted node's own tenant (Phase 8 #3). `beforeDelete` runs first, on the same
|
|
250
|
+
* transaction, so a caller can lock the node and refuse (rollback) before anything changes; it
|
|
251
|
+
* may also retire rows that depend on the node and return the tenants those changes touched,
|
|
252
|
+
* which are bumped with the rest.
|
|
153
253
|
*
|
|
154
254
|
* v1 SCOPE NOTE: this refuses a node with CHILDREN. Broader resource-orphan cleanup (rows in
|
|
155
255
|
* the many tenant-scoped app tables that reference the deleted node) is out of scope for v1 and
|
|
156
256
|
* is tracked as a follow-up; deleteNode v1 only guarantees the HIERARCHY stays consistent.
|
|
157
257
|
*/
|
|
158
|
-
async deleteNode(nodeUuid
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
258
|
+
async deleteNode(nodeUuid,
|
|
259
|
+
/** Runs on the delete's transaction after the bump (e.g. the audit row) — a throw rolls back. */
|
|
260
|
+
inTransaction,
|
|
261
|
+
/**
|
|
262
|
+
* Runs on the delete's transaction BEFORE anything is deleted (Phase 8 #3: lock the node, check
|
|
263
|
+
* authority over its members and agents, retire its agents and cross-tenant grants) — a throw
|
|
264
|
+
* rolls back and nothing is deleted or bumped. Returned tenant ids are bumped too.
|
|
265
|
+
*/
|
|
266
|
+
beforeDelete) {
|
|
267
|
+
// Under the tree lock (review F3): a concurrent move cannot hang a child under this node
|
|
268
|
+
// between the children check and the delete.
|
|
269
|
+
await this.inTreeLock(async (conn) => {
|
|
270
|
+
const children = (await conn.query(`SELECT 1 FROM \`${APP}\`.\`companies\` WHERE c_parent_uuid = ? LIMIT 1`, [nodeUuid]));
|
|
271
|
+
if (children.length > 0) {
|
|
272
|
+
throw new NodeHasChildrenError(nodeUuid);
|
|
273
|
+
}
|
|
274
|
+
const touched = (beforeDelete ? await beforeDelete(conn) : undefined) ?? [];
|
|
275
|
+
// The former-parent ancestry — the deleted node's ancestors (excluding self). They lose the
|
|
276
|
+
// node from their reach → bump so their sessions re-derive. Read BEFORE the delete.
|
|
277
|
+
const formerAncestryRows = (await conn.query(`SELECT ancestor_uuid FROM \`${AUTHZ}\`.\`scope_closure\`
|
|
278
|
+
WHERE descendant_uuid = ? AND ancestor_uuid <> ?`, [nodeUuid, nodeUuid]));
|
|
170
279
|
// Remove the node's closure rows (self-row + any it appears in as ancestor/descendant).
|
|
171
280
|
await conn.query(`DELETE FROM \`${AUTHZ}\`.\`scope_closure\`
|
|
172
281
|
WHERE ancestor_uuid = ? OR descendant_uuid = ?`, [nodeUuid, nodeUuid]);
|
|
173
282
|
// Remove the company row itself.
|
|
174
283
|
await conn.query(`DELETE FROM \`${APP}\`.\`companies\` WHERE c_uuid = ?`, [nodeUuid]);
|
|
175
|
-
// Bump the former-parent ancestry so their cached subtrees re-derive at next refresh
|
|
176
|
-
|
|
177
|
-
|
|
284
|
+
// Bump the former-parent ancestry so their cached subtrees re-derive at next refresh, and the
|
|
285
|
+
// deleted node's OWN tenant (Phase 8 #3): its memberships and postings cascade away, so
|
|
286
|
+
// sessions cached in it must reload (and find nothing). A leaf has no descendants to bump.
|
|
287
|
+
// Plus the tenants `beforeDelete` touched (e.g. other tenants' agents that lost the node).
|
|
288
|
+
const bumpSet = new Set([
|
|
289
|
+
...formerAncestryRows.map((r) => r.ancestor_uuid),
|
|
290
|
+
nodeUuid,
|
|
291
|
+
...touched,
|
|
292
|
+
]);
|
|
293
|
+
for (const tenant of bumpSet) {
|
|
294
|
+
await this.bumper.bumpForTenant(tenant, conn);
|
|
178
295
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
await conn.rollback();
|
|
183
|
-
throw cause;
|
|
184
|
-
}
|
|
185
|
-
finally {
|
|
186
|
-
await conn.release();
|
|
187
|
-
}
|
|
296
|
+
if (inTransaction)
|
|
297
|
+
await inTransaction(conn);
|
|
298
|
+
});
|
|
188
299
|
}
|
|
189
300
|
/** Read the node forest's parent edges (`c_uuid` → `c_parent_uuid`) as `ScopeEdge[]`. */
|
|
190
301
|
async readEdges(q) {
|
|
@@ -210,6 +321,47 @@ class ScopeClosureService {
|
|
|
210
321
|
}
|
|
211
322
|
}
|
|
212
323
|
exports.ScopeClosureService = ScopeClosureService;
|
|
324
|
+
/**
|
|
325
|
+
* Split the edge set into the CLEAN part and the nodes QUARANTINED because their parent chain
|
|
326
|
+
* reaches a cycle (the cycle's members and everything below them). PURE. Orphans and depth are
|
|
327
|
+
* left to `buildClosureRows` (still fail-loud): only a cycle is quarantined, because a cycle is
|
|
328
|
+
* the corruption a tenant admin could once create through the generic company patch (#2).
|
|
329
|
+
*/
|
|
330
|
+
function quarantineCycles(edges) {
|
|
331
|
+
const parentOf = new Map();
|
|
332
|
+
for (const e of edges)
|
|
333
|
+
parentOf.set(e.node, e.parent);
|
|
334
|
+
const state = new Map();
|
|
335
|
+
for (const e of edges) {
|
|
336
|
+
if (state.has(e.node))
|
|
337
|
+
continue;
|
|
338
|
+
const path = [];
|
|
339
|
+
const onPath = new Set();
|
|
340
|
+
let current = e.node;
|
|
341
|
+
let verdict = 'ok';
|
|
342
|
+
for (;;) {
|
|
343
|
+
if (current === null || current === undefined || !parentOf.has(current))
|
|
344
|
+
break; // root / orphan
|
|
345
|
+
const known = state.get(current);
|
|
346
|
+
if (known !== undefined) {
|
|
347
|
+
verdict = known;
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
if (onPath.has(current)) {
|
|
351
|
+
verdict = 'bad'; // revisited a node on this walk → a cycle
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
onPath.add(current);
|
|
355
|
+
path.push(current);
|
|
356
|
+
current = parentOf.get(current);
|
|
357
|
+
}
|
|
358
|
+
for (const n of path)
|
|
359
|
+
state.set(n, verdict);
|
|
360
|
+
}
|
|
361
|
+
const quarantined = edges.filter((e) => state.get(e.node) === 'bad').map((e) => e.node);
|
|
362
|
+
const bad = new Set(quarantined);
|
|
363
|
+
return { clean: edges.filter((e) => !bad.has(e.node)), quarantined };
|
|
364
|
+
}
|
|
213
365
|
/** deleteNode fail-closed guard: the node still has child companies (re-parent/cascade first). */
|
|
214
366
|
class NodeHasChildrenError extends Error {
|
|
215
367
|
constructor(nodeUuid) {
|
|
@@ -37,7 +37,8 @@ export declare class ServiceAccountController {
|
|
|
37
37
|
account: import("./service-account.service.js").ServiceAccountView;
|
|
38
38
|
}>;
|
|
39
39
|
/**
|
|
40
|
-
* Issue a NEW key for an account. Gated `service_account:manage_keys`. Body: `{ expiresAtMs
|
|
40
|
+
* Issue a NEW key for an account. Gated `service_account:manage_keys`. Body: `{ expiresAtMs }`
|
|
41
|
+
* (a future epoch-ms) or `{ neverExpires: true }` — one of the two is required (400 otherwise).
|
|
41
42
|
* Returns the raw `ask_…` key EXACTLY ONCE — copy it now, it is never retrievable again.
|
|
42
43
|
*/
|
|
43
44
|
issueKey(req: AuthedRequest, id: string, body: unknown): Promise<{
|
|
@@ -45,7 +46,7 @@ export declare class ServiceAccountController {
|
|
|
45
46
|
}>;
|
|
46
47
|
/**
|
|
47
48
|
* Rotate an account's key. Gated `service_account:manage_keys`. The previous key stops
|
|
48
|
-
* authenticating; the new raw key is returned EXACTLY ONCE.
|
|
49
|
+
* authenticating; the new raw key is returned EXACTLY ONCE. Same expiry rule as issue.
|
|
49
50
|
*/
|
|
50
51
|
rotateKey(req: AuthedRequest, id: string, body: unknown): Promise<{
|
|
51
52
|
key: import("./service-account.service.js").IssuedKey;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service-account.controller.d.ts","sourceRoot":"","sources":["../../../src/features/service-account/service-account.controller.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"service-account.controller.d.ts","sourceRoot":"","sources":["../../../src/features/service-account/service-account.controller.ts"],"names":[],"mappings":"AA4BA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EACL,qBAAqB,EAOtB,MAAM,8BAA8B,CAAC;AAGtC,qFAAqF;AACrF,UAAU,aAAa;IACrB,SAAS,EAAE,SAAS,CAAC;CACtB;AAwDD,qBAEa,wBAAwB;IACvB,OAAO,CAAC,QAAQ,CAAC,GAAG;gBAAH,GAAG,EAAE,qBAAqB;IAEvD,8FAA8F;IAGxF,IAAI,CAAQ,GAAG,EAAE,aAAa;;;IAQpC;;;;;OAKG;IAGG,UAAU,CAAQ,GAAG,EAAE,aAAa;IAQ1C,8FAA8F;IAGxF,GAAG,CAAQ,GAAG,EAAE,aAAa,EAAe,EAAE,EAAE,MAAM;;;IAQ5D;;;OAGG;IAGG,MAAM,CAAQ,GAAG,EAAE,aAAa,EAAU,IAAI,EAAE,OAAO;;;IAiB7D;;;OAGG;IAGG,MAAM,CAAQ,GAAG,EAAE,aAAa,EAAe,EAAE,EAAE,MAAM,EAAU,IAAI,EAAE,OAAO;;;IAuBtF;;;;OAIG;IAGG,QAAQ,CAAQ,GAAG,EAAE,aAAa,EAAe,EAAE,EAAE,MAAM,EAAU,IAAI,EAAE,OAAO;;;IAUxF;;;OAGG;IAGG,SAAS,CAAQ,GAAG,EAAE,aAAa,EAAe,EAAE,EAAE,MAAM,EAAU,IAAI,EAAE,OAAO;;;IAUzF;;;OAGG;IAGG,UAAU,CAAQ,GAAG,EAAE,aAAa,EAAe,EAAE,EAAE,MAAM;;;CAQpE"}
|