Multi-Tenancy (x/tenant)
Multi-Tenancy
Section titled “Multi-Tenancy”This guide shows how to add multi-tenant isolation to a Plumego service using x/tenant. You will resolve a tenant identity from each request, enforce policy at the middleware layer, and read the tenant ID in handlers and storage calls.
For the module boundary rationale, see the x/tenant Primer.
Release posture note
Section titled “Release posture note”x/tenant is an experimental, high-risk extension family because tenant mistakes can become isolation failures. Keep tenant wiring explicit, fail closed on resolution, policy, quota, and session errors, and check Release Posture plus Extension Maturity before using it as a frozen production contract.
When to use x/tenant vs. a simple header read
Section titled “When to use x/tenant vs. a simple header read”If you only need to extract a tenant identifier from a header and pass it to storage queries, you can do that in application code without x/tenant:
tenantID := r.Header.Get("X-Tenant-ID")rows, err := db.QueryContext(ctx, db, "SELECT * FROM items WHERE tenant_id = $1", tenantID)Reach for x/tenant when you need:
- Enforced, fail-closed tenant resolution (reject requests with no tenant)
- Policy evaluation (per-tenant feature flags, model access, tool allowlists)
- Per-tenant quota enforcement with
Retry-Afterfeedback - JWT-backed tenant session lifecycle
Step 1 — Resolve the tenant identity
Section titled “Step 1 — Resolve the tenant identity”Add resolve.Middleware to the app’s middleware stack, after auth so the JWT principal is already in context:
import ( authmw "github.com/spcent/plumego/middleware/auth" resolve "github.com/spcent/plumego/x/tenant/resolve" "github.com/spcent/plumego/security/jwt")
// Auth runs first so the JWT principal is available for tenant extractionauthMw, err := authmw.Authenticate(jwtManager.Authenticator(jwt.TokenTypeAccess))if err != nil { return err}app.Use(authMw)
// Resolver extracts tenant ID from the principal or falls back to X-Tenant-ID headerapp.Use(resolve.Middleware(resolve.Options{ AllowMissing: false, // reject requests with no tenant ID (fail-closed)}))Resolution order:
authn.Principal.TenantIDfrom context (set by the JWT middleware)- Custom
Extractorfunction (if configured) X-Tenant-IDHTTP header
Use a custom extractor
Section titled “Use a custom extractor”app.Use(resolve.Middleware(resolve.Options{ Extractor: func(r *http.Request) (string, error) { host := r.Host parts := strings.SplitN(host, ".", 2) if len(parts) < 2 { return "", nil } return parts[0], nil }, AllowMissing: false,}))Step 2 — Read the tenant ID in handlers
Section titled “Step 2 — Read the tenant ID in handlers”import tenantcore "github.com/spcent/plumego/x/tenant/core"
func (h ItemHandler) List(w http.ResponseWriter, r *http.Request) { tenantID := tenantcore.TenantIDFromContext(r.Context())
items, err := h.db.QueryContext(r.Context(), "SELECT id, name FROM items WHERE tenant_id = $1", tenantID) // ...}Step 3 — Enforce tenant policy (optional)
Section titled “Step 3 — Enforce tenant policy (optional)”Implement tenantcore.PolicyEvaluator and attach policy.Middleware:
import ( "github.com/spcent/plumego/x/tenant/policy" tenantcore "github.com/spcent/plumego/x/tenant/core")
type MyPolicyEvaluator struct { db *sql.DB}
func (e *MyPolicyEvaluator) Evaluate(ctx context.Context, tenantID string, req tenantcore.PolicyRequest) (tenantcore.PolicyResult, error) { // look up tenant capabilities in your database allowed, err := e.isAllowed(ctx, tenantID, req.Path, req.Method) if err != nil { return tenantcore.PolicyResult{}, err } return tenantcore.PolicyResult{Allowed: allowed}, nil}
app.Use(policy.Middleware(policy.Options{ Evaluator: &MyPolicyEvaluator{db: sqlDB},}))Step 4 — Enforce per-tenant quota (optional)
Section titled “Step 4 — Enforce per-tenant quota (optional)”Implement tenantcore.QuotaManager and attach quota.Middleware:
import ( "github.com/spcent/plumego/x/tenant/quota" tenantcore "github.com/spcent/plumego/x/tenant/core")
app.Use(quota.Middleware(quota.Options{ Manager: myQuotaManager, // your tenantcore.QuotaManager implementation Estimator: func(r *http.Request) int { // estimate token/request cost from the request return 1 },}))When the tenant’s quota is exhausted, the middleware returns 429 with Retry-After and X-Quota-Remaining: 0.
Middleware ordering
Section titled “Middleware ordering”Attach tenant middleware after auth and before route handlers:
app.Use(requestid.Middleware())recoveryMw, err := recovery.Middleware(recovery.Config{Logger: app.Logger()})if err != nil { return err}accesslogMw, err := accesslog.Middleware(accesslog.Config{Logger: app.Logger()})if err != nil { return err}app.Use(recoveryMw)app.Use(accesslogMw)authMw, err := authmw.Authenticate(authenticator)if err != nil { return err}app.Use(authMw) // auth before tenantapp.Use(resolve.Middleware(resolveOpts)) // tenant resolveapp.Use(policy.Middleware(policyOpts)) // policy after resolveapp.Use(quota.Middleware(quotaOpts)) // quota after resolve