Skip to content
Zever
Home

Tenants

Resolve which tenant owns a request, from single-tenant to header-based SaaS.

2 min readEdit on GitHub

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.

single (the default) gives every request one hardcoded ID (id, default "default"). Stay there until a second customer exists.

main.go
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.

Terminal window
TENANT_ADAPTER=header
TENANT_HEADER=X-Tenant-ID
TENANT_SUBDOMAIN_REGEX=^(?P<tenant>[a-z0-9-]+)\.example\.com$
Terminal window
TENANT_ADAPTER=header
TENANT_HEADER=X-Tenant-ID
TENANT_SUBDOMAINREGEX=^([a-z0-9-]+)\.example\.com$
TENANT_ID=default

Validate 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.

  • Resolve once at the middleware edge and propagate through the tenant/context.go helpers. 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() error takes 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.
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.