Skip to main content

Multi-tenancy

To keep customers apart in one database, every query needs a tenant condition. The only reliable way is to make it impossible to forget.

A global filter​

A filter applies to every query of a model, and reaches joins, Include, Has, and subqueries by itself.

type tenantKey struct{}

func WithTenant(ctx context.Context, id int64) context.Context {
return context.WithValue(ctx, tenantKey{}, id)
}

func (Invoice) Configure(b *ant.ModelBuilder[Invoice]) {
b.Filter("tenant", func(ctx context.Context) ant.Cond {
id, ok := ctx.Value(tenantKey{}).(int64)
if !ok {
return ant.FailedCond(ErrNoTenant) // fail closed
}

return ant.Eq(&I.TenantID, id)
})
}

Fail closed. With no tenant in the context, the filter returns an error and the query fails. If it returned an empty condition instead, code that forgot to set a tenant would see every tenant's rows, which is the worst way this can go wrong.

Every model at once​

An embedded struct with ConfigureMixin brings the column, the filter, and the value on insert all at once, so a model can't get just part of it wrong.

type Tenanted struct {
TenantID int64
}

func (Tenanted) ConfigureMixin(m *ant.Mixin) {
m.Filter("tenant", func(ctx context.Context) ant.Cond {
id, err := TenantOf(ctx)
if err != nil {
return ant.FailedCond(err)
}

return m.Eq("tenant_id", id)
})
}

func (t *Tenanted) BeforeInsert(ctx context.Context) error {
id, err := TenantOf(ctx)
t.TenantID = id

return err
}

func TenantOf(ctx context.Context) (int64, error) {
id, ok := ctx.Value(tenantKey{}).(int64)
if !ok {
return 0, ErrNoTenant
}

return id, nil
}

Now a model only has to embed it.

type Bill struct {
Tenanted
ID int64
Total int64
}

The ledger example is built this way.

What a filter does not cover​

func (Member) Configure(b *ant.ModelBuilder[Member]) {
b.Index(&M.TenantID).And(&M.Email).Unique()
}

For a harder boundary, PostgreSQL's row-level security enforces the same rule in the database. Set the tenant per transaction with SET LOCAL, inside db.Tx.