Warden
1.x
Docs/Warden/Namespaces (Nested Tenancy)
Open

Reading14 min
Updated31 Jul 2026
Sourcev1/concepts/namespaces.mdx

Tenants are flat by default — every entity sits in one bucket. Real organizations aren't flat. Namespaces add a hierarchical dimension on top of tenant_id: roles, permissions, policies, resource types, and relation tuples can live at any depth in a tree, with cascading inheritance from ancestors and strict isolation between siblings.

tenant: acme
  ""                          (root — visible everywhere)
  ├── engineering             (shared across eng teams)
  │   ├── platform            (sees its own + engineering + root)
  │   │   └── sre             (sees its own + platform + engineering + root)
  │   └── frontend            (sees its own + engineering + root, but NOT platform)
  └── billing                 (sees its own + root, but NOT engineering)

A role declared at engineering is automatically visible to engineering/platform, engineering/frontend, and every descendant — but not to siblings like billing. That's the whole feature in one sentence.

When to Reach for Namespaces01

Use namespaces when inside one tenant you have multiple organizational units that:

  • Share some config (the company-wide super-admin role, audit policies)

  • But also have their own scoped config (per-team viewer roles, project-specific resource types)

  • Need delegation — let team leads manage their own roles without touching siblings

If your tenants are completely independent (acme vs globex), you want multiple tenants, not nested namespaces. Tenants are a hard wall — no cross-tenant access ever. Namespaces are a soft hierarchy within a tenant.

PatternPick
Multi-tenant SaaS, customers don't see each otherOne tenant per customer (flat)
Single-customer deployment with multiple teamsOne tenant, namespaces per team
Shared infrastructure with per-team policy carve-outsOne tenant, root + nested namespaces
Single-team appOne tenant, no namespaces (or skip tenant entirely — see Multi-Tenancy)

Path Syntax02

A namespace path is a /-separated list of segments:

