Skip to content
gohacks.dev
Databases

Ent Multi Tenancy With Privacy Rules and Shared Mixins

Isolate tenants in Ent with a mixin, a privacy filter and a create hook, and see the foreign key hole the filter leaves open and the hook that closes it

16 min read
Summarize in AI
Flat illustration of a database beside the table it holds.

Multi tenancy in an ORM is easy to do 95 percent of the way and then break on the remaining 5. The common 95 is a tenant_id column and a filter on it. The 5 is everything the filter does not touch: foreign keys that point across tenants, background jobs with no tenant in sight, unique constraints that should be per tenant, and the day someone writes a query that forgets the filter. Ent has the parts to do this well: mixins for the shared column, privacy rules for the filter, and hooks for the stamp. This article assembles them and runs the cases on PostgreSQL 18.6, including the one that leaks until you close it.

Tested with Go 1.25.6, Ent v0.14.5, pgx v5.11.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)

It builds on Ent mixins and Ent privacy, and covers for Ent what GORM multi tenant patterns covers for GORM. The database enforced alternative, row level security, is the backstop to add if a leak would be a breach.

The design

Three pieces, each with one job:

  1. A mixin gives every tenant owned entity an immutable tenant_id.
  2. A privacy policy, defined once in the mixin, narrows every query and mutation to the tenant in the context and denies the operation if there is none.
  3. A hook, also defined once in the mixin, sets tenant_id on every create from the context, so application code never passes it and cannot pass a wrong one.

Entities opt in by listing the mixin. The tenant travels in the context.Context:

tenancy/tenancy.goGo
package tenancy

import "context"

type key struct{}

// NewContext carries the tenant for the current request.
func NewContext(ctx context.Context, tenant int) context.Context {
	return context.WithValue(ctx, key{}, tenant)
}

func FromContext(ctx context.Context) (int, bool) {
	t, ok := ctx.Value(key{}).(int)
	return t, ok
}

type skipKey struct{}

// WithoutReferenceCheck turns off the foreign key check in the invoice hook. It exists only to show
// what the check prevents.
func WithoutReferenceCheck(ctx context.Context) context.Context {
	return context.WithValue(ctx, skipKey{}, true)
}

func ReferenceCheckSkipped(ctx context.Context) bool {
	skip, _ := ctx.Value(skipKey{}).(bool)
	return skip
}

The mixin

The fields live in one file:

ent/schema/mixin/tenant.goGo
package mixin

import (
	"entgo.io/ent"
	"entgo.io/ent/schema/field"
	entmixin "entgo.io/ent/schema/mixin"
)

// TenantMixin adds tenant_id to every entity that lists it. Its policy and hooks are in tenant_rules.go.
type TenantMixin struct{ entmixin.Schema }

func (TenantMixin) Fields() []ent.Field {
	return []ent.Field{field.Int("tenant_id").Immutable()}
}

Immutable() matters: once a row has a tenant, no update builder offers SetTenantID, so a bug cannot move a row between tenants. The policy and hook are in a second file that the script adds after the first generation, because they import generated packages (the two pass generation described in the codegen workflow):

ent/schema/mixin/tenant_rules.txtText
package mixin

import (
	"context"

	"entgo.io/ent"
	"entgo.io/ent/entql"
	gen "example.test/lab/ent"
	"example.test/lab/ent/hook"
	"example.test/lab/ent/privacy"
	"example.test/lab/tenancy"
)

// Policy narrows every query and mutation to the tenant in the context, and refuses to run without one.
func (TenantMixin) Policy() ent.Policy {
	return privacy.Policy{
		Query:    privacy.QueryPolicy{filterTenant()},
		Mutation: privacy.MutationPolicy{filterTenant()},
	}
}

func filterTenant() privacy.QueryMutationRule {
	type tenantFilter interface{ WhereTenantID(entql.IntP) }
	return privacy.FilterFunc(func(ctx context.Context, f privacy.Filter) error {
		tid, ok := tenancy.FromContext(ctx)
		if !ok {
			return privacy.Denyf("missing tenant in the context")
		}
		tf, ok := f.(tenantFilter)
		if !ok {
			return privacy.Denyf("unexpected filter type %T", f)
		}
		tf.WhereTenantID(entql.IntEQ(tid))
		return privacy.Skip
	})
}

