This document describes Cyber Ware's multi-tenancy model, tenant topology, and isolation mechanisms.
Cyber Ware uses a hierarchical multi-tenancy model where tenants form a single-root tree. Every tenant except the root has exactly one parent, and all tenants descend from a single shared root. Tenants can have child tenants, creating organizational structures like:
Root
├── Organization A
│ ├── Team A1
│ └── Team A2
└── Organization B
├── Team B1
└── Team B2
Key principles:
- Isolation by default — tenants cannot access each other's data
- Hierarchical access — parent tenants may access child tenant data (configurable)
- Barriers — child tenants can opt out of parent visibility via
self_managedflag
The tenant structure is a single-root tree — every tenant except the root has exactly one parent, and all tenants descend from a single shared root.
[Root] ← The only tenant with no parent
/ \
[T1] [T5]
/ \ |
[T2] [T3] [T6]
|
[T4]
Properties:
- Exactly one tenant in the whole hierarchy has
parent_id = NULL; this tenant is the root. - Every other tenant has exactly one parent.
- Depth is not bounded by the tenant model itself; any limits on hierarchy depth come from the concrete Account/Tenant Management service implementation (operational policy, performance envelope, storage constraints).
- The root is identified by convention (the single tenant with
parent_id = NULL). There is nois_systemfield, and the root is not referred to as a "system tenant".
Relationship with the RG forest. When tenants are materialized as groups in the Resource Group module, the single-root tree is embedded inside the broader RG forest: RG may hold several root groups (ownership-graph roots, auxiliary forests), each carrying its own tenant_id. At most one of those roots may be a tenant-type group — that root is the single tenant root described above. All other tenants live below it as sub-tenants. Non-tenant RG roots inherit the main tenant's tenant_id via seeding but are not tenants themselves. RG enforces this at create time (TenantRootAlreadyExists / 409 Conflict). See RG DESIGN §Tenant Root Uniqueness.
Why single-root tree?
- One OAuth client is enough for S2S tenant-scoped flows that need to act as the root — no per-root credential fan-out at the vendor IdP.
- Unambiguous "act as root" semantics — platform-level tenant-scoped operations always address the same tenant.
- Organizational autonomy is preserved via sub-roots — in multi-tenant deployments each independent organization is modelled as its own sub-root directly under the root; barriers continue to provide isolation between sub-trees.
- Works naturally for single-user / consumer deployments of Cyber-Fabric-based products — the root is the tenant that owns all business objects, and no sub-roots are created.
- Avoids the accidental complexity of DAGs — closure-table rows, barriers, and ancestry queries stay tree-shaped.
Deployment shapes:
Both shapes satisfy the same topology invariant (exactly one parent_id = NULL); what differs is whether business objects live on the root.
| Deployment | Role of the root | Business objects on the root? |
|---|---|---|
| Multi-tenant vendor deployment | Structural anchor above organization sub-roots | No — they live on sub-roots and below |
| Single-user / consumer deployment | Owner of all platform data for the sole user | Yes |
See ADR 0004 for the rationale and the rejected alternatives (forest, DAG).
| Property | Type | Description |
|---|---|---|
id |
UUID | Unique tenant identifier |
parent_id |
UUID? | Parent tenant. NULL for exactly one tenant: the root. |
status |
enum | active, suspended, deleted |
self_managed |
bool | If true, creates a barrier — parent cannot access this subtree |
Status semantics:
active— normal operationsuspended— tenant temporarily disabled (e.g., billing issue), data preserveddeleted— soft-deleted, may be purged after retention period
A barrier is created when a tenant sets self_managed = true. This prevents parent tenants from accessing the subtree rooted at the barrier tenant.
Example:
T1 (parent)
├── T2 (self_managed=true) ← BARRIER
│ └── T3
└── T4
Access from T1's perspective:
- ✅ Can access T1's own resources
- ❌ Cannot access T2's resources (barrier)
- ❌ Cannot access T3's resources (behind barrier)
- ✅ Can access T4's resources
Access from T2's perspective:
- ✅ Can access T2's own resources
- ✅ Can access T3's resources (T3 is in T2's subtree, no barrier between them)
Use cases:
- Enterprise customer wants data isolation from reseller/partner
- Compliance requirements (data sovereignty)
- Organizational autonomy within a larger structure
Barrier interpretation is context-dependent:
Barriers are not absolute — their enforcement depends on the type of data and operation. The same parent-child relationship may have different access rules for different resource types:
| Data Type | Barrier Enforced? | Rationale |
|---|---|---|
| Business data (tasks, documents) | ✅ Yes | Core isolation requirement |
| Usage/metrics for billing | ❌ No | Parent needs to bill child tenant |
| Audit logs | Compliance may require parent visibility | |
| Tenant metadata (name, status) | ❌ No | Parent needs to manage child tenants |
Example: Reseller T1 has enterprise customer T2 (self_managed=true):
- T1 ❌ cannot read T2's business data (tasks, files, etc.)
- T1 ✅ can read T2's usage metrics for billing purposes
- T1 ✅ can see T2's tenant metadata (name, status, plan)
This means barrier_mode in authorization requests applies to specific resource types, not globally. Each module/endpoint decides whether barriers apply to its resources.
Implementation: The tenant_closure table includes a barrier column that indicates whether a barrier exists between ancestor and descendant. See Closure Table.
Two different tenant concepts appear in authorization:
| Concept | Description | Example |
|---|---|---|
| Subject Tenant | Tenant the user belongs to (from token/identity) | User's "home" organization |
| Context Tenant | Tenant scope for the current operation | May differ for cross-tenant operations |
Typical case: Subject tenant = Context tenant (user operates in their own tenant)
Cross-tenant case: Admin from parent tenant T1 operates in child tenant T2's context:
- Subject tenant: T1 (where admin belongs)
- Context tenant: T2 (where operation is scoped)
In authorization requests:
Many operations need to query "all resources in tenant T and its children". This is a subtree query.
Options for subtree queries:
| Approach | Pros | Cons |
|---|---|---|
| Recursive CTE | No extra tables | Slow for deep hierarchies, not portable |
| Explicit ID list from PDP | Simple SQL | Doesn't scale (thousands of IDs) |
| Closure table | O(1) JOIN, scales well | Requires sync, storage overhead |
Cyber Ware recommends closure tables for production deployments with hierarchical tenants.
Tenant scope parameters (in context.tenant_context):
| Parameter | Default | Description |
|---|---|---|
mode |
"subtree" |
"root_only" (single tenant) or "subtree" (tenant + descendants) |
root_id |
— | Root tenant. Optional — PDP can determine from token_scopes or subject.properties.tenant_id |
barrier_mode |
"all" |
"all" (respect barriers) or "none" (ignore). See DESIGN.md. |
tenant_status |
all | Filter by tenant status (active, suspended) |
The tenant_closure table is a denormalized representation of the tenant hierarchy. It contains all ancestor-descendant pairs, enabling efficient subtree queries.
Schema:
| Column | Type | Description |
|---|---|---|
ancestor_id |
UUID | Ancestor tenant |
descendant_id |
UUID | Descendant tenant |
barrier |
SMALLINT NOT NULL DEFAULT 0 | 0 = no respected barrier on path (ancestor, descendant], 1 = at least one self_managed tenant exists on that path |
descendant_status |
enum | Status of descendant tenant (denormalized for query efficiency) |
Barrier semantics: The barrier column is defined over the path (ancestor, descendant]: the ancestor endpoint is excluded, the descendant endpoint is included. Formally, barrier = 1 iff any tenant on that path has self_managed = true. Self-rows (id, id) are a special case and always carry barrier = 0.
This endpoint rule is what makes a plain AND barrier = 0 predicate satisfy the SDK contract:
get_ancestors(T, Respect)returns empty ifTitself is self-managed, because every strict-ancestor row ending atThasbarrier = 1get_descendants(T, Respect)excludes a self-managed childCand its subtree, because rows starting atTand ending atCor below havebarrier = 1is_ancestor(A, D, Respect)returnsfalsewhenDitself is self-managed or when another self-managed tenant lies on(A, D]
Example data for the hierarchy:
T1
├── T2 (self_managed=true)
│ └── T3
└── T4
| ancestor_id | descendant_id | barrier | descendant_status |
|---|---|---|---|
| T1 | T1 | 0 | active |
| T1 | T2 | 1 | active |
| T1 | T3 | 1 | active |
| T1 | T4 | 0 | active |
| T2 | T2 | 0 | active |
| T2 | T3 | 0 | active |
| T3 | T3 | 0 | active |
| T4 | T4 | 0 | active |
Key observations:
T1 → T2: barrier = 1 because the descendant endpointT2is self-managed and the descendant endpoint is included in(ancestor, descendant]T1 → T3: barrier = 1 becauseT2is on(T1, T3]T2 → T2: barrier = 0 because self-rows always carrybarrier = 0T2 → T3: barrier = 0 because ancestorT2is excluded from(T2, T3]- Because the hierarchy has a single root, that root appears as
ancestor_idof every tenant in the table (subject to the barrier rules above).
Query: "All tenants in T1's subtree, with barrier_mode: "all""
-- barrier_mode: "all" (default) adds the barrier clause
SELECT descendant_id FROM tenant_closure
WHERE ancestor_id = 'T1'
AND barrier = 0
-- barrier_mode: "none" omits the barrier clauseResult: T1, T4 (T2 and T3 excluded due to barrier = 1)
Query: "All tenants in T2's subtree"
SELECT descendant_id FROM tenant_closure WHERE ancestor_id = 'T2' AND barrier = 0Result: T2, T3 (T2 → T2 is the self-row; T2 → T3 has barrier = 0 because ancestor T2 is excluded from (T2, T3])
Future extensibility: The barrier column is SMALLINT to allow future use as a bitmask for multiple barrier types (e.g., bit 0 for self_managed, bit 1 for data_sovereignty). 16 bits of bitmask headroom is ample for any realistic number of barrier dimensions, and the type is portable across PostgreSQL and MySQL. Selective enforcement changes from barrier = 0 to a (barrier & mask) = 0 check without touching the schema.
Synchronization: How projection tables are synchronized with vendor systems, consistency guarantees, and conflict resolution are out of scope for this document. See Tenant Resolver design documentation (TBD).
- DESIGN.md — Core authorization design
- RESOURCE_GROUP_MODEL.md — Resource group topology, membership, hierarchy
- AUTHZ_USAGE_SCENARIOS.md — Authorization scenarios with tenant examples
- ADR 0004: Single-Root Tenant Model Topology — Rationale for single-root tree vs. forest vs. DAG
{ "subject": { "properties": { "tenant_id": "T1" } // Subject tenant }, "context": { "tenant_context": { "mode": "root_only", // Single tenant T2 "root_id": "T2" } // OR for subtree: // "tenant_context": { // "mode": "subtree", // T2 + descendants // "root_id": "T2" // } } }