@venturekit-pro/tenancy 0.0.39 → 0.0.41
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/README.md +36 -0
- package/dist/hierarchy/index.d.ts +5 -0
- package/dist/hierarchy/index.d.ts.map +1 -0
- package/dist/hierarchy/index.js +3 -0
- package/dist/hierarchy/index.js.map +1 -0
- package/dist/hierarchy/reach.d.ts +90 -0
- package/dist/hierarchy/reach.d.ts.map +1 -0
- package/dist/hierarchy/reach.js +106 -0
- package/dist/hierarchy/reach.js.map +1 -0
- package/dist/hierarchy/tree.d.ts +91 -0
- package/dist/hierarchy/tree.d.ts.map +1 -0
- package/dist/hierarchy/tree.js +149 -0
- package/dist/hierarchy/tree.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/migrations/0000_vk_tenancy_tree.sql +44 -0
- package/package.json +6 -6
- package/src/migrations/0000_vk_tenancy_tree.sql +44 -0
package/README.md
CHANGED
|
@@ -77,6 +77,42 @@ const tenant = getCurrentTenant(ctx);
|
|
|
77
77
|
// { id: 'acme', slug: 'acme', metadata: { ... } }
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
+
## Hierarchy
|
|
81
|
+
|
|
82
|
+
Tenants form trees — a group and its schools, a franchise and its stores. The tree is
|
|
83
|
+
`vk_tenants.parent_id` plus `vk_tenant_closure` (every ancestor→descendant path with its
|
|
84
|
+
depth, migration `0000_vk_tenancy_tree`), maintained in code and read in one statement:
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
import { createTenantTree, unpackTenantRoles } from '@venturekit-pro/tenancy';
|
|
88
|
+
import { query } from '@venturekit/data';
|
|
89
|
+
|
|
90
|
+
const tree = createTenantTree(); // or { tenants: 'platform.tenant_ref', closure: 'platform.tenant_closure' } on a projection
|
|
91
|
+
await tree.setParent(query, schoolId, groupId); // rewrites the subtree's closure; refuses a cycle
|
|
92
|
+
await tree.descendantsOf(query, groupId); // [{ tenantId, depth }], shallowest first
|
|
93
|
+
await tree.ancestorsOf(query, schoolId); // nearest first
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
A role held in a parent counts in its descendants — a group's owner is the owner of each
|
|
97
|
+
of its schools without a membership row in any. The rules are pure (`inheritedTenantRole`,
|
|
98
|
+
`reachableTenants`), and the tree feeds them:
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
const pack = unpackTenantRoles(ctx.user.claims['custom:tenantRoles']);
|
|
102
|
+
const isSystemRole = (role: string) => role in SYSTEM_ROLES; // custom roles are rows of the tenant that defined them
|
|
103
|
+
|
|
104
|
+
// a request naming another tenant: may this session act as it, and as what?
|
|
105
|
+
const held = await tree.roleIn(query, pack, requestedTenantId, { inherits: isSystemRole });
|
|
106
|
+
if (!held) throw new ForbiddenError('This session may not act as that tenant');
|
|
107
|
+
|
|
108
|
+
// a session description / tenant switcher: every tenant this user may act as
|
|
109
|
+
const sites = await tree.reach(query, pack, { inherits: isSystemRole });
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
An explicit membership in a child always wins over an inherited one, and a nearer
|
|
113
|
+
ancestor over a farther one. The store is read only when the memberships alone do not
|
|
114
|
+
settle the question.
|
|
115
|
+
|
|
80
116
|
## Erasure and export
|
|
81
117
|
|
|
82
118
|
The cascade walker that backs `hardDeleteTenant` also serves the two
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { inheritedTenantRole, reachableTenants } from './reach.js';
|
|
2
|
+
export type { HeldTenantRole, HierarchyOptions, TenantDescendant, TenantMembership } from './reach.js';
|
|
3
|
+
export { createTenantTree, TenantTreeCycleError, TenantTreeDepthError, TENANT_TREE_MAX_DEPTH, VK_TENANT_TREE } from './tree.js';
|
|
4
|
+
export type { TenantAncestor, TenantTree, TenantTreeTables, TreeQuerier } from './tree.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hierarchy/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AACnE,YAAY,EAAE,cAAc,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AACvG,OAAO,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAChI,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/hierarchy/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEnE,OAAO,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant hierarchy — what a role in a parent tenant is worth in its children.
|
|
3
|
+
*
|
|
4
|
+
* Multi-tenant products grow trees: a school group and its schools, a franchise
|
|
5
|
+
* and its stores, an agency and its clients' workspaces. The membership model
|
|
6
|
+
* stays flat (one row per user × tenant, the `TenantUser` shape the scopes
|
|
7
|
+
* middleware reads), and the tree adds ONE rule on top: **a role held in a
|
|
8
|
+
* parent is held in every descendant, unless the app says that role does not
|
|
9
|
+
* travel.** A group's owner is the owner of each school without a membership row
|
|
10
|
+
* in any of them; an explicit membership in a child always wins over the
|
|
11
|
+
* inherited one; a nearer ancestor wins over a farther one.
|
|
12
|
+
*
|
|
13
|
+
* These are the rules, pure, over what the caller supplies: the memberships
|
|
14
|
+
* (typically the packed `custom:tenantRoles` claim, see `unpackTenantRoles`) and
|
|
15
|
+
* the tenant's ancestors or a membership tenant's descendants. The store that
|
|
16
|
+
* answers those — `vk_tenants.parent_id` and `vk_tenant_closure`, or a projection's
|
|
17
|
+
* own pair — is `./tree.ts`, whose `roleIn` / `reach` feed these two functions.
|
|
18
|
+
* Two questions, two functions:
|
|
19
|
+
*
|
|
20
|
+
* - {@link inheritedTenantRole} — "may this caller act as tenant X, and as
|
|
21
|
+
* what?" — for a request that names a tenant other than its own.
|
|
22
|
+
* - {@link reachableTenants} — "which tenants may this caller act as at all?"
|
|
23
|
+
* — for a session description or a tenant switcher.
|
|
24
|
+
*
|
|
25
|
+
* Which roles travel is the app's call ({@link HierarchyOptions.inherits}). The
|
|
26
|
+
* default is that every role does; an app whose custom roles are rows of the
|
|
27
|
+
* tenant that defined them (their scopes mean nothing in a child) passes a
|
|
28
|
+
* predicate that keeps inheritance to its system roles.
|
|
29
|
+
*/
|
|
30
|
+
/** One membership: which role the caller holds in which tenant. Same shape as a packed claim entry. */
|
|
31
|
+
export interface TenantMembership {
|
|
32
|
+
tenantId: string;
|
|
33
|
+
role: string;
|
|
34
|
+
}
|
|
35
|
+
export interface HierarchyOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Whether a role held in a parent counts in its descendants. Default: every
|
|
38
|
+
* role does. Return `false` for roles whose meaning is bound to the tenant
|
|
39
|
+
* that defined them (custom roles keyed by a per-tenant id, for instance).
|
|
40
|
+
*/
|
|
41
|
+
inherits?: (role: string, tenantId: string) => boolean;
|
|
42
|
+
}
|
|
43
|
+
/** How a caller comes to hold a role in a tenant. */
|
|
44
|
+
export interface HeldTenantRole {
|
|
45
|
+
tenantId: string;
|
|
46
|
+
role: string;
|
|
47
|
+
/** The tenant the membership is actually in: the tenant itself, or the ancestor the role came from. */
|
|
48
|
+
via: string;
|
|
49
|
+
/** Hops from `via` down to `tenantId`; `0` is the caller's own membership. */
|
|
50
|
+
depth: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The role a caller holds in `tenantId`, and through which tenant — or `null`
|
|
54
|
+
* when it has no business there.
|
|
55
|
+
*
|
|
56
|
+
* The caller's own membership in the tenant wins outright, whatever the role.
|
|
57
|
+
* Otherwise `ancestors` is walked **nearest first** (the app reads them from
|
|
58
|
+
* its tree: parent, grandparent, …) and the first ancestor the caller holds an
|
|
59
|
+
* inheritable role in decides. Roles the app marks as non-inheriting are
|
|
60
|
+
* skipped, not refused — a farther ancestor with a system role still counts.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* const pack = unpackTenantRoles(claims['custom:tenantRoles']);
|
|
65
|
+
* const ancestors = await closure.ancestorsOf(requestedTenantId); // nearest first
|
|
66
|
+
* const held = inheritedTenantRole(pack, requestedTenantId, ancestors, {
|
|
67
|
+
* inherits: (role) => isSystemRole(role),
|
|
68
|
+
* });
|
|
69
|
+
* if (!held) throw new ForbiddenError('This session may not act as that tenant');
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export declare function inheritedTenantRole(memberships: ReadonlyMap<string, string> | readonly TenantMembership[], tenantId: string, ancestors: readonly string[], options?: HierarchyOptions): HeldTenantRole | null;
|
|
73
|
+
/** A descendant of a tenant, as the app's tree reports it. */
|
|
74
|
+
export interface TenantDescendant {
|
|
75
|
+
tenantId: string;
|
|
76
|
+
/** Hops down from the ancestor asked about: a child is `1`. */
|
|
77
|
+
depth: number;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Every tenant a caller may act as: each membership's own tenant, plus — for a
|
|
81
|
+
* membership whose role inherits — every descendant of that tenant, carrying the
|
|
82
|
+
* role. A tenant reached more than once keeps the **nearest** claim to it: an
|
|
83
|
+
* explicit membership (depth 0) over any inherited one, a parent's role over a
|
|
84
|
+
* grandparent's. `descendantsOf` is asked once per membership whose role
|
|
85
|
+
* inherits, so a caller with only leaf memberships costs no tree read at all.
|
|
86
|
+
*
|
|
87
|
+
* The result is unordered; the app joins names and sorts for display.
|
|
88
|
+
*/
|
|
89
|
+
export declare function reachableTenants(memberships: ReadonlyMap<string, string> | readonly TenantMembership[], descendantsOf: (tenantId: string) => readonly TenantDescendant[], options?: HierarchyOptions): HeldTenantRole[];
|
|
90
|
+
//# sourceMappingURL=reach.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reach.d.ts","sourceRoot":"","sources":["../../src/hierarchy/reach.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,uGAAuG;AACvG,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;CACxD;AAED,qDAAqD;AACrD,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,uGAAuG;IACvG,GAAG,EAAE,MAAM,CAAC;IACZ,8EAA8E;IAC9E,KAAK,EAAE,MAAM,CAAC;CACf;AAaD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,gBAAgB,EAAE,EACtE,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,SAAS,MAAM,EAAE,EAC5B,OAAO,GAAE,gBAAqB,GAC7B,cAAc,GAAG,IAAI,CAYvB;AAED,8DAA8D;AAC9D,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,+DAA+D;IAC/D,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAC9B,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,gBAAgB,EAAE,EACtE,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,SAAS,gBAAgB,EAAE,EAChE,OAAO,GAAE,gBAAqB,GAC7B,cAAc,EAAE,CAiBlB"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant hierarchy — what a role in a parent tenant is worth in its children.
|
|
3
|
+
*
|
|
4
|
+
* Multi-tenant products grow trees: a school group and its schools, a franchise
|
|
5
|
+
* and its stores, an agency and its clients' workspaces. The membership model
|
|
6
|
+
* stays flat (one row per user × tenant, the `TenantUser` shape the scopes
|
|
7
|
+
* middleware reads), and the tree adds ONE rule on top: **a role held in a
|
|
8
|
+
* parent is held in every descendant, unless the app says that role does not
|
|
9
|
+
* travel.** A group's owner is the owner of each school without a membership row
|
|
10
|
+
* in any of them; an explicit membership in a child always wins over the
|
|
11
|
+
* inherited one; a nearer ancestor wins over a farther one.
|
|
12
|
+
*
|
|
13
|
+
* These are the rules, pure, over what the caller supplies: the memberships
|
|
14
|
+
* (typically the packed `custom:tenantRoles` claim, see `unpackTenantRoles`) and
|
|
15
|
+
* the tenant's ancestors or a membership tenant's descendants. The store that
|
|
16
|
+
* answers those — `vk_tenants.parent_id` and `vk_tenant_closure`, or a projection's
|
|
17
|
+
* own pair — is `./tree.ts`, whose `roleIn` / `reach` feed these two functions.
|
|
18
|
+
* Two questions, two functions:
|
|
19
|
+
*
|
|
20
|
+
* - {@link inheritedTenantRole} — "may this caller act as tenant X, and as
|
|
21
|
+
* what?" — for a request that names a tenant other than its own.
|
|
22
|
+
* - {@link reachableTenants} — "which tenants may this caller act as at all?"
|
|
23
|
+
* — for a session description or a tenant switcher.
|
|
24
|
+
*
|
|
25
|
+
* Which roles travel is the app's call ({@link HierarchyOptions.inherits}). The
|
|
26
|
+
* default is that every role does; an app whose custom roles are rows of the
|
|
27
|
+
* tenant that defined them (their scopes mean nothing in a child) passes a
|
|
28
|
+
* predicate that keeps inheritance to its system roles.
|
|
29
|
+
*/
|
|
30
|
+
const ALL_ROLES_INHERIT = () => true;
|
|
31
|
+
function asMap(memberships) {
|
|
32
|
+
if (memberships instanceof Map)
|
|
33
|
+
return memberships;
|
|
34
|
+
const out = new Map();
|
|
35
|
+
for (const { tenantId, role } of memberships) {
|
|
36
|
+
if (!out.has(tenantId))
|
|
37
|
+
out.set(tenantId, role);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The role a caller holds in `tenantId`, and through which tenant — or `null`
|
|
43
|
+
* when it has no business there.
|
|
44
|
+
*
|
|
45
|
+
* The caller's own membership in the tenant wins outright, whatever the role.
|
|
46
|
+
* Otherwise `ancestors` is walked **nearest first** (the app reads them from
|
|
47
|
+
* its tree: parent, grandparent, …) and the first ancestor the caller holds an
|
|
48
|
+
* inheritable role in decides. Roles the app marks as non-inheriting are
|
|
49
|
+
* skipped, not refused — a farther ancestor with a system role still counts.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* const pack = unpackTenantRoles(claims['custom:tenantRoles']);
|
|
54
|
+
* const ancestors = await closure.ancestorsOf(requestedTenantId); // nearest first
|
|
55
|
+
* const held = inheritedTenantRole(pack, requestedTenantId, ancestors, {
|
|
56
|
+
* inherits: (role) => isSystemRole(role),
|
|
57
|
+
* });
|
|
58
|
+
* if (!held) throw new ForbiddenError('This session may not act as that tenant');
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export function inheritedTenantRole(memberships, tenantId, ancestors, options = {}) {
|
|
62
|
+
const held = asMap(memberships);
|
|
63
|
+
const inherits = options.inherits ?? ALL_ROLES_INHERIT;
|
|
64
|
+
const own = held.get(tenantId);
|
|
65
|
+
if (own !== undefined)
|
|
66
|
+
return { tenantId, role: own, via: tenantId, depth: 0 };
|
|
67
|
+
for (const [index, ancestor] of ancestors.entries()) {
|
|
68
|
+
const role = held.get(ancestor);
|
|
69
|
+
if (role !== undefined && inherits(role, ancestor)) {
|
|
70
|
+
return { tenantId, role, via: ancestor, depth: index + 1 };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Every tenant a caller may act as: each membership's own tenant, plus — for a
|
|
77
|
+
* membership whose role inherits — every descendant of that tenant, carrying the
|
|
78
|
+
* role. A tenant reached more than once keeps the **nearest** claim to it: an
|
|
79
|
+
* explicit membership (depth 0) over any inherited one, a parent's role over a
|
|
80
|
+
* grandparent's. `descendantsOf` is asked once per membership whose role
|
|
81
|
+
* inherits, so a caller with only leaf memberships costs no tree read at all.
|
|
82
|
+
*
|
|
83
|
+
* The result is unordered; the app joins names and sorts for display.
|
|
84
|
+
*/
|
|
85
|
+
export function reachableTenants(memberships, descendantsOf, options = {}) {
|
|
86
|
+
const held = asMap(memberships);
|
|
87
|
+
const inherits = options.inherits ?? ALL_ROLES_INHERIT;
|
|
88
|
+
const best = new Map();
|
|
89
|
+
const offer = (candidate) => {
|
|
90
|
+
const current = best.get(candidate.tenantId);
|
|
91
|
+
if (!current || candidate.depth < current.depth)
|
|
92
|
+
best.set(candidate.tenantId, candidate);
|
|
93
|
+
};
|
|
94
|
+
for (const [tenantId, role] of held) {
|
|
95
|
+
offer({ tenantId, role, via: tenantId, depth: 0 });
|
|
96
|
+
if (!inherits(role, tenantId))
|
|
97
|
+
continue;
|
|
98
|
+
for (const { tenantId: descendant, depth } of descendantsOf(tenantId)) {
|
|
99
|
+
if (depth < 1)
|
|
100
|
+
continue;
|
|
101
|
+
offer({ tenantId: descendant, role, via: tenantId, depth });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return [...best.values()];
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=reach.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reach.js","sourceRoot":"","sources":["../../src/hierarchy/reach.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tenant tree in the store: the parent edge on the tenants table and the
|
|
3
|
+
* closure table beside it (`0000_vk_tenancy_tree.sql`), read and maintained here.
|
|
4
|
+
*
|
|
5
|
+
* Two structures, one owner. `parent_id` is the fact; `vk_tenant_closure` is the
|
|
6
|
+
* fact unrolled — every ancestor→descendant path with its depth, the self-row
|
|
7
|
+
* at 0 — so the questions a request asks ("the tenants under X", "the ancestors
|
|
8
|
+
* of Y") are one indexed read each. Both are written **only** through
|
|
9
|
+
* {@link TenantTree.setParent} / {@link TenantTree.rebuildClosure}, which
|
|
10
|
+
* rebuild the closure wholesale for the tenant and its whole subtree: the
|
|
11
|
+
* graphs are small and shallow, and wholesale is easier to prove right than
|
|
12
|
+
* incremental edits. No trigger does it — a rule in a trigger runs where the
|
|
13
|
+
* tests do not look.
|
|
14
|
+
*
|
|
15
|
+
* The table names are configurable ({@link TenantTreeTables}) because a service
|
|
16
|
+
* that only *projects* tenants from another system keeps the same two structures
|
|
17
|
+
* under its own names (a `tenant_ref` with a `parent_id`, a closure beside it)
|
|
18
|
+
* and wants the same maintenance code rather than a copy.
|
|
19
|
+
*
|
|
20
|
+
* The hierarchy *rules* — what a role in a parent is worth in a child — are
|
|
21
|
+
* `./reach.ts`, pure; {@link TenantTree.reach} and {@link TenantTree.roleIn} are
|
|
22
|
+
* those rules fed from this store.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* const tree = createTenantTree(); // vk_tenants / vk_tenant_closure
|
|
27
|
+
* await tree.setParent(query, schoolId, groupId); // refuses a cycle
|
|
28
|
+
* const sites = await tree.reach(query, unpackTenantRoles(claims['custom:tenantRoles']), { inherits: isSystemRole });
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
import type { HeldTenantRole, HierarchyOptions, TenantDescendant, TenantMembership } from './reach.js';
|
|
32
|
+
/**
|
|
33
|
+
* Any querier that runs SQL with positional parameters and resolves to the rows —
|
|
34
|
+
* `@venturekit/data`'s `query`, a transaction's `query`, a `pg` client wrapped.
|
|
35
|
+
* Row columns are read under the aliases the statements give them, so a client
|
|
36
|
+
* that maps snake_case to camelCase and one that does not both work.
|
|
37
|
+
*/
|
|
38
|
+
export type TreeQuerier = (sql: string, params?: unknown[]) => Promise<unknown>;
|
|
39
|
+
/** Where the tree lives. Defaults are the package's own tables. */
|
|
40
|
+
export interface TenantTreeTables {
|
|
41
|
+
/** The tenants table; must have `id` and the parent column. */
|
|
42
|
+
tenants: string;
|
|
43
|
+
/** The parent edge column on {@link tenants}. */
|
|
44
|
+
parentColumn: string;
|
|
45
|
+
/** The closure table: `ancestor_id`, `descendant_id`, `depth`. */
|
|
46
|
+
closure: string;
|
|
47
|
+
}
|
|
48
|
+
export declare const VK_TENANT_TREE: TenantTreeTables;
|
|
49
|
+
/** How deep a tree may go — a guard against a runaway walk, not a product limit anyone should meet. */
|
|
50
|
+
export declare const TENANT_TREE_MAX_DEPTH = 16;
|
|
51
|
+
/** A tenant's place relative to another: how many hops away. */
|
|
52
|
+
export interface TenantAncestor {
|
|
53
|
+
tenantId: string;
|
|
54
|
+
/** Hops up from the tenant asked about: the parent is `1`. */
|
|
55
|
+
depth: number;
|
|
56
|
+
}
|
|
57
|
+
export interface TenantTree {
|
|
58
|
+
readonly tables: TenantTreeTables;
|
|
59
|
+
/**
|
|
60
|
+
* Make `parentId` the parent of `tenantId` (or `null` to make it a root) and
|
|
61
|
+
* rebuild the closure for the tenant and everything under it. Refuses a tenant
|
|
62
|
+
* as its own ancestor ({@link TenantTreeCycleError}) and a tree deeper than
|
|
63
|
+
* {@link TENANT_TREE_MAX_DEPTH} ({@link TenantTreeDepthError}). Idempotent for
|
|
64
|
+
* an unchanged parent — safe to call right after inserting the tenant row.
|
|
65
|
+
*/
|
|
66
|
+
setParent(q: TreeQuerier, tenantId: string, parentId: string | null): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* Rewrite the closure rows of `tenantId` and its whole subtree from the parent
|
|
69
|
+
* column as it stands — for a row whose parent was written by other means (a
|
|
70
|
+
* projection, a backfill). What `setParent` calls after writing the edge.
|
|
71
|
+
*/
|
|
72
|
+
rebuildClosure(q: TreeQuerier, tenantId: string): Promise<void>;
|
|
73
|
+
/** The ancestors of a tenant, nearest first; empty for a root. */
|
|
74
|
+
ancestorsOf(q: TreeQuerier, tenantId: string): Promise<TenantAncestor[]>;
|
|
75
|
+
/** Every tenant under a tenant, shallowest first; empty for a leaf. */
|
|
76
|
+
descendantsOf(q: TreeQuerier, tenantId: string): Promise<TenantDescendant[]>;
|
|
77
|
+
/** The descendants of several tenants in one read, keyed by the tenant asked about (absent = none). */
|
|
78
|
+
descendantsOfAll(q: TreeQuerier, tenantIds: readonly string[]): Promise<Map<string, TenantDescendant[]>>;
|
|
79
|
+
/** `reachableTenants` over this tree: every tenant a caller with these memberships may act as. One read, for the inheriting memberships only. */
|
|
80
|
+
reach(q: TreeQuerier, memberships: ReadonlyMap<string, string> | readonly TenantMembership[], options?: HierarchyOptions): Promise<HeldTenantRole[]>;
|
|
81
|
+
/** `inheritedTenantRole` over this tree: the role held in `tenantId`, or `null`. The store is read only when the memberships alone do not settle it. */
|
|
82
|
+
roleIn(q: TreeQuerier, memberships: ReadonlyMap<string, string> | readonly TenantMembership[], tenantId: string, options?: HierarchyOptions): Promise<HeldTenantRole | null>;
|
|
83
|
+
}
|
|
84
|
+
export declare class TenantTreeCycleError extends Error {
|
|
85
|
+
constructor(tenantId: string, parentId: string);
|
|
86
|
+
}
|
|
87
|
+
export declare class TenantTreeDepthError extends Error {
|
|
88
|
+
constructor(tenantId: string, depth: number);
|
|
89
|
+
}
|
|
90
|
+
export declare function createTenantTree(tables?: Partial<TenantTreeTables>): TenantTree;
|
|
91
|
+
//# sourceMappingURL=tree.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tree.d.ts","sourceRoot":"","sources":["../../src/hierarchy/tree.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAGH,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEvG;;;;;GAKG;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAEhF,mEAAmE;AACnE,MAAM,WAAW,gBAAgB;IAC/B,+DAA+D;IAC/D,OAAO,EAAE,MAAM,CAAC;IAChB,iDAAiD;IACjD,YAAY,EAAE,MAAM,CAAC;IACrB,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,eAAO,MAAM,cAAc,EAAE,gBAI5B,CAAC;AAEF,uGAAuG;AACvG,eAAO,MAAM,qBAAqB,KAAK,CAAC;AAExC,gEAAgE;AAChE,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,8DAA8D;IAC9D,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAClC;;;;;;OAMG;IACH,SAAS,CAAC,CAAC,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpF;;;;OAIG;IACH,cAAc,CAAC,CAAC,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChE,kEAAkE;IAClE,WAAW,CAAC,CAAC,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CAAC;IACzE,uEAAuE;IACvE,aAAa,CAAC,CAAC,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAAC;IAC7E,uGAAuG;IACvG,gBAAgB,CAAC,CAAC,EAAE,WAAW,EAAE,SAAS,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAC,CAAC,CAAC;IACzG,iJAAiJ;IACjJ,KAAK,CAAC,CAAC,EAAE,WAAW,EAAE,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,gBAAgB,EAAE,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CAAC;IACrJ,wJAAwJ;IACxJ,MAAM,CAAC,CAAC,EAAE,WAAW,EAAE,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,gBAAgB,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;CAC9K;AAED,qBAAa,oBAAqB,SAAQ,KAAK;gBACjC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;CAI/C;AAED,qBAAa,oBAAqB,SAAQ,KAAK;gBACjC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM;CAI5C;AAgBD,wBAAgB,gBAAgB,CAAC,MAAM,GAAE,OAAO,CAAC,gBAAgB,CAAM,GAAG,UAAU,CAoGnF"}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tenant tree in the store: the parent edge on the tenants table and the
|
|
3
|
+
* closure table beside it (`0000_vk_tenancy_tree.sql`), read and maintained here.
|
|
4
|
+
*
|
|
5
|
+
* Two structures, one owner. `parent_id` is the fact; `vk_tenant_closure` is the
|
|
6
|
+
* fact unrolled — every ancestor→descendant path with its depth, the self-row
|
|
7
|
+
* at 0 — so the questions a request asks ("the tenants under X", "the ancestors
|
|
8
|
+
* of Y") are one indexed read each. Both are written **only** through
|
|
9
|
+
* {@link TenantTree.setParent} / {@link TenantTree.rebuildClosure}, which
|
|
10
|
+
* rebuild the closure wholesale for the tenant and its whole subtree: the
|
|
11
|
+
* graphs are small and shallow, and wholesale is easier to prove right than
|
|
12
|
+
* incremental edits. No trigger does it — a rule in a trigger runs where the
|
|
13
|
+
* tests do not look.
|
|
14
|
+
*
|
|
15
|
+
* The table names are configurable ({@link TenantTreeTables}) because a service
|
|
16
|
+
* that only *projects* tenants from another system keeps the same two structures
|
|
17
|
+
* under its own names (a `tenant_ref` with a `parent_id`, a closure beside it)
|
|
18
|
+
* and wants the same maintenance code rather than a copy.
|
|
19
|
+
*
|
|
20
|
+
* The hierarchy *rules* — what a role in a parent is worth in a child — are
|
|
21
|
+
* `./reach.ts`, pure; {@link TenantTree.reach} and {@link TenantTree.roleIn} are
|
|
22
|
+
* those rules fed from this store.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* const tree = createTenantTree(); // vk_tenants / vk_tenant_closure
|
|
27
|
+
* await tree.setParent(query, schoolId, groupId); // refuses a cycle
|
|
28
|
+
* const sites = await tree.reach(query, unpackTenantRoles(claims['custom:tenantRoles']), { inherits: isSystemRole });
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
import { inheritedTenantRole, reachableTenants } from './reach.js';
|
|
32
|
+
export const VK_TENANT_TREE = {
|
|
33
|
+
tenants: 'vk_tenants',
|
|
34
|
+
parentColumn: 'parent_id',
|
|
35
|
+
closure: 'vk_tenant_closure',
|
|
36
|
+
};
|
|
37
|
+
/** How deep a tree may go — a guard against a runaway walk, not a product limit anyone should meet. */
|
|
38
|
+
export const TENANT_TREE_MAX_DEPTH = 16;
|
|
39
|
+
export class TenantTreeCycleError extends Error {
|
|
40
|
+
constructor(tenantId, parentId) {
|
|
41
|
+
super(`Tenant ${tenantId} cannot have ${parentId} as its parent: that tenant is beneath it`);
|
|
42
|
+
this.name = 'TenantTreeCycleError';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export class TenantTreeDepthError extends Error {
|
|
46
|
+
constructor(tenantId, depth) {
|
|
47
|
+
super(`Tenant ${tenantId} would sit ${depth} levels deep; the tree stops at ${TENANT_TREE_MAX_DEPTH}`);
|
|
48
|
+
this.name = 'TenantTreeDepthError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/* SQL identifiers come from code, never from a request — but a typo must not become SQL. */
|
|
52
|
+
const IDENTIFIER = /^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)?$/;
|
|
53
|
+
function identifier(value, what) {
|
|
54
|
+
if (!IDENTIFIER.test(value))
|
|
55
|
+
throw new Error(`createTenantTree: ${what} '${value}' is not a plain SQL identifier`);
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
function asMemberships(memberships) {
|
|
59
|
+
return memberships instanceof Map
|
|
60
|
+
? [...memberships].map(([tenantId, role]) => ({ tenantId, role }))
|
|
61
|
+
: [...memberships];
|
|
62
|
+
}
|
|
63
|
+
export function createTenantTree(tables = {}) {
|
|
64
|
+
const T = identifier(tables.tenants ?? VK_TENANT_TREE.tenants, 'tenants table');
|
|
65
|
+
const P = identifier(tables.parentColumn ?? VK_TENANT_TREE.parentColumn, 'parent column');
|
|
66
|
+
const C = identifier(tables.closure ?? VK_TENANT_TREE.closure, 'closure table');
|
|
67
|
+
const rows = async (q, sql, params) => (await q(sql, params));
|
|
68
|
+
/* the tenant and everything under it, walking the parent column (the closure may be what is being repaired) */
|
|
69
|
+
const subtree = `
|
|
70
|
+
with recursive sub as (
|
|
71
|
+
select id, 0 as depth from ${T} where id = $1::uuid
|
|
72
|
+
union all
|
|
73
|
+
select t.id, s.depth + 1 from ${T} t join sub s on t.${P} = s.id where s.depth < ${TENANT_TREE_MAX_DEPTH}
|
|
74
|
+
)`;
|
|
75
|
+
const tree = {
|
|
76
|
+
tables: { tenants: T, parentColumn: P, closure: C },
|
|
77
|
+
async setParent(q, tenantId, parentId) {
|
|
78
|
+
if (parentId !== null) {
|
|
79
|
+
if (parentId === tenantId)
|
|
80
|
+
throw new TenantTreeCycleError(tenantId, parentId);
|
|
81
|
+
/* the new parent's ancestry, by the parent column: the tenant must not be in it, and the depth must fit */
|
|
82
|
+
const up = await rows(q, `with recursive up as (
|
|
83
|
+
select id as "tenantId", 0 as depth, ${P} as parent_id from ${T} where id = $1::uuid
|
|
84
|
+
union all
|
|
85
|
+
select t.id, up.depth + 1, t.${P} from ${T} t join up on t.id = up.parent_id where up.depth < ${TENANT_TREE_MAX_DEPTH + 1}
|
|
86
|
+
)
|
|
87
|
+
select "tenantId", depth from up`, [parentId]);
|
|
88
|
+
if (up.some((a) => a.tenantId === tenantId))
|
|
89
|
+
throw new TenantTreeCycleError(tenantId, parentId);
|
|
90
|
+
const parentDepth = up.length; // the parent's own row counts: a root parent puts the tenant at depth 1
|
|
91
|
+
const below = await rows(q, `${subtree} select coalesce(max(depth), 0)::int as n from sub`, [tenantId]);
|
|
92
|
+
const deepest = parentDepth + Number(below[0]?.n ?? 0);
|
|
93
|
+
if (deepest > TENANT_TREE_MAX_DEPTH)
|
|
94
|
+
throw new TenantTreeDepthError(tenantId, deepest);
|
|
95
|
+
}
|
|
96
|
+
await q(`update ${T} set ${P} = $2::uuid where id = $1::uuid`, [tenantId, parentId]);
|
|
97
|
+
await tree.rebuildClosure(q, tenantId);
|
|
98
|
+
},
|
|
99
|
+
async rebuildClosure(q, tenantId) {
|
|
100
|
+
await q(`${subtree} delete from ${C} where descendant_id in (select id from sub)`, [tenantId]);
|
|
101
|
+
await q(`${subtree},
|
|
102
|
+
up as (
|
|
103
|
+
select s.id as descendant_id, s.id as ancestor_id, 0 as depth, t.${P} as parent_id
|
|
104
|
+
from sub s join ${T} t on t.id = s.id
|
|
105
|
+
union all
|
|
106
|
+
select up.descendant_id, t.id, up.depth + 1, t.${P}
|
|
107
|
+
from up join ${T} t on t.id = up.parent_id
|
|
108
|
+
where up.depth < ${TENANT_TREE_MAX_DEPTH}
|
|
109
|
+
)
|
|
110
|
+
insert into ${C} (ancestor_id, descendant_id, depth)
|
|
111
|
+
select ancestor_id, descendant_id, depth from up`, [tenantId]);
|
|
112
|
+
},
|
|
113
|
+
async ancestorsOf(q, tenantId) {
|
|
114
|
+
return rows(q, `select ancestor_id as "tenantId", depth from ${C} where descendant_id = $1::uuid and depth > 0 order by depth`, [tenantId]);
|
|
115
|
+
},
|
|
116
|
+
async descendantsOf(q, tenantId) {
|
|
117
|
+
return rows(q, `select descendant_id as "tenantId", depth from ${C} where ancestor_id = $1::uuid and depth > 0 order by depth`, [tenantId]);
|
|
118
|
+
},
|
|
119
|
+
async descendantsOfAll(q, tenantIds) {
|
|
120
|
+
const out = new Map();
|
|
121
|
+
if (tenantIds.length === 0)
|
|
122
|
+
return out;
|
|
123
|
+
const found = await rows(q, `select ancestor_id as "rootId", descendant_id as "tenantId", depth from ${C} where ancestor_id = any($1::uuid[]) and depth > 0 order by depth`, [[...tenantIds]]);
|
|
124
|
+
for (const { rootId, tenantId, depth } of found) {
|
|
125
|
+
const list = out.get(rootId) ?? [];
|
|
126
|
+
list.push({ tenantId, depth: Number(depth) });
|
|
127
|
+
out.set(rootId, list);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
},
|
|
131
|
+
async reach(q, memberships, options = {}) {
|
|
132
|
+
const held = asMemberships(memberships);
|
|
133
|
+
const inherits = options.inherits ?? (() => true);
|
|
134
|
+
const roots = held.filter((m) => inherits(m.role, m.tenantId)).map((m) => m.tenantId);
|
|
135
|
+
const below = await tree.descendantsOfAll(q, roots);
|
|
136
|
+
return reachableTenants(held, (tenantId) => below.get(tenantId) ?? [], options);
|
|
137
|
+
},
|
|
138
|
+
async roleIn(q, memberships, tenantId, options = {}) {
|
|
139
|
+
const held = asMemberships(memberships);
|
|
140
|
+
const direct = inheritedTenantRole(held, tenantId, [], options);
|
|
141
|
+
if (direct || held.length === 0)
|
|
142
|
+
return direct;
|
|
143
|
+
const ancestors = await tree.ancestorsOf(q, tenantId);
|
|
144
|
+
return inheritedTenantRole(held, tenantId, ancestors.map((a) => a.tenantId), options);
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
return tree;
|
|
148
|
+
}
|
|
149
|
+
//# sourceMappingURL=tree.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tree.js","sourceRoot":"","sources":["../../src/hierarchy/tree.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export { TenantContext, createTenantContext, getCurrentTenant, resolveTenant, }
|
|
|
12
12
|
export * from './middleware/index.js';
|
|
13
13
|
export { listTenants, getTenantById, getTenantBySlug, getTenantByPrimaryDomain, createTenant, updateTenant, setTenantStatus, isUniqueViolation as isTenantUniqueViolation, } from './crud/index.js';
|
|
14
14
|
export type { TenantRecord, TenantWriteFields, TenantStatus as TenantDbStatus, } from './crud/index.js';
|
|
15
|
+
export * from './hierarchy/index.js';
|
|
15
16
|
export { suspendTenant, archiveTenant, restoreTenant, hardDeleteTenant, planCascade, executeCascade, planUserErasure, executeUserErasure, exportUserData, } from './lifecycle/index.js';
|
|
16
17
|
export type { TenantLifecycleStatus, LifecycleArgs, LifecycleOperationArgs, HardDeleteArgs, LifecycleAuditEvent, LifecycleAuditSink, CascadePlanEntry, CascadeOptions, CascadeResult, UserDataOptions, UserDataExport, } from './lifecycle/index.js';
|
|
17
18
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,cAAc,kBAAkB,CAAC;AAGjC,OAAO,EACL,aAAa,EACb,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,GACd,MAAM,oBAAoB,CAAC;AAG5B,cAAc,uBAAuB,CAAC;AAGtC,OAAO,EACL,WAAW,EACX,aAAa,EACb,eAAe,EACf,wBAAwB,EACxB,YAAY,EACZ,YAAY,EACZ,eAAe,EACf,iBAAiB,IAAI,uBAAuB,GAC7C,MAAM,iBAAiB,CAAC;AACzB,YAAY,EACV,YAAY,EACZ,iBAAiB,EAGjB,YAAY,IAAI,cAAc,GAC/B,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,cAAc,kBAAkB,CAAC;AAGjC,OAAO,EACL,aAAa,EACb,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,GACd,MAAM,oBAAoB,CAAC;AAG5B,cAAc,uBAAuB,CAAC;AAGtC,OAAO,EACL,WAAW,EACX,aAAa,EACb,eAAe,EACf,wBAAwB,EACxB,YAAY,EACZ,YAAY,EACZ,eAAe,EACf,iBAAiB,IAAI,uBAAuB,GAC7C,MAAM,iBAAiB,CAAC;AACzB,YAAY,EACV,YAAY,EACZ,iBAAiB,EAGjB,YAAY,IAAI,cAAc,GAC/B,MAAM,iBAAiB,CAAC;AAMzB,cAAc,sBAAsB,CAAC;AASrC,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,gBAAgB,EAChB,WAAW,EACX,cAAc,EACd,eAAe,EACf,kBAAkB,EAClB,cAAc,GACf,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EACV,qBAAqB,EACrB,aAAa,EACb,sBAAsB,EACtB,cAAc,EACd,mBAAmB,EACnB,kBAAkB,EAClB,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,eAAe,EACf,cAAc,GACf,MAAM,sBAAsB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,11 @@ export { createTenantContext, getCurrentTenant, resolveTenant, } from './context
|
|
|
15
15
|
export * from './middleware/index.js';
|
|
16
16
|
// Bare vk_tenants CRUD (column-agnostic create / read / update)
|
|
17
17
|
export { listTenants, getTenantById, getTenantBySlug, getTenantByPrimaryDomain, createTenant, updateTenant, setTenantStatus, isUniqueViolation as isTenantUniqueViolation, } from './crud/index.js';
|
|
18
|
+
// Hierarchy: the tree in the store (`createTenantTree` — `vk_tenants.parent_id` +
|
|
19
|
+
// `vk_tenant_closure`, or a projection's own pair) and the rules over it (a role in a
|
|
20
|
+
// parent tenant, held in its descendants: `inheritedTenantRole` for a request naming
|
|
21
|
+
// another tenant, `reachableTenants` for a session's tenant switcher)
|
|
22
|
+
export * from './hierarchy/index.js';
|
|
18
23
|
// Role → scopes mapping lives in @venturekit/auth (vk_role_scopes +
|
|
19
24
|
// createRoleScopesResolver) — baseline authorization, useful without
|
|
20
25
|
// tenancy. The scopes middleware consumes it through the structural
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,QAAQ;AACR,cAAc,kBAAkB,CAAC;AAEjC,UAAU;AACV,OAAO,EAEL,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,GACd,MAAM,oBAAoB,CAAC;AAE5B,aAAa;AACb,cAAc,uBAAuB,CAAC;AAEtC,gEAAgE;AAChE,OAAO,EACL,WAAW,EACX,aAAa,EACb,eAAe,EACf,wBAAwB,EACxB,YAAY,EACZ,YAAY,EACZ,eAAe,EACf,iBAAiB,IAAI,uBAAuB,GAC7C,MAAM,iBAAiB,CAAC;AASzB,oEAAoE;AACpE,qEAAqE;AACrE,oEAAoE;AACpE,gEAAgE;AAEhE,yEAAyE;AACzE,6BAA6B;AAC7B,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,gBAAgB,EAChB,WAAW,EACX,cAAc,EACd,eAAe,EACf,kBAAkB,EAClB,cAAc,GACf,MAAM,sBAAsB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,QAAQ;AACR,cAAc,kBAAkB,CAAC;AAEjC,UAAU;AACV,OAAO,EAEL,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,GACd,MAAM,oBAAoB,CAAC;AAE5B,aAAa;AACb,cAAc,uBAAuB,CAAC;AAEtC,gEAAgE;AAChE,OAAO,EACL,WAAW,EACX,aAAa,EACb,eAAe,EACf,wBAAwB,EACxB,YAAY,EACZ,YAAY,EACZ,eAAe,EACf,iBAAiB,IAAI,uBAAuB,GAC7C,MAAM,iBAAiB,CAAC;AASzB,kFAAkF;AAClF,sFAAsF;AACtF,qFAAqF;AACrF,sEAAsE;AACtE,cAAc,sBAAsB,CAAC;AAErC,oEAAoE;AACpE,qEAAqE;AACrE,oEAAoE;AACpE,gEAAgE;AAEhE,yEAAyE;AACzE,6BAA6B;AAC7B,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,gBAAgB,EAChB,WAAW,EACX,cAAc,EACd,eAAe,EACf,kBAAkB,EAClB,cAAc,GACf,MAAM,sBAAsB,CAAC"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- @venturekit-pro/tenancy — the tenant tree.
|
|
2
|
+
--
|
|
3
|
+
-- Multi-tenant products grow hierarchies: a group and its schools, a franchise
|
|
4
|
+
-- and its stores, an agency and its clients' workspaces. Two structures carry it:
|
|
5
|
+
--
|
|
6
|
+
-- - `vk_tenants.parent_id` — the edge, one per tenant, NULL at a root.
|
|
7
|
+
-- - `vk_tenant_closure` — every ancestor→descendant path, the self-row at
|
|
8
|
+
-- depth 0 included, so "the tenants under X" and "the ancestors of Y" are one
|
|
9
|
+
-- indexed read each rather than a recursive walk per request.
|
|
10
|
+
--
|
|
11
|
+
-- The closure is maintained in CODE (`createTenantTree().setParent` /
|
|
12
|
+
-- `rebuildClosure`), wholesale for the re-parented tenant's whole subtree —
|
|
13
|
+
-- small, shallow graphs, and wholesale is easier to prove right than incremental
|
|
14
|
+
-- edits. No trigger: a rule in a trigger runs where tests do not look.
|
|
15
|
+
--
|
|
16
|
+
-- Named to sort right after `0000_vk_tenancy_tenants.sql` and before any
|
|
17
|
+
-- project migration, so a project's own backfill (copying a parent column of
|
|
18
|
+
-- its overlay table into `parent_id`, say) can rely on both structures.
|
|
19
|
+
|
|
20
|
+
ALTER TABLE vk_tenants
|
|
21
|
+
ADD COLUMN IF NOT EXISTS parent_id uuid REFERENCES vk_tenants (id) ON DELETE RESTRICT;
|
|
22
|
+
|
|
23
|
+
-- a tenant is never its own parent; deeper cycles are refused in code (`setParent`)
|
|
24
|
+
ALTER TABLE vk_tenants
|
|
25
|
+
DROP CONSTRAINT IF EXISTS vk_tenants_no_self_parent;
|
|
26
|
+
ALTER TABLE vk_tenants
|
|
27
|
+
ADD CONSTRAINT vk_tenants_no_self_parent CHECK (parent_id IS NULL OR parent_id <> id);
|
|
28
|
+
|
|
29
|
+
CREATE INDEX IF NOT EXISTS vk_tenants_parent_idx ON vk_tenants (parent_id) WHERE parent_id IS NOT NULL;
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS vk_tenant_closure (
|
|
32
|
+
ancestor_id uuid NOT NULL REFERENCES vk_tenants (id) ON DELETE CASCADE,
|
|
33
|
+
descendant_id uuid NOT NULL REFERENCES vk_tenants (id) ON DELETE CASCADE,
|
|
34
|
+
-- 0 is the tenant itself; a child is 1
|
|
35
|
+
depth integer NOT NULL CHECK (depth >= 0),
|
|
36
|
+
PRIMARY KEY (ancestor_id, descendant_id)
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
CREATE INDEX IF NOT EXISTS vk_tenant_closure_descendant_idx ON vk_tenant_closure (descendant_id);
|
|
40
|
+
|
|
41
|
+
-- every existing tenant is a root until told otherwise: its self-row
|
|
42
|
+
INSERT INTO vk_tenant_closure (ancestor_id, descendant_id, depth)
|
|
43
|
+
SELECT id, id, 0 FROM vk_tenants
|
|
44
|
+
ON CONFLICT DO NOTHING;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@venturekit-pro/tenancy",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.41",
|
|
4
4
|
"description": "Multi-tenant utilities for VentureKit",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -30,11 +30,11 @@
|
|
|
30
30
|
}
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@venturekit/core": "0.0.
|
|
33
|
+
"@venturekit/core": "0.0.41"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
|
-
"@venturekit/data": "0.0.
|
|
37
|
-
"@venturekit/runtime": "0.0.
|
|
36
|
+
"@venturekit/data": "0.0.41",
|
|
37
|
+
"@venturekit/runtime": "0.0.41"
|
|
38
38
|
},
|
|
39
39
|
"peerDependenciesMeta": {
|
|
40
40
|
"@venturekit/data": {
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^26.5.1",
|
|
49
|
-
"@venturekit/data": "0.0.
|
|
50
|
-
"@venturekit/runtime": "0.0.
|
|
49
|
+
"@venturekit/data": "0.0.41",
|
|
50
|
+
"@venturekit/runtime": "0.0.41",
|
|
51
51
|
"typescript": "^5.3.0"
|
|
52
52
|
},
|
|
53
53
|
"scripts": {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- @venturekit-pro/tenancy — the tenant tree.
|
|
2
|
+
--
|
|
3
|
+
-- Multi-tenant products grow hierarchies: a group and its schools, a franchise
|
|
4
|
+
-- and its stores, an agency and its clients' workspaces. Two structures carry it:
|
|
5
|
+
--
|
|
6
|
+
-- - `vk_tenants.parent_id` — the edge, one per tenant, NULL at a root.
|
|
7
|
+
-- - `vk_tenant_closure` — every ancestor→descendant path, the self-row at
|
|
8
|
+
-- depth 0 included, so "the tenants under X" and "the ancestors of Y" are one
|
|
9
|
+
-- indexed read each rather than a recursive walk per request.
|
|
10
|
+
--
|
|
11
|
+
-- The closure is maintained in CODE (`createTenantTree().setParent` /
|
|
12
|
+
-- `rebuildClosure`), wholesale for the re-parented tenant's whole subtree —
|
|
13
|
+
-- small, shallow graphs, and wholesale is easier to prove right than incremental
|
|
14
|
+
-- edits. No trigger: a rule in a trigger runs where tests do not look.
|
|
15
|
+
--
|
|
16
|
+
-- Named to sort right after `0000_vk_tenancy_tenants.sql` and before any
|
|
17
|
+
-- project migration, so a project's own backfill (copying a parent column of
|
|
18
|
+
-- its overlay table into `parent_id`, say) can rely on both structures.
|
|
19
|
+
|
|
20
|
+
ALTER TABLE vk_tenants
|
|
21
|
+
ADD COLUMN IF NOT EXISTS parent_id uuid REFERENCES vk_tenants (id) ON DELETE RESTRICT;
|
|
22
|
+
|
|
23
|
+
-- a tenant is never its own parent; deeper cycles are refused in code (`setParent`)
|
|
24
|
+
ALTER TABLE vk_tenants
|
|
25
|
+
DROP CONSTRAINT IF EXISTS vk_tenants_no_self_parent;
|
|
26
|
+
ALTER TABLE vk_tenants
|
|
27
|
+
ADD CONSTRAINT vk_tenants_no_self_parent CHECK (parent_id IS NULL OR parent_id <> id);
|
|
28
|
+
|
|
29
|
+
CREATE INDEX IF NOT EXISTS vk_tenants_parent_idx ON vk_tenants (parent_id) WHERE parent_id IS NOT NULL;
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS vk_tenant_closure (
|
|
32
|
+
ancestor_id uuid NOT NULL REFERENCES vk_tenants (id) ON DELETE CASCADE,
|
|
33
|
+
descendant_id uuid NOT NULL REFERENCES vk_tenants (id) ON DELETE CASCADE,
|
|
34
|
+
-- 0 is the tenant itself; a child is 1
|
|
35
|
+
depth integer NOT NULL CHECK (depth >= 0),
|
|
36
|
+
PRIMARY KEY (ancestor_id, descendant_id)
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
CREATE INDEX IF NOT EXISTS vk_tenant_closure_descendant_idx ON vk_tenant_closure (descendant_id);
|
|
40
|
+
|
|
41
|
+
-- every existing tenant is a root until told otherwise: its self-row
|
|
42
|
+
INSERT INTO vk_tenant_closure (ancestor_id, descendant_id, depth)
|
|
43
|
+
SELECT id, id, 0 FROM vk_tenants
|
|
44
|
+
ON CONFLICT DO NOTHING;
|