// Hooks stamp the tenant on every create, so callers never pass it and cannot forge it.
func (TenantMixin) Hooks() []ent.Hook {
	return []ent.Hook{
		hook.On(func(next ent.Mutator) ent.Mutator {
			return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
				tid, ok := tenancy.FromContext(ctx)
				if !ok {
					return nil, privacy.Denyf("missing tenant in the context")
				}
				s, ok := m.(interface{ SetTenantID(int) })
				if !ok {
					return nil, privacy.Denyf("unexpected mutation type %T", m)
				}
				s.SetTenantID(tid)
				return next.Mutate(ctx, m)
			})
		}, ent.OpCreate),
	}
}

var _ gen.Hook

The filter. filterTenant is a privacy.FilterFunc, which receives a generic privacy.Filter for whichever entity is being queried or mutated. It reads the tenant from the context, denies if there is none, asserts that the filter has a WhereTenantID method (every entity with the field does, thanks to the entql feature) and calls it with the tenant. Then it returns Skip, so evaluation continues, and since a policy with every rule skipping allows the operation, the filtered operation proceeds. One function covers every entity that uses the mixin, which is the reason to use a filter and not a typed rule per entity.

The stamp. The hook runs on OpCreate. It reads the tenant, casts the mutation to anything with SetTenantID(int) and sets it. A create in a context with no tenant is refused here too.

The entities

ent/schema/customer.goGo
package schema

import (
	"entgo.io/ent"
	"entgo.io/ent/schema/edge"
	"entgo.io/ent/schema/field"
	"entgo.io/ent/schema/index"
	"example.test/lab/ent/schema/mixin"
)

type Customer struct{ ent.Schema }

func (Customer) Mixin() []ent.Mixin { return []ent.Mixin{mixin.TenantMixin{}} }

func (Customer) Fields() []ent.Field {
	return []ent.Field{field.String("email").NotEmpty(), field.String("name")}
}

func (Customer) Edges() []ent.Edge { return []ent.Edge{edge.To("invoices", Invoice.Type)} }

// Emails are unique per tenant, not globally.
func (Customer) Indexes() []ent.Index {
	return []ent.Index{index.Fields("tenant_id", "email").Unique()}
}
ent/schema/invoice.goGo
package schema

import (
	"entgo.io/ent"
	"entgo.io/ent/schema/edge"
	"entgo.io/ent/schema/field"
	"example.test/lab/ent/schema/mixin"
)

type Invoice struct{ ent.Schema }

func (Invoice) Mixin() []ent.Mixin { return []ent.Mixin{mixin.TenantMixin{}} }

func (Invoice) Fields() []ent.Field {
	return []ent.Field{field.Int("customer_id"), field.Int("cents").Positive()}
}

func (Invoice) Edges() []ent.Edge {
	return []ent.Edge{edge.From("customer", Customer.Type).Ref("invoices").Unique().Required().Field("customer_id")}
}

The only multi tenant lines are Mixin() and, for Customer, the unique index over tenant_id and email. That index is the one most often forgotten: with a global unique index on email, tenants compete for addresses and one customer's signup can fail because another tenant has the same address. Per tenant uniqueness is the correct rule for almost every business key (emails, slugs, invoice numbers, SKUs).

The run

db.goGo
package main

import (
	"database/sql"
	"log"

	"entgo.io/ent/dialect"
	entsql "entgo.io/ent/dialect/sql"
	"example.test/lab/ent"
	_ "github.com/jackc/pgx/v5/stdlib"
)

const base = "postgres://postgres:[email protected]:55432/lab?sslmode=disable"

// open recreates a schema and returns a client whose connections use it.
func open(schema string, opts ...ent.Option) *ent.Client {
	admin, err := sql.Open("pgx", base)
	if err != nil {
		log.Fatal(err)
	}
	admin.Exec("drop schema if exists " + schema + " cascade")
	admin.Exec("create schema " + schema)
	admin.Close()
	db, err := sql.Open("pgx", base+"&search_path="+schema)
	if err != nil {
		log.Fatal(err)
	}
	drv := entsql.OpenDB(dialect.Postgres, db)
	return ent.NewClient(append([]ent.Option{ent.Driver(drv)}, opts...)...)
}

func must(err error) {
	if err != nil {
		log.SetFlags(0)
		log.Fatal(err)
	}
}
ent/generate.goGo
package ent

//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature privacy,entql,schema/snapshot ./schema
main.goGo
package main

import (
	"context"
	"fmt"

	"example.test/lab/ent"
	"example.test/lab/ent/customer"
	"example.test/lab/ent/invoice"
	"example.test/lab/ent/privacy"
	_ "example.test/lab/ent/runtime"
	"example.test/lab/tenancy"
)

func main() {
	client := open("l0753")
	defer client.Close()
	t1 := tenancy.NewContext(context.Background(), 1)
	t2 := tenancy.NewContext(context.Background(), 2)
	system := privacy.DecisionContext(context.Background(), privacy.Allow)
	must(client.Schema.Create(system))

	// Each tenant creates its own data without ever passing a tenant id.
	c1 := client.Customer.Create().SetEmail("[email protected]").SetName("Pat (tenant 1)").SaveX(t1)
	c2 := client.Customer.Create().SetEmail("[email protected]").SetName("Pat (tenant 2)").SaveX(t2)
	fmt.Println("same email in two tenants is fine:", c1.TenantID, c2.TenantID, "| ids", c1.ID, c2.ID)
	client.Invoice.Create().SetCustomerID(c1.ID).SetCents(1200).SaveX(t1)
	client.Invoice.Create().SetCustomerID(c2.ID).SetCents(3400).SaveX(t2)

	fmt.Println("tenant 1 sees customers:", client.Customer.Query().CountX(t1), "| invoices:", client.Invoice.Query().CountX(t1))
	fmt.Println("tenant 2 sees customers:", client.Customer.Query().CountX(t2), "| invoices:", client.Invoice.Query().CountX(t2))
	fmt.Println("a system job sees:", client.Customer.Query().CountX(system), "customers")

	// Fail closed.
	_, err := client.Customer.Query().Count(context.Background())
	fmt.Println("no tenant in the context:", err)
	_, err = client.Customer.Create().SetEmail("[email protected]").SetName("X").Save(context.Background())
	fmt.Println("create with no tenant:", err)

	// Tenant 1 cannot reach tenant 2's rows by id.
	_, err = client.Customer.Get(t1, c2.ID)
	fmt.Println("tenant 1 reads tenant 2's customer:", ent.IsNotFound(err))
	n, _ := client.Customer.Update().Where(customer.ID(c2.ID)).SetName("hijacked").Save(t1)
	fmt.Println("tenant 1 updates tenant 2's customer, rows changed:", n)

	// Uniqueness is per tenant.
	_, err = client.Customer.Create().SetEmail("[email protected]").SetName("Again").Save(t1)
	fmt.Println("duplicate email inside one tenant:", ent.IsConstraintError(err))

	// The foreign key hazard: the tenant filter does not check where an edge points.
	stray, err := client.Invoice.Create().SetCustomerID(c2.ID).SetCents(1).Save(tenancy.WithoutReferenceCheck(t1))
	fmt.Println("without the reference check, tenant 1 bills tenant 2's customer:", err)
	loaded := client.Invoice.Query().Where(invoice.ID(stray.ID)).WithCustomer().OnlyX(t1)
	fmt.Println("   tenant 1 reads that invoice, customer edge loaded:", loaded.Edges.Customer != nil)
	_, err = client.Invoice.Create().SetCustomerID(c2.ID).SetCents(1).Save(t1)
	fmt.Println("with the reference check:", err)

	// Eager loading goes through the policy as well.
	inv := client.Invoice.Query().WithCustomer().Where(invoice.CentsGT(3000)).OnlyX(t2)
	fmt.Println("tenant 2 invoice with its customer:", inv.Edges.Customer.Name)
}
ent/schema/invoice_hooks.txtText
package schema

import (
	"context"
	"errors"

	"entgo.io/ent"
	gen "example.test/lab/ent"
	"example.test/lab/ent/customer"
	"example.test/lab/ent/hook"
	"example.test/lab/tenancy"
)

