Plugin

Multi-tenancy

One user, many organizations, a different role in each — with query scoping that can't be forgotten.

grit plugin add multitenant
cd apps/api && go run cmd/migrate/main.go

Why a plugin

Most apps are single-tenant, and putting org_id on every table is a schema decision no framework should make for you. Install it when you need it.

Marking a model as tenant-owned

Embed tenant.Owned. That's what opts a table into isolation:

type Invoice struct {
ID string
Amount int
tenant.Owned // adds OrgID + the index
}

Then just query normally — scoping is applied for you:

// Only the active organization's invoices.
db.WithContext(ctx).Find(&invoices)
// OrgID is stamped on insert; you never set it.
db.WithContext(ctx).Create(&Invoice{Amount: 100})
Scoping fails closed

A query against a tenant-owned model with no active organization returns an error — it does not quietly return every tenant's rows. Hand-written scoping fails the other way: forget one WHERE and you get a silent cross-tenant leak that no test catches, because the query returns more rows rather than failing.

Cross-tenant work

Platform admin screens and background sweeps need to see everything. Opt out explicitly — each call is deliberate and greppable:

tenant.Unscoped(db).Find(&allInvoices)

How the active organization is chosen

From the X-Organization-ID header, or automatically when the user belongs to exactly one. No subdomains — they force DNS and TLS decisions on every deployment and make local development awkward.

curl https://api.example.com/api/invoices \
-H "Authorization: Bearer ..." \
-H "X-Organization-ID: 01H..."

Membership is always verified against the database. Trusting the header alone would let any signed-in user read another tenant's data by editing one request header.

Requesting an organization you don't belong to returns 404, not 403 — a 403 confirms it exists and lets an attacker enumerate tenants.

Roles per organization

Membership carries the role, so someone is Editor in one organization and Viewer in another. It reuses the roles you already have — no parallel permission system.

Endpoints

GET /api/organizations orgs you belong to
POST /api/organizations create (you become owner)
GET /api/organizations/:id/members
POST /api/organizations/:id/members
DELETE /api/organizations/:id/members/:userId
Adding this to an existing app

Existing rows have no org_id. Before adding tenant.Owned to a model that already has data, create a default organization and backfill it — otherwise those rows become invisible to every scoped query.