- Docs
- Operate
- Tenants
Tenants
Resolve which tenant owns a request, from single-tenant to header-based SaaS.
tenant answers “which customer owns this request” without tenant parsing in every handler. Resolve maps request metadata to a tenant ID, and Scoped carries it on the context. It resolves ownership but is not an access gate: pair it with Authorization for allow and deny.
Quick start
Section titled “Quick start”single (the default) gives every request one hardcoded ID (id, default "default"). Stay there until a second customer exists.
import ( "context" "github.com/zenta-dev/zever/config" "github.com/zenta-dev/zever/container")
c := container.New(config.Default())t, err := c.Tenant()if err != nil { panic(err)}defer t.Close()
ctx := context.Background()id, err := t.Resolve(ctx, map[string]string{"tenant": ""})fmt.Println(id, err)scoped, err := t.Scoped(ctx, id)fmt.Println(scoped != nil, err)Expected output:
default <nil>true <nil>Resolve tenants from a header or subdomain
Section titled “Resolve tenants from a header or subdomain”Use header for multi-tenant SaaS APIs where a gateway or client sets a per-call header (default X-Tenant-ID, DefaultHeader). With X-Tenant-ID: acme metadata, Resolve returns acme. The header wins; otherwise subdomain_regex matches the first capture group against the lowercased Host metadata. With neither, Resolve returns tenant.ErrNotFound. Meta keys match case-insensitively, and patterns prone to catastrophic backtracking are rejected at Open. Strip or overwrite the tenant header at your edge, since the adapter trusts it.
TENANT_ADAPTER=headerTENANT_HEADER=X-Tenant-IDTENANT_SUBDOMAIN_REGEX=^(?P<tenant>[a-z0-9-]+)\.example\.com$TENANT_ADAPTER=headerTENANT_HEADER=X-Tenant-IDTENANT_SUBDOMAINREGEX=^([a-z0-9-]+)\.example\.com$TENANT_ID=defaultValidate rejects an invalid header key (printable ASCII, no whitespace, colon, or DEL) and any subdomain_regex over 500 characters (MaxRegexLength, a ReDoS bound). An empty header means the default. Adapters are single (one fixed ID, constant-time lookup) and header (request header plus optional subdomain regex). See the tenant config reference.
Production checklist
Section titled “Production checklist”- Resolve once at the middleware edge and propagate through the
tenant/context.gohelpers. Never re-derive deeper, where an unvalidated value could slip in. - Fail closed on an unknown tenant. Mapping it to a default turns one typo into a cross-tenant data leak.
- Both adapters are stateless, so replicas agree when they share config.
Tenant.Close() errortakes no context. - Tenant IDs are customer data. Document the header name and validation rule for API clients, since it is public contract. Never log raw option maps.
Troubleshooting
Section titled “Troubleshooting”Every request lands on default after switching to header
The gateway is not sending the configured header, or the app reads a different name. Confirm TENANT_HEADER matches what the client sends (X-Tenant-ID vs X-Tenant) and that proxies forward it.
header must be a valid header key
The name contains whitespace, a colon, or non-ASCII characters. Fix the name. Empty is allowed (it means the default), garbage is not.
subdomain_regex exceeds max length
The pattern is over 500 characters. Shorten it, or split matching into a regex plus code.