Tenant models

Tenant-scoped queries on every read.

8 minmedium

Multi-tenant SaaS = many isolated customers in one DB. Each tenant sees only their own data. Done right, it's invisible; done wrong, you leak customer A's data to customer B. This lesson covers the Grit pattern.

The model

apps/api/internal/models/tenant.go
type Tenant struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
Name string `gorm:"not null" json:"name"`
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
PlanID string `json:"plan_id"`
CreatedAt time.Time `json:"created_at"`
}
// Every row in a tenant-scoped table gets a TenantID
type Invoice struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
TenantID uuid.UUID `gorm:"type:uuid;not null;index" json:"tenant_id"` // ← the scope
CustomerID uuid.UUID `gorm:"type:uuid;not null" json:"customer_id"`
Total decimal.Decimal
CreatedAt time.Time
}

Two important pieces: a tenants table, and a TenantID column on every tenant-scoped row (orders, invoices, products — anything that "belongs to" a tenant).

The middleware that scopes queries

apps/api/internal/middleware/tenant.go
func TenantScope(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userID, _ := c.Get("user_id")
var user models.User
db.First(&user, "id = ?", userID)
c.Set("tenant_id", user.TenantID.String())
c.Next()
}
}

Every protected request gets tenant_id on its context from the authenticated user's tenant.

Using it in handlers

apps/api/internal/handlers/invoice.go
func (h *InvoiceHandler) List(c *gin.Context) {
tenantID := c.GetString("tenant_id")
var invoices []models.Invoice
h.db.Where("tenant_id = ?", tenantID).Find(&invoices)
c.JSON(200, gin.H{"data": invoices})
}
func (h *InvoiceHandler) Create(c *gin.Context) {
tenantID, _ := uuid.Parse(c.GetString("tenant_id"))
var inv models.Invoice
c.ShouldBindJSON(&inv)
inv.TenantID = tenantID // ← force the tenant, never trust input
h.db.Create(&inv)
c.JSON(201, gin.H{"data": inv})
}

Every read filters by tenant; every write forces the tenant from context, never from the request body. Never accept tenant_id in the request body — that'd let the user write to another tenant.

The #1 multi-tenant bug: forgetting WHERE tenant_id = ? on a query. The query returns EVERY tenant's rows. Code review for it; add a lint rule that flags db.Find() without a Where clause in tenant- scoped handlers.

Row-level security as belt-and-braces

Postgres supports row-level security (RLS) — DB-level filtering by a session var. If a handler forgets the filter, the DB itself rejects. Set up:

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Then in the middleware:
db.Exec("SET LOCAL app.current_tenant = ?", tenantID)

Now even a buggy handler can't leak cross-tenant data. Worth the ~30 minutes to set up for high-stakes SaaS.

The Tenant model on the web side

On signup, create a tenant + assign the user as owner:

tenant := &Tenant{Name: input.CompanyName, Slug: slug.Make(input.CompanyName)}
db.Create(tenant)
user := &User{
Email: input.Email,
TenantID: tenant.ID,
Role: "owner",
}
db.Create(user)

Quick check

A handler does `db.Find(&invoices)` without filtering by tenant. What's the impact?

Try it

Make one of your existing models tenant-scoped:

  1. Add TenantID uuid.UUID to the Product model.
  2. Run grit migrate.
  3. Update the Product handler's List + Create to filter by / force tenant.
  4. Create two test tenants. Make sure tenant A's admin sees only tenant A's products.

Paste the WHERE clause + the force-from-context line in notes.md.

What's next

Tenants done. Next — role gates in the UI. Don't just block on the server; hide buttons the user can't use.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub