Skip to content
Multi-tenancy

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.

ContextReads 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)
neitherrefused 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):

TableHolds
steward_tenantsslug (unique), name, active
steward_tenant_userswhich accounts belong to which tenant
steward_tenant_user_rolesroles 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:

  1. the bearer token, when the token is bound to a tenant,
  2. TenancyConfig.Resolve, when set (e.g. read a subdomain),
  3. 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

PageInside a tenantPlatform administrator
Tenantshiddencreate tenants, pick their members
Administratorsmembers only; roles are the tenant’s; delete removes the membership, not the accountevery account; roles are platform-wide
Rolesread-onlyfull
Permissions, Menu, Settingshiddenfull
Operation logthis tenant’s entriesevery 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:reindex and migrate run with WithoutTenant.
  • 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 with WithTenant or WithoutTenant before querying tenant tables.