Skip to content

Organization Hierarchy

Craft Easy supports two independent hierarchies:

  1. Tenant hierarchy — parent tenants and sub-tenants, modeled on the Tenant document 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.
  2. Organization hierarchy — a tenant-internal tree of OrgNode documents. 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:

{
    "tenant_path": {"$regex": "(^|,)65ab(,|$)"}
}

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_path and depth from the parent.
  • Calls assert_sub_tenant_depth_allowed to enforce SUB_TENANT_MAX_DEPTH.
  • Calls assert_sub_tenant_child_limit to enforce SUB_TENANT_MAX_CHILDREN.
  • Calls assert_features_subset so the child cannot enable a feature the parent lacks. An empty enabled_features on the parent is treated as "all features are available".
  • Inherits partner_id from 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.