Documentation
¶
Overview ¶
Package tenancy routes every query to the right tenant's data — by database, by schema, or by a tenant_id column — with the tenant carried in context.Context and no global mutable state.
It is a router, a scope, and a validator. It is not a framework: it never opens a connection you did not hand it, never provisions a tenant, and never makes a tenant "current" by mutating the process.
tnc, err := tenancy.New(tenancy.ModeDatabase, "postgres",
tenancy.Connect(openTenantDB),
tenancy.Landlord(landlordDB),
)
db := tnc.DB()
// handlers are ordinary playsql; ctx decides whose rows come back
users, err := playsql.Query[User](db).WhereEq("active", true).Get(ctx)
Index ¶
- Variables
- func EnsureSameTenant(sessionTenant func(*http.Request) (string, bool)) func(http.Handler) http.Handler
- func From(ctx context.Context) (string, bool)
- func With(ctx context.Context, tenant string) context.Context
- func Without(ctx context.Context) context.Context
- type Finder
- type Mode
- type Option
- type PoolCache
- type Tenancy
- func (t *Tenancy) DB() *playsql.DB
- func (t *Tenancy) Each(ctx context.Context, fn func(context.Context) error) error
- func (t *Tenancy) Middleware(find Finder) func(http.Handler) http.Handler
- func (t *Tenancy) Mode() Mode
- func (t *Tenancy) Scope() playsql.Scope
- func (t *Tenancy) Tenants(ctx context.Context) ([]string, error)
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ErrNoList = errors.New("tenancy: Each requires the List option")
ErrNoList is returned by Each when the Tenancy was built without List. Nothing else in this package needs to enumerate tenants — a request carries its own — so the ability to is opt-in.
var ErrNoTenant = errors.New("tenancy: no tenant in context")
ErrNoTenant is returned when a query runs on a ctx carrying neither a tenant nor an explicit Without. Every mode fails closed on it: a missing tenant is a bug, never a licence to read every tenant's rows.
var ErrPoolCacheClosed = errors.New("tenancy: pool cache is closed")
ErrPoolCacheClosed is returned by Connect after Close. A closed cache does not reopen: build a new one.
Functions ¶
func EnsureSameTenant ¶
func EnsureSameTenant(sessionTenant func(*http.Request) (string, bool)) func(http.Handler) http.Handler
EnsureSameTenant rejects a request whose session belongs to a different tenant than the request resolved to. Mount it inside Middleware, so there is a tenant in ctx to compare against.
Why this exists when nothing else about tenancy needs middleware ¶
Under ModeDatabase a user's identity is a per-tenant primary key. A session holding user_id = 5 means "user 5 of whichever database we are pointed at" — so a session minted on tenant A, replayed against tenant B, logs its bearer in as B's user 5. A different person. Possibly an admin.
No amount of ctx-propagation saves you from that, because the data layer is doing exactly what it was told: it is auth that is confused, not routing. Hence the one piece of this package that lives outside the data layer.
sessionTenant is yours to supply, because only your app knows its own auth — pull the tenant out of the session cookie, the JWT claim, wherever you bound it. Two rules follow, and the README says them louder:
- Bind the tenant into the session or token, not just the user ID. A bare user_id is meaningless once the database underneath it can change.
- UUID user IDs blunt the log-in-as-someone-else attack — B's database holds no such user — but they do not stop the rest of the session (flash, cart, half-finished wizard) crossing tenants. Bind the tenant anyway.
A request with no session tenant passes through: that is an anonymous visitor on the way to a login page, and there is nothing yet to mismatch. It is once a session names a tenant that it must name this one.
ModeRow is not exempt. The same session presented against another tenant's rows is the same attack, minus the primary-key collision.
func From ¶
From reports the tenant bound to ctx. ok is false when there is none — including on a ctx from Without, since the landlord is not a tenant.
Use it at every async boundary: read the tenant here, put it in the job payload, and call With on the other side.
Example (AsyncBoundary) ¶
Carrying the tenant across a queue: From on the way in, With on the way out. Nothing enforces this — ctx does not survive a queue — so it is a rule.
type Job struct {
Tenant string
PostID int
}
// Enqueue, inside a request: refuse to enqueue a job with no tenant, or the
// worker will pull a job it cannot serve.
enqueue := func(ctx context.Context, postID int) (Job, error) {
tenant, ok := tenancy.From(ctx)
if !ok {
return Job{}, tenancy.ErrNoTenant
}
return Job{Tenant: tenant, PostID: postID}, nil
}
// Consume, inside a worker: there is no request and no ctx, so bind the
// tenant back before touching the database.
consume := func(ctx context.Context, job Job, db *playsql.DB) error {
ctx = tenancy.With(ctx, job.Tenant)
_, err := playsql.Query[Post](db).WhereEq("id", job.PostID).First(ctx)
return err
}
_, _ = enqueue, consume
func With ¶
With returns a ctx bound to tenant. Everything downstream — the connection a query resolves to, the tenant_id a scope appends — follows from this.
Middleware calls it per request. Workers call it per job, after reading the tenant back off the payload. A background goroutine that outlives its request must be handed a ctx built with it, not the request's (which is cancelled).
An empty tenant is not a tenant: With(ctx, "") leaves ctx untenanted rather than binding the empty string, so a header that failed to parse cannot quietly become a tenant key.
func Without ¶
Without returns a ctx that has deliberately left tenancy: under ModeRow the tenant scope is skipped, under ModeDatabase and ModeSchema queries resolve to the landlord pool. It is the only road out — a ctx that merely forgot to set a tenant is an error, not a free pass to every tenant's rows.
Reach for it in admin tooling, in the login flow that has to find which tenant an email belongs to before a tenant exists, and nowhere else.
Types ¶
type Finder ¶
Finder pulls the tenant key out of a request. Subdomain, Header and PathPrefix cover the usual shapes; anything else is a func you write.
Returning an error means "this request names no tenant I can serve" — the middleware answers 404 and the handler never runs. It does not mean "this tenant does not exist": a Finder reads the request, it does not consult a database. An unknown-but-well-formed key fails later, at Connect.
func Header ¶
Header reads the tenant from a request header — the shape an API gateway or a service mesh usually hands you.
Be sure the header is one your edge sets and strips, not one a client can forge: this reads whatever arrives. If clients reach your app directly, put the tenant in the authenticated session instead and read it from there.
func PathPrefix ¶
func PathPrefix() Finder
PathPrefix reads the tenant from the first path segment: /acme/posts is "acme".
The path is not rewritten — your router still sees /acme/posts. Wrap the handler in http.StripPrefix, or route on the full path; either way the choice is yours to make explicitly, because silently mutating the URL a middleware was handed is how debugging sessions get long.
func Subdomain ¶
Subdomain reads the tenant from the host: acme.example.com under Subdomain("example.com") is "acme".
The port is ignored, so it works behind a dev server on :8080. The base domain on its own (example.com, no subdomain) names no tenant and is an error — mount your landlord app on that host separately, rather than letting a bare domain fall through to some default tenant.
Deeper labels are not split: a.b.example.com yields "a.b", not "a". If that is not what you want, write a Finder that says what you do want.
type Mode ¶
type Mode int
Mode is the isolation model: how a tenant's data is kept apart from every other tenant's. Three modes cover the field.
Mode is data, not polymorphism. There is no Strategy interface and no dispatch at query time — ModeDatabase and ModeSchema run the same code, and ModeRow shares none of it. What Mode buys is a validating constructor: you declare your architecture once, and New tells you at startup when your wiring contradicts it, instead of a query telling you at 3am.
const ( // ModeDatabase gives every tenant its own database, and so its own *sql.DB. // Queries route by ctx; there are no tenant_id columns anywhere. ModeDatabase Mode = iota // ModeSchema gives every tenant its own schema inside one database. It is // byte-for-byte ModeDatabase — one pool per tenant, routed by ctx — with one // requirement this package cannot check for you: // // The schema must be pinned in the DSN your Connect func returns (Postgres: // options=-c search_path=<tenant>; MySQL: a db-qualified DSN). Never issue // SET search_path on a checked-out connection: pooled connections are reused // across requests, so the next tenant inherits it. That is a cross-tenant data // leak with no error message. ModeSchema // ModeRow keeps every tenant in the same tables, told apart by a discriminator // column (tenant_id by default). One shared pool, no connection routing: the // Scope appends the predicate. Models opt in by declaring it; a model that // does not is global. ModeRow )
type Option ¶
Option configures a Tenancy. Which options are legal depends on the mode, and New rejects every combination that contradicts it — see New.
func Column ¶
Column names the discriminator column the Scope filters on. ModeRow only; defaults to "tenant_id".
func Connect ¶
Connect supplies the per-tenant pool. ModeDatabase and ModeSchema only.
It runs once per statement, so it must be cheap: a map lookup, not a sql.Open. Wrap your opener in a PoolCache, or write your own — either way the pools are yours, and closing them is yours too. Under ModeSchema the DSN it builds must pin the schema.
func Landlord ¶
Landlord supplies the pool that Without(ctx) resolves to: the database holding the tenant list itself, and anything else that is nobody's tenant data. ModeDatabase and ModeSchema only — under ModeRow the landlord is the same pool with the scope skipped.
type PoolCache ¶
type PoolCache struct {
// contains filtered or unexported fields
}
PoolCache keeps one *sql.DB per tenant, opened on first use.
"The caller owns the pools" is this package's rule, and this does not bend it: you construct the cache, you close it, and Tenancy never learns it exists — it only ever sees the Connect func.
pc := tenancy.NewPoolCache(openTenantDSN)
defer pc.Close()
tnc, err := tenancy.New(tenancy.ModeDatabase, "postgres",
tenancy.Connect(pc.Connect),
tenancy.Landlord(landlordDB),
)
It ships because everyone writing ModeDatabase writes this map, and the hand-rolled version is where the double-open race lives: check the map, miss, two goroutines call sql.Open, one pool is orphaned with its connections still held. Here a tenant's pool is opened exactly once no matter how many requests race for it, and a slow open blocks only that tenant.
What it deliberately does not do:
- No eviction, no LRU, no TTL, no idle reaping. One pool per tenant, held until Close. Those are policy, and policy is a guess about your tenant count and your pool sizing — both of which you know and this package does not. If "forever" is the wrong answer for ten thousand tenants, do not use this: write the cache your deployment actually needs. Connect being a plain func is what keeps that door open.
- No health checks or reconnection. database/sql already pools, retries and reaps dead connections underneath each *sql.DB.
A failed open is not cached: the next request for that tenant tries again, so a database that was down for a minute does not stay unreachable for the life of the process.
Safe for concurrent use.
func NewPoolCache ¶
NewPoolCache returns a cache that opens tenant pools with open. open is called at most once per tenant (per successful open), off the cache's lock, so a slow or hanging open blocks only the tenant it is for.
func (*PoolCache) Close ¶
Close closes every pool the cache opened and returns their errors joined. Opens still in flight are waited for, then closed; opens not yet started never start. Calling it twice is fine — the second call is a no-op.
This is the other half of "you own the pools": nothing else in this package closes a connection, so if you do not call this, nothing does.
type Tenancy ¶
type Tenancy struct {
// contains filtered or unexported fields
}
Tenancy is the wiring: a mode, the connections it implies, and the handle the app queries through. Build one at startup and pass it around; it holds no mutable state and is safe for concurrent use.
func New ¶
New validates the wiring against the mode and builds the query handle.
dialect is positional because every tenant shares it: this package routes connections, it does not switch grammars, so a deployment cannot mix Postgres tenants with MySQL ones. It is a playsql dialect name ("postgres", "mysql", "sqlite", "sqlserver").
Every rejection below is a bug that would otherwise surface as a nil deref on the first request, or — worse — as a query that quietly returns the wrong tenant's rows:
ModeDatabase/ModeSchema without Connect nowhere to route to ModeDatabase/ModeSchema without Landlord nowhere for Without(ctx) to go ModeDatabase/ModeSchema with Shared/Column two architectures mixed ModeRow without Shared nothing to query ModeRow with Connect or Landlord two architectures mixed
One thing New cannot check: whether a ModeSchema Connect returns a DSN that actually pins the schema. Nothing in a DSN says "this is a schema". See ModeSchema.
Example (Database) ¶
A database per tenant, wired end to end.
package main
import (
"context"
"database/sql"
"log"
"net/http"
"github.com/martin3zra/tenancy"
)
func main() {
var (
landlordDB *sql.DB
routes http.Handler
tenantFromSession func(*http.Request) (string, bool)
)
pc := tenancy.NewPoolCache(func(ctx context.Context, tenant string) (*sql.DB, error) {
db, err := sql.Open("postgres", "postgres://localhost/"+tenant)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(10)
return db, db.PingContext(ctx)
})
defer pc.Close()
tnc, err := tenancy.New(tenancy.ModeDatabase, "postgres",
tenancy.Connect(pc.Connect),
tenancy.Landlord(landlordDB),
)
if err != nil {
log.Fatal(err)
}
app := tnc.Middleware(tenancy.Subdomain("example.com"))(
tenancy.EnsureSameTenant(tenantFromSession)(routes),
)
_ = http.ListenAndServe(":8080", app)
}
Output:
func (*Tenancy) DB ¶
DB is the handle the app queries through: a ctx-routed playsql.Dynamic under ModeDatabase and ModeSchema, the shared pool under ModeRow. Either way, handlers are ordinary playsql and ctx decides whose rows come back.
func (*Tenancy) Each ¶
Each runs fn once per tenant, with the tenant already bound in ctx. It is what backfills, cron jobs and per-tenant maintenance are made of:
err := tnc.Each(ctx, func(ctx context.Context) error {
return backfill(ctx, tnc.DB()) // ctx routes to this tenant
})
Provisioning stays out of scope, but iteration is not provisioning: creating a tenant's database is a decision, looping over the ones you have is a for loop.
It is sequential and stops on the first error. A backfill across 500 tenants wants a bounded worker pool, and this package deliberately does not ship one: you own the pools, so you own the concurrency budget, and only you know whether tenant 200 failing should stop tenant 201. Both answers are a few lines with errgroup, on top of List. See the README.
ctx is the ctx the listing runs on and the parent of each tenant's ctx, so cancelling it stops the loop. If List reads the landlord database, hand it a Without(ctx). A tenant bound in the incoming ctx is replaced, not merged — Each visits every tenant, not the current one repeatedly.
Example ¶
Every tenant in a loop: backfills, cron, per-tenant migrations.
package main
import (
"context"
"database/sql"
"log"
"github.com/martin3zra/playsql"
"github.com/martin3zra/tenancy"
)
type User struct {
ID int `db:"id"`
Active bool `db:"active"`
Name string `db:"name"`
}
type Tenant struct {
ID int `db:"id"`
Slug string `db:"slug"`
}
func main() {
var landlordDB *sql.DB
landlord, err := playsql.Use(landlordDB, "postgres")
if err != nil {
log.Fatal(err)
}
pc := tenancy.NewPoolCache(func(_ context.Context, tenant string) (*sql.DB, error) {
return sql.Open("postgres", "postgres://localhost/"+tenant)
})
defer pc.Close()
tnc, err := tenancy.New(tenancy.ModeDatabase, "postgres",
tenancy.Connect(pc.Connect),
tenancy.Landlord(landlordDB),
tenancy.List(func(ctx context.Context) ([]string, error) {
rows, err := playsql.Query[Tenant](landlord).Get(ctx)
if err != nil {
return nil, err
}
keys := make([]string, len(rows))
for i, t := range rows {
keys[i] = t.Slug
}
return keys, nil
}),
)
if err != nil {
log.Fatal(err)
}
// The list lives in the landlord database, so read it without a tenant.
ctx := tenancy.Without(context.Background())
err = tnc.Each(ctx, func(ctx context.Context) error {
// ctx already carries the tenant; tnc.DB() routes to it.
_, err := playsql.Query[User](tnc.DB()).Count(ctx)
return err
})
if err != nil {
log.Fatal(err)
}
}
Output:
func (*Tenancy) Middleware ¶
Middleware binds the tenant that find pulls off the request into the request's ctx, for the rest of the chain. It is the Go answer to Laravel's switch-tenant task pipeline: nothing is switched, nothing is mutated, and two requests for two tenants can run side by side because the tenant lives in ctx and not in the process.
app := tnc.Middleware(tenancy.Subdomain("acme.test"))(
tenancy.EnsureSameTenant(tenantFromSession)(handler),
)
A request whose tenant cannot be found gets 404 and never reaches the handler — the alternative is a handler running with no tenant, which every query in this package then fails closed on anyway, just later and with a worse error.
func (*Tenancy) Scope ¶
Scope is the playsql.Scope a model declares to become tenant-scoped. ModeRow only.
func (Post) Scopes() []playsql.Scope { return []playsql.Scope{tnc.Scope()} }
Every query on that model then gains "tenant_id = ?", filled from ctx. A model that does not declare it is global — which is why this package ships no landlord marker: not opting in is the marker.
It fails closed. A ctx with no tenant aborts the query with ErrNoTenant rather than running unscoped, because an unscoped query on a shared table is every tenant's rows at once. Without(ctx) skips the predicate — that is the escape hatch, and it is deliberate, explicit, and the only one.
Why this panics outside ModeRow ¶
Scopes() has no error return, so there is nowhere to put one. The alternative — handing back a Scope whose Apply always fails — would defer a wiring mistake to the first query, which is exactly the 3am bug Mode exists to prevent. Under ModeDatabase and ModeSchema, "tenant_id = ?" is a bug either way: the column does not exist, or it does and you have double-filtered a database that already holds one tenant's rows. So this panics at declaration time, in the same breath as the model that got it wrong.
Example ¶
A tenant_id column on a shared table. The handler is ordinary playsql.
package main
import (
"database/sql"
"fmt"
"log"
"net/http"
"github.com/martin3zra/playsql"
"github.com/martin3zra/tenancy"
)
type User struct {
ID int `db:"id"`
Active bool `db:"active"`
Name string `db:"name"`
}
func main() {
var sharedDB *sql.DB
tnc, err := tenancy.New(tenancy.ModeRow, "postgres",
tenancy.Shared(sharedDB),
tenancy.Column("tenant_id"),
)
if err != nil {
log.Fatal(err)
}
// A model opts in by declaring the scope; see Post below. A model that
// declares nothing is global.
db := tnc.DB()
http.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
users, err := playsql.Query[User](db).WhereEq("active", true).Get(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "%d users", len(users))
})
}
Output: