Multi-tenancy
With Config.Tenancy set, one panel serves several isolated tenants
(workspaces) from a single database. Each row of a tenant-owned table carries a
tenant_id. Steward fills it on insert and filters every query by it, so a
tenant never reads or writes another tenant’s rows.
Tenancy is off by default. With Tenancy left nil, nothing below is
registered and the panel behaves exactly as a single-tenant one.
app, err := steward.New(steward.Config{
DB: db,
SecretKey: secret,
Tenancy: &steward.TenancyConfig{},
})Tenant-owned models
Embed steward.TenantScoped in every model that belongs to a tenant:
type Survey struct {
ID uint
steward.TenantScoped // TenantID uint, indexed, not null
Title string
}The column is yours to migrate, like the rest of the model. Steward never
shows TenantID on a default form or grid.
When tenant_id has to be part of a composite index, declare the field
yourself and implement steward.TenantScopedModel:
type Survey struct {
ID uint
TenantID uint `gorm:"not null;uniqueIndex:idx_survey_code,priority:1"`
Code string `gorm:"size:64;uniqueIndex:idx_survey_code,priority:2"`
}
func (Survey) IsTenantScoped() {}How scoping works
A GORM plugin enforces the tenant on every query that goes through a
context. It covers Find, First, Count, Pluck, Row, Update,
Delete and Create, on the model and on db.Table(name) queries against its
table. That includes the queries Steward builds itself: grids, forms,
relation option lists, unique: rules, dashboard aggregates and exports.
| Context | Reads and writes |
|---|---|
steward.WithTenant(ctx, id) | only rows of tenant id; inserts are stamped with id |
steward.WithoutTenant(ctx) | every tenant (platform administration, migrations, jobs) |
| neither | refused with steward.ErrNoTenant |
The last row is deliberate: a query that forgot its tenant fails instead of leaking. Panel requests carry the right context already. Code of your own, such as background jobs, has to pass one:
db.WithContext(steward.WithTenant(ctx, tenantID)).Find(&surveys)An update can’t move a row to another tenant, and a create can’t name a tenant other than the context’s.
Raw and Exec are not parsed and are never scoped. Add the condition
yourself there.
Tables with no model embedding TenantScoped can be scoped by name:
Tenancy: &steward.TenancyConfig{Tables: []string{"legacy_rows"}},Outside the panel
A second binary sharing the database (an API server, a worker) registers the plugin itself, and the table prefix with it:
steward.SetupModels(db, "steward_")
db.Use(steward.TenantPlugin())Tenants, members and roles
Migration 0007 creates three tables (shown with the default prefix):
| Table | Holds |
|---|---|
steward_tenants | slug (unique), name, active |
steward_tenant_users | which accounts belong to which tenant |
steward_tenant_user_roles | roles an account holds inside one tenant |
An account is global: its username, email and password are shared by every
tenant it belongs to. Roles in role_users stay platform-wide. On each
request the account’s roles in the current tenant are added to User.Roles,
so HasRole, permissions and policies see both sets.
A user holding the administrator role platform-wide is a platform
administrator. A user holding it only inside a tenant is that tenant’s
administrator: permission checks pass inside the tenant, but the platform
pages stay closed. c.IsPlatformAdmin() tells the two apart.
Choosing the tenant
For each signed-in request Steward picks the tenant from, in order:
- the bearer token, when the token is bound to a tenant,
TenancyConfig.Resolve, when set (e.g. read a subdomain),- the tenant selected in the session.
The choice must be a tenant the user belongs to. Otherwise, or when nothing is selected, a member falls back to their first active tenant. A platform administrator with no selection works across all tenants (“All workspaces”). A user who belongs to no active tenant gets a 403 on every page except sign out.
The header shows a workspace switcher to anyone with more than one tenant, and
always to platform administrators. It posts to POST {Prefix}/auth/tenant.
In handlers, c.Tenant() returns the current tenant (nil in platform mode) and
c.TenantID() its id.
Built-in pages under tenancy
| Page | Inside a tenant | Platform administrator |
|---|---|---|
| Tenants | hidden | create tenants, pick their members |
| Administrators | members only; roles are the tenant’s; delete removes the membership, not the account | every account; roles are platform-wide |
| Roles | read-only | full |
| Permissions, Menu, Settings | hidden | full |
| Operation log | this tenant’s entries | every entry |
A tenant administrator can’t edit an account that also belongs to another tenant or is a platform administrator. Its password is shared by those tenants too.
API tokens
With EnableTokenAuth, a token is bound to one tenant when it is issued. Pass
the tenant’s slug:
POST /admin/auth/token
{"username": "dave", "password": "…", "tenant": "north"}tenant can be left out when the account belongs to exactly one tenant. With
several it answers 409 and lists them:
{"message": "Choose a workspace: pass tenant with one of these slugs.",
"tenants": [{"slug": "north", "name": "North"}, {"slug": "south", "name": "South"}]}Background work
- Exports record the tenant they were queued in and run in it.
search:reindexandmigraterun withWithoutTenant.- Search hits are read back through the repository, so they are scoped. Hits from other tenants still use up the engine’s result window.
- Scheduled jobs (
App.Jobs) get a plain context. Wrap it withWithTenantorWithoutTenantbefore querying tenant tables.