// The tenant filter does not check where a foreign key points. This hook does: the customer must be
// visible to the current tenant, and the customer query runs through the same policy.
func (Invoice) Hooks() []ent.Hook {
	return []ent.Hook{
		hook.On(func(next ent.Mutator) ent.Mutator {
			return hook.InvoiceFunc(func(ctx context.Context, m *gen.InvoiceMutation) (ent.Value, error) {
				id, ok := m.CustomerID()
				if !ok || tenancy.ReferenceCheckSkipped(ctx) {
					return next.Mutate(ctx, m)
				}
				visible, err := m.Client().Customer.Query().Where(customer.ID(id)).Exist(ctx)
				if err != nil {
					return nil, err
				}
				if !visible {
					return nil, errors.New("customer does not exist for this tenant")
				}
				return next.Mutate(ctx, m)
			})
		}, ent.OpCreate),
	}
}
Shell
set -e
# Pass 1 generates from the fields alone. The rules import generated packages, so they are added afterwards.
GOFLAGS=-mod=mod go generate ./ent
mv ent/schema/mixin/tenant_rules.txt ent/schema/mixin/tenant_rules.go
mv ent/schema/invoice_hooks.txt ent/schema/invoice_hooks.go
GOFLAGS=-mod=mod go generate ./ent
go mod tidy > /dev/null 2>&1
go run .
Text
same email in two tenants is fine: 1 2 | ids 1 2
tenant 1 sees customers: 1 | invoices: 1
tenant 2 sees customers: 1 | invoices: 1
a system job sees: 2 customers
no tenant in the context: missing tenant in the context: ent/privacy: deny rule
create with no tenant: missing tenant in the context: ent/privacy: deny rule
tenant 1 reads tenant 2's customer: true
tenant 1 updates tenant 2's customer, rows changed: 0
duplicate email inside one tenant: true
without the reference check, tenant 1 bills tenant 2's customer: <nil>
   tenant 1 reads that invoice, customer edge loaded: false
with the reference check: customer does not exist for this tenant
tenant 2 invoice with its customer: Pat (tenant 2)

Isolation works

Each tenant creates its data without mentioning a tenant: SetEmail(...).SetName(...) and no SetTenantID. The stamp hook sets it. The results:

  • same email in two tenants is fine: 1 2 | ids 1 2: both customers are [email protected], stored under tenants 1 and 2.
  • tenant 1 sees customers: 1 | invoices: 1 and tenant 2 sees customers: 1 | invoices: 1: the same CountX call returns each tenant's own rows.
  • a system job sees: 2 customers: a context made with privacy.DecisionContext(ctx, privacy.Allow) bypasses the policy, deliberately and visibly, which is how migrations and jobs that span tenants should work.

It fails closed

A query with a plain context.Background() is refused, and so is a create: missing tenant in the context: ent/privacy: deny rule. This is the property that makes the design safe against the forgotten filter. A handler that forgets to put the tenant in the context does not leak, it errors in the first test run.

By-id access is filtered too

Tenant 1 reading tenant 2's customer by its id got NotFound, and a bulk update by id changed 0 rows (tenant 1 updates tenant 2's customer, rows changed: 0). Enumerating ids across tenants therefore reveals nothing. Per tenant uniqueness held as well: a second [email protected] inside tenant 1 was a constraint error.

The hole the filter leaves

The most important result in the lab is the one that looks like a bug and is not: the filter protects rows, not references. An invoice has a customer_id, and the database only checks that a customer with that id exists, in any tenant. The tenant filter on the invoice create does not look at where customer_id points.

With the reference check turned off for one call, the output is:

without the reference check, tenant 1 bills tenant 2's customer: <nil>

The create succeeded. A tenant 1 invoice now points at a tenant 2 customer. Reading it back as tenant 1 with WithCustomer() loaded no customer (customer edge loaded: false), because the customer query passes through the policy and tenant 2's customer is invisible. The data is wrong in a quiet way: tenant 1 has an invoice it cannot fully read, and anyone with a system context sees a link across the boundary.

The fix is a hook on Invoice creates that checks the referenced customer through the same policy. The hook queries Customer with the mutation's own context, so the tenant filter applies, and refuses if the customer is not visible. With the check on, the same create returns customer does not exist for this tenant. The hook costs one extra EXISTS query per invoice create, and it closes the hole. Write one such check for every foreign key to a tenant owned entity, or generate them.

For defence in depth, make the database enforce it. A composite foreign key (tenant_id, customer_id) referencing customers(tenant_id, id) makes PostgreSQL refuse a cross tenant link regardless of how the row was written. Ent does not generate composite foreign keys from edges, so this is a migration you write by hand. It is the stronger guarantee and worth it where tenant isolation is a contractual promise.

