Organization Hierarchy¶
Craft Easy supports two independent hierarchies:
- Tenant hierarchy — parent tenants and sub-tenants, modeled on the
Tenantdocument itself. A parent tenant can spawn child tenants, each with their own users, data, and features. This is how a group company models its regional subsidiaries. - Organization hierarchy — a tenant-internal tree of
OrgNodedocuments. Levels are named by the tenant ("Region → District → Store", or "Region → Facility → Department"), and each node groups users and resources under a single tenant.
Both use the materialized path pattern so ancestor and descendant queries run in constant time regardless of depth.
Why two hierarchies?¶
The tenant hierarchy is a permission boundary — a sub-tenant has its own users, its own features, its own data. The organization hierarchy is a reporting boundary — nodes live inside one tenant, share the tenant's users, and exist to group records for dashboards, cost centers, and access scoping.
Use the tenant hierarchy when the regions have their own staff and data that must not leak sideways. Use the organization hierarchy when you want a tree structure inside a single tenant — for example, to filter reports by store or to hang attribute-based access off a specific department.
The materialized path pattern¶
Both Tenant and OrgNode carry three fields that together describe their position:
| Field | Example | Purpose |
|---|---|---|
parent_tenant_id / parent_id |
65ab... |
Pointer to the immediate parent, or None for a root |
tenant_path / path |
65a1,65a7,65ab |
Comma-separated ancestor IDs from root to immediate parent |
depth / level |
3 |
How many levels below the root (0 = root) |
Ancestors are resolved by parsing the path. Descendants are resolved with a single regex query:
The regex matches any document whose path contains 65ab as a standalone ID segment, anywhere in the chain. Indexes on tenant_path and depth keep these queries O(log n) regardless of how deep the tree goes.
Whenever a parent changes (reparenting, delete-and-recreate) the framework walks the subtree and rewrites path and depth for every descendant. Application code never computes paths manually.
Tenant hierarchy¶
A child tenant is created through the dedicated endpoint:
POST /tenants/{parent_tenant_id}/sub-tenants
{
"name": "North Region",
"slug": "north-region",
"enabled_features": ["sales"]
}
The hook _compute_hierarchy_on_create fills in the rest:
- Computes
tenant_pathanddepthfrom the parent. - Calls
assert_sub_tenant_depth_allowedto enforceSUB_TENANT_MAX_DEPTH. - Calls
assert_sub_tenant_child_limitto enforceSUB_TENANT_MAX_CHILDREN. - Calls
assert_features_subsetso the child cannot enable a feature the parent lacks. An emptyenabled_featureson the parent is treated as "all features are available". - Inherits
partner_idfrom the parent unless overridden.
Tree, ancestors, and reparenting¶
Three read endpoints are available on every tenant:
GET /tenants/{tenant_id}/sub-tenants # direct children only
GET /tenants/{tenant_id}/tree # full subtree as nested JSON
GET /tenants/{tenant_id}/ancestors # root-first ancestor list, excluding self
The hierarchy helpers in craft_easy.core.tenant provide the same data to Python callers:
from craft_easy.core.tenant import (
get_tenant_tree,
get_tenant_ancestors,
has_children,
rebuild_tenant_paths,
)
descendants = await get_tenant_tree(tenant_id) # includes self
ancestors = await get_tenant_ancestors(tenant_id) # root-first, excludes self
Reparenting is supported by updating parent_tenant_id through the standard PATCH route. The _rebuild_paths_after_update hook calls rebuild_tenant_paths(tenant_id), which walks the moved subtree and rewrites tenant_path and depth on every descendant in a single pass.
Feature inheritance rules¶
When a parent changes its enabled_features, assert_descendants_features_subset runs before the write commits. If shrinking the parent's feature list would strip a feature that a descendant currently has enabled, the update is rejected with 403 listing which descendants would break. This prevents orphaned feature grants — a descendant can never hold a feature its parent does not own.
Hierarchy limits¶
Two settings cap the shape of the tree:
SUB_TENANT_MAX_DEPTH: int = 3 # deepest allowed level (root=0)
SUB_TENANT_MAX_CHILDREN: int = 100 # max direct children per parent
Raise either cautiously — the regex descendant query is cheap but the payload size of /tree is not. Most deployments stabilize at two or three levels.
Organization hierarchy¶
Inside a single tenant, create a OrgNode tree for reporting and grouping. Unlike the tenant hierarchy, the level labels are tenant-defined — one tenant may call their levels "Region → Facility → Department" while another uses "Country → City → Store".
Level configuration¶
Each tenant owns an OrgHierarchyConfig document that names their levels:
POST /org-hierarchy-configs
{
"levels": [
{"level": 0, "name": "Region", "required": true},
{"level": 1, "name": "District", "required": false},
{"level": 2, "name": "Store", "required": false}
],
"max_depth": 3
}
OrgHierarchyConfig.get_level_name(level) returns the configured label for a depth, and get_required_levels() returns levels that every leaf must traverse. The config is purely for metadata — it does not block writes directly, but the CRUD route uses it to auto-populate OrgNode.node_type and to enforce max_depth.
Nodes¶
Create nodes through the standard CRUD endpoint. Paths and levels are filled in automatically:
POST /org-nodes
{
"name": "Sweden",
"parent_id": null
}
# -> {"name": "Sweden", "level": 0, "path": "", "node_type": "Region"}
POST /org-nodes
{
"name": "Stockholm",
"parent_id": "65ab..."
}
# -> {"name": "Stockholm", "level": 1, "path": "65ab...", "node_type": "District"}
Tree, ancestors, and move¶
Each node exposes the same three hierarchy reads that tenants do:
GET /org-nodes/{node_id}/tree
GET /org-nodes/{node_id}/ancestors
GET /org-nodes/{node_id}/children
PATCH /org-nodes/{node_id}/move
PATCH .../move takes {"new_parent_id": "..."}. The route validates that:
- The new parent exists and belongs to the same tenant.
- The move does not create a cycle (the new parent is not a descendant of the node being moved).
- The resulting depth does not exceed
OrgHierarchyConfig.max_depth.
Once validated, the entire moved subtree has its path and level rewritten.
Tagging access with nodes¶
Org nodes pair naturally with attribute-level access and tag scopes. An access group can be restricted to "users in nodes under Stockholm" by adding the node's path prefix as a filter. See Attribute-Level Access for the full pattern.
Picking the right tool¶
| Need | Use |
|---|---|
| Regional subsidiaries with their own users and data | Tenant hierarchy |
| Cost centers, reporting buckets, store groupings | Organization hierarchy |
| Ad-hoc labels that cut across the tree | Tagging System |
| Parent organization billing multiple independent tenants | Partners |
The hierarchies are not mutually exclusive. A retail chain might use a single tenant for all of Sweden with an org tree of regions and stores, and a separate tenant — owned by the same partner — for Norway. Each dimension answers a different question; pick the one that matches the boundary you need to enforce.