Multi-Tenancy
How Warden isolates authorization data per tenant.
Tenant Scoping
Every Warden operation is scoped to a tenant. This ensures that roles, permissions, policies, and relations from one tenant cannot leak to another.
Need a hierarchy inside one tenant? See Namespaces — the second scope dimension layered on top of
tenant_id. Tenants are a hard wall; namespaces are a soft hierarchy with cascading inheritance for org charts that aren't flat.
With Forge
When running inside Forge, tenant context is automatically available via forge.ScopeFrom(ctx):
// Forge middleware sets scope automatically
// from Authsome JWT or Keysmith API key
ctx = forge.WithScope(ctx, forge.Scope{
AppID: "myapp",
TenantID: "tenant-123",
})Standalone Mode
Without Forge, set tenant context explicitly:
import "github.com/xraph/warden"
ctx = warden.WithTenant(ctx, "myapp", "tenant-123")How Isolation Works
Store Layer
All store queries include tenant ID as a filter. The PostgreSQL store uses tenant_id columns on every table:
SELECT * FROM warden_roles
WHERE tenant_id = $1 AND id = $2;Engine Layer
The engine extracts tenant scope from context before every operation:
result, err := eng.Check(ctx, &warden.CheckRequest{
Subject: warden.Subject{Kind: warden.SubjectUser, ID: "user-42"},
Action: warden.Action{Name: "read"},
Resource: warden.Resource{Type: "document", ID: "doc-123"},
})
// Only evaluates roles, policies, and relations for the current tenantDSL: tenant declared in source
The same scope can be set in .warden source via the tenant header (or --tenant / --var TENANT=... at apply time):
warden config 1
tenant ${TENANT} // bound at apply time via --var TENANT=acme
role admin { name = "Admin" }Apply across environments without rebuilding the source:
warden apply -f config/ --var TENANT=acme --store $PROD_DSN
warden apply -f config/ --var TENANT=staging --store $STAGE_DSNThe applier wires this scope into every entity it creates — roles, permissions, policies, resource types, and relation tuples all land in the named tenant. See Variables for the full template substitution rules.
Single-Tenant Apps: Skip the Tenant Entirely
Tenant scope is optional on both sides. When an app never calls warden.WithTenant and the DSL omits the tenant header, every entity lands in the global scope (tenant_id = "") and every Check uses the same empty scope — they match cleanly.
warden config 1
// No `tenant` declaration — applies to the global scope.
permission "doc:read" (document : read)
role viewer {
name = "Viewer"
grants = ["doc:read"]
}warden apply -f config/ --store sqlite:./warden.db
# No --tenant flag — entities are written with tenant_id = "".This is the right path for single-tenant deployments. Multi-tenant apps should always pass a tenant explicitly (via source, --tenant, or --var) so entities never accidentally land in the global bucket.
Cross-Tenant Prevention
- Store methods that omit tenant ID return
warden.ErrMissingTenant - There is no API to query across tenants
- TypeIDs are globally unique, but data is partitioned by tenant
Tenant-Scoped Operations
All CRUD operations on entities are automatically scoped:
// Creates a role for tenant-123 only
store.CreateRole(ctx, role)
// Lists roles for tenant-123 only
roles, _ := store.ListRoles(ctx, filter)
// Deletes all data for tenant-123
store.DeleteByTenant(ctx, "tenant-123")