What this design does not cover

  • Raw SQL and other programs bypass the policy. Use row level security if they must be covered.
  • System contexts see everything. Grep for DecisionContext in code review, and keep the helper that builds one in a single package.
  • Caches and search indexes need the tenant in their keys. A cache keyed by id alone serves tenant A's data to tenant B.
  • Aggregates and reports that span tenants (billing, analytics) run as a system job on purpose, and their output must not be returned to a tenant.
  • Tenant resolution. Derive the tenant from the authenticated identity or a verified host name, never from a request field.

Indexes and performance

The filter adds tenant_id = $n to every statement, so the column belongs at the front of the indexes you care about. A lookup by email that is already unique per tenant is served by the (tenant_id, email) index from the lab, because the tenant predicate is the leading column. A list of invoices ordered by creation date for one tenant wants (tenant_id, created_at). An index that starts with another column cannot serve the tenant filtered query as well, and PostgreSQL may scan far more than the tenant's rows.

Two effects to watch as the data grows. First, one large tenant makes statistics lopsided: the planner assumes an average tenant, and a query for the biggest one can pick a plan that suits a small one. If a few tenants are huge, partition the large tables by tenant (hash or list partitioning) so each tenant's rows are together and the planner has better numbers. Second, the filter is on every query, so an eager load, a count and a bulk update all pay for it. Check the statements in the debug log (Ent queries) and confirm with EXPLAIN that the tenant index is used.

Adding tenancy to an existing system

Most teams add this after the product exists. The migration is a sequence, not a switch:

  1. Add tenant_id as nullable and backfill it in batches, deriving it from the relationships you have (a user's account, a project's organisation).
  2. Add the composite indexes concurrently, so writes are not blocked.
  3. Turn on the hook that stamps new rows, so no new row is created without a tenant, while the old ones are being backfilled.
  4. Make the column not null and add the per tenant unique indexes once every row has a value.
  5. Turn on the policy, first in a mode that only logs what it would filter, if you can, then enforcing.
  6. Replace global unique indexes with per tenant ones, in a window where both exist.

The policy goes on last. Enabling it first means the first request that arrives with no tenant in the context breaks, and you learn how many code paths forgot it from your users. Search the codebase for every Ent call that lacks a tenant context before the switch, and write the cross tenant tests described above.

Per tenant data operations

Operational tasks become per tenant operations, and the model should make them easy:

  • Export. A tenant asks for their data. With a filter that is a system job running every entity's query with tenant_id = X. Keep a list of the tenant owned entities in one place so a new entity cannot be forgotten.
  • Deletion. Removing a tenant is a delete across every tenant owned table in dependency order. Do it in batches from a job, not in one transaction, and record what was removed.
  • Restore. Restoring one tenant's data from a backup is hard in a shared database, because the backup is of everything. Decide ahead of time whether that is acceptable. If tenants need independent restores, a schema or database per tenant (GORM's comparison of the strategies) fits better.
  • Noisy neighbours. One tenant's heavy query slows the others. Per tenant rate limits at the API and statement timeouts at the database limit the damage, and a pool per tenant class (free, paid, enterprise) keeps big customers from starving the rest.

Mistakes to avoid

  • Trusting the tenant from the client. The context must be built from authentication, never a header the caller sets.
  • Forgetting background workers. A worker that consumes a queue must restore the tenant from the message into the context before touching Ent. A job with no tenant fails closed, which is what you want, and also the first thing you will hit.
  • Caching without the tenant. An in process cache keyed by customer id serves one tenant's customer to another that happens to share an id value.
  • Logging data without the tenant. An incident needs to be scoped by tenant, so put the tenant id in structured log fields.
  • Treating the filter as authorization. Tenant isolation says whose data this is. Whether a user inside the tenant may read it is a separate rule, such as the owner rules in Ent privacy, and the two policies compose.

Testing the isolation

Treat the table above as the test plan. Create data for two tenants, run every store method as each, and assert both what each sees and what each cannot do. Include the negative cases: no tenant, another tenant's id on read, update and delete, and a cross tenant reference on every foreign key. Run them against PostgreSQL (Ent testing), and add the "no tenant means error" test first, since it guards the line that matters most.

Checklist

  • Put tenant_id in an immutable mixin and list the mixin in each tenant owned entity.
  • Filter every query and mutation with one privacy rule, and fail closed without a tenant.
  • Stamp the tenant on create with a hook.
  • Make business keys unique per tenant with a composite index.
  • Check every foreign key across tenant owned entities, in a hook and, if you can, in the database.
  • Keep system contexts explicit and rare.

The features are documented in the Ent privacy guide and the multi tenancy example.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.