RuleDetail
Empty path ("")The tenant root. Always valid.
Segment regex^[a-z][a-z0-9-]{0,62}$ — lowercase, kebab-allowed, ≤63 chars per segment.
Separator/. No leading/trailing slash; no empty segments (a//b rejected).
Reserved segmentssystem, admin — rejected. Reserved for Warden's own use.
Max depth8 by default (configurable via Config.MaxNamespaceDepth).
Leading /Only valid in role-parent references in DSL source — marks an absolute path from tenant root.

Validate programmatically:

import "github.com/xraph/warden"

if err := warden.ValidateNamespacePath("engineering/platform/sre", 0); err != nil {
    // err names which rule was broken
}

// 0 means "use the default cap (warden.MaxNamespaceDepth)".

Cascading Inheritance03

When the engine evaluates a Check at namespace N, it walks the ancestor chain: N, then N's parent, then its parent, all the way up to the tenant root. Use warden.AncestorNamespaces to see the chain explicitly:

warden.AncestorNamespaces("engineering/platform/sre")
// → ["engineering/platform/sre", "engineering/platform", "engineering", ""]

The runtime semantics, per entity kind:

LookupBehavior
Roles assigned to a subjectCascades from the assignment's namespace down to descendants. A role assigned at engineering applies for the subject in engineering/platform.
Permissions referenced from a role's grantsResolved at the role's own namespace first, then walks ancestors. So a tenant-root permission catalog is accessible from any sub-namespace.
Resource typesSame walk — define document at the root, every namespace can use it.
Policies at namespace NApply to checks in N and all descendants. Sub-namespace policies stack on top of ancestor policies in the merge.
Relation tuplesLocated at a namespace; do not cascade. A tuple document:d1 viewer = user:alice at engineering is invisible from billing — even though both are in the same tenant. This keeps tuple namespaces partition-clean.

Sibling isolation. A role declared in billing is invisible from engineering. The walk only goes up, never sideways. Cross-namespace references must use the absolute path (/billing/...) explicitly — see Cross-namespace references below.

Setting the Namespace at Check Time04

Three places, in priority order (later wins):

  1. Contextwarden.WithNamespace(ctx, "eng/platform") threads through every Check / Enforce / CanI.

  2. CheckRequest.NamespacePath — overrides the context-derived value.

  3. WithCallNamespacePath(...) call option — wins per-call.

import "github.com/xraph/warden"

ctx := warden.WithTenant(context.Background(), "app", "acme")
ctx  = warden.WithNamespace(ctx, "engineering/platform")

// Threaded namespace.
result, _ := eng.Check(ctx, &warden.CheckRequest{
    Subject:  warden.Subject{Kind: warden.SubjectUser, ID: "u1"},
    Action:   warden.Action{Name: "deploy"},
    Resource: warden.Resource{Type: "service", ID: "api"},
})

// Per-call override (e.g. running an audit query from a parent ns).
result, _ = eng.Check(ctx, req, warden.WithCallNamespacePath("engineering"))

// Inline on the request.
req.NamespacePath = "engineering/platform/sre"
result, _ = eng.Check(ctx, req)

The engine resolves the namespace, builds the ancestor list with AncestorNamespaces, and queries every level. The cost is roughly O(depth) extra store calls — at most 8 by default, usually 1–3 in practice.

Declaring Namespaces05

Two equivalent forms — pick whichever fits your workflow:

go
import (
    "github.com/xraph/warden/permission"
    "github.com/xraph/warden/role"
)

// Tenant-root permission, visible from every namespace.
_ = store.CreatePermission(ctx, &permission.Permission{
    TenantID:      "acme",
    NamespacePath: "",
    Name:          "audit:read",
    Resource:      "audit_log",
    Action:        "read",
})

// Engineering-org role, inherits from no parent.
_ = store.CreateRole(ctx, &role.Role{
    TenantID:      "acme",
    NamespacePath: "engineering",
    Slug:          "eng-viewer",
    Name:          "Engineering Viewer",
})

// Platform-team role, inherits from engineering's role.
_ = store.CreateRole(ctx, &role.Role{
    TenantID:      "acme",
    NamespacePath: "engineering/platform",
    Slug:          "platform-admin",
    ParentSlug:    "eng-viewer",   // resolved up the namespace chain
    Name:          "Platform Admin",
})

Cross-Namespace References06

Bare slugs (role X : viewer) resolve in the role's own namespace, then walk ancestors. To reach across the tree — say a role in engineering/platform should inherit from a role in billing — use an absolute path with a leading /:

namespace "engineering" {
  namespace "platform" {
    role billing-platform-admin : /billing/billing-admin {
      name = "Platform admin with billing oversight"
    }
  }
}

The leading / flips the resolver into absolute mode: it goes straight to billing/billing-admin instead of walking the role's own ancestors. This is the only escape hatch — sibling lookups always require it.

Resolution Rules at a Glance07

ReferenceResolution
role X : viewer (bare)Try viewer at this role's namespace, then each ancestor up to root.
role X : /eng/admin (absolute)Look exactly at eng/admin, no walk.
Permission name in grants = ["doc:read"]Try at this role's namespace, then ancestors.
Permission shorthand permission "x:y" (document : read)document is resolved at the permission's namespace, then ancestors.
Resource type referenced in a permission expressionSame — namespace-of-the-permission, then ancestors.
Relation tuple lookup at Check timeExact namespace match (no walk).
Policy applicability at Check timePolicy's namespace and every ancestor.

Storage Shape08

Every entity table has a namespace_path TEXT NOT NULL DEFAULT '' column. Unique keys include it:

-- before namespaces
UNIQUE(tenant_id, slug)

-- with namespaces
UNIQUE(tenant_id, namespace_path, slug)

So role viewer at the root and role viewer at engineering/platform are distinct entities — their slugs collide, but their (tenant_id, namespace_path, slug) triples don't. This is intentional: teams should be able to define a local viewer without coordinating across the entire company.

A typed prefix index (idx_warden_roles_ns on (tenant_id, namespace_path)) keeps ancestor-walk queries fast on PostgreSQL and SQLite. MongoDB uses a sparse compound index with the same shape.

Worked Example: Drift-Free Per-Team Carve-Outs09

Suppose acme runs an internal platform with two engineering teams plus a billing org. Tenant-wide policy: never deploy without an MFA token. Per-team carve-outs: Platform engineers can read everything in their namespace; SRE can also page; Frontend can only ship UI.

go
// Tenant-root: MFA-required policy applies to every descendant.
_ = store.CreatePolicy(ctx, &policy.Policy{
    TenantID:      "acme",
    NamespacePath: "",
    Name:          "global-mfa",
    Effect:        policy.EffectAllow,
    IsActive:      true,
    Actions:       []string{"deploy:*"},
    Obligations:   []string{"require-mfa"},
})

// Engineering-org viewer role, shared by both sub-teams.
engViewer := &role.Role{
    TenantID:      "acme",
    NamespacePath: "engineering",
    Slug:          "eng-viewer",
    Name:          "Engineering Viewer",
}
_ = store.CreateRole(ctx, engViewer)

// Platform admin inherits from eng-viewer via the ancestor walk.
_ = store.CreateRole(ctx, &role.Role{
    TenantID:      "acme",
    NamespacePath: "engineering/platform",
    Slug:          "platform-admin",
    ParentSlug:    "eng-viewer",
    Name:          "Platform Admin",
})

// SRE inherits from platform-admin in the same namespace.
_ = store.CreateRole(ctx, &role.Role{
    TenantID:      "acme",
    NamespacePath: "engineering/platform",
    Slug:          "sre",
    ParentSlug:    "platform-admin",
    Name:          "SRE",
})

// Frontend developer inherits from eng-viewer (different namespace, same parent).
_ = store.CreateRole(ctx, &role.Role{
    TenantID:      "acme",
    NamespacePath: "engineering/frontend",
    Slug:          "frontend-developer",
    ParentSlug:    "eng-viewer",
    Name:          "Frontend Developer",
})

// Check: SRE on a deploy. Engine walks engineering/platform → engineering → root,
// finds the global-mfa policy, returns Allow + Obligation "require-mfa".
ctx = warden.WithNamespace(ctx, "engineering/platform")
result, _ := eng.Check(ctx, &warden.CheckRequest{...})

Performance Characteristics10

OperationCost in namespace depth d
Check (RBAC + ABAC + ReBAC)O(d) extra store reads — one per ancestor level
ListRoles for an explicit namespaceSingle indexed lookup, no walk
ListActivePolicies(tenant, [N, parent, root])Single query with IN (...) filter
AncestorNamespaces itselfO(d) string ops, no I/O

The default cap of 8 keeps the worst case under a millisecond on warm caches even on busy systems. Bench: BenchmarkCheckWithNamespace/depth/{0,1,4,8} is part of the engine's regression suite.

Reserved Segments and Common Gotchas11

  • system and admin are rejected by ValidateNamespacePath — they're reserved for Warden's own internal use (think future system-managed namespaces). Use ops or governance if you need a similar concept.

  • A namespace path does not create implicit empty segments. "a/b" exists only if you explicitly created entities at a and at a/b. The store doesn't pre-register parent paths — engineering/platform is just the path string, not a row.

  • Renaming a namespace segment is not a rename — it's a delete + recreate. Migrate carefully if you rebrand a team.

  • Relation tuples don't cascade. A document shared at engineering is invisible from engineering/platform even though that's a descendant. This is intentional: you don't want a tuple created in one team's scope leaking sideways via inheritance.

See Also12