Documentation
¶
Index ¶
- Constants
- Variables
- func GetTenantFromContext(ctx context.Context) (id, code, schema string, ok bool)
- func MigrateAllSchemas(db *gorm.DB, dbCfg config.DatabaseConfig, tenantModules []module.OrderedModule, ...) error
- func NewTenantMigrateCommand[T config.Settings](app core.App[T], dbCfg config.DatabaseConfig) *cobra.Command
- func QualifiedTable(ctx context.Context, table string) (string, error)
- func ResolveSchemaContext(ctx context.Context, db *gorm.DB, tenantID string) (context.Context, error)
- func SchemaFromContext(ctx context.Context) (schema string, ok bool)
- func WithTenant(ctx context.Context, id, code, schema string) context.Context
- type AuthorizeFunc
- type Module
- type Option
- type Plugin
- type Tenant
- type TenantAwareRepository
- func (r *TenantAwareRepository[T]) Create(ctx context.Context, model *T) error
- func (r *TenantAwareRepository[T]) Delete(ctx context.Context, model *T) error
- func (r *TenantAwareRepository[T]) Find(ctx context.Context, dest *[]T, conds ...interface{}) error
- func (r *TenantAwareRepository[T]) First(ctx context.Context, dest *T, conds ...interface{}) error
- func (r *TenantAwareRepository[T]) Query(ctx context.Context) *gorm.DB
- func (r *TenantAwareRepository[T]) Transaction(ctx context.Context, fn func(tx *gorm.DB) error) error
- func (r *TenantAwareRepository[T]) Update(ctx context.Context, model *T, attrs interface{}) error
Constants ¶
const ( TenantIDKey contextKey = "tenant_id" TenantCodeKey contextKey = "tenant_code" SchemaNameKey contextKey = "schema_name" DefaultSchema string = "public" // DefaultHeader is the HTTP header the middleware reads by default. // Callers with a two-level hierarchy (e.g. organization -> farm, where // the farm is the schema-bearing unit) should configure a different // header via WithHeader and keep this name free for their own // org-level authorization layer, so the two don't collide. DefaultHeader string = "X-Tenant-ID" )
Variables ¶
var ErrNoTenantInContext = errors.New("tenant: no schema resolved in context for tenant-bound model")
ErrNoTenantInContext is returned when a tenant-bound model is touched without a resolved schema in the statement context. The plugin fails closed: it never falls back to the public schema for tenant-bound data, because a silent fallback would mean a missing tenant context reads or writes into the wrong schema instead of failing loudly.
Functions ¶
func GetTenantFromContext ¶
GetTenantFromContext extracts the full tenant identity from context. All three values must be present for ok to be true; use SchemaFromContext if you only need the schema.
func MigrateAllSchemas ¶
func MigrateAllSchemas(db *gorm.DB, dbCfg config.DatabaseConfig, tenantModules []module.OrderedModule, projectRoot string) error
MigrateAllSchemas runs every TenantNamespace module's migrations (tenantModules) against every known tenant schema — the generic replacement for a bespoke "migrate all my per-tenant schemas" loop. Meant to run on every deploy.
func NewTenantMigrateCommand ¶
func QualifiedTable ¶
QualifiedTable returns "<schema>.<table>" for the tenant resolved in ctx, for the rare case a caller must write raw SQL against a tenant-bound table. It returns an error under the same fail-closed rule as the plugin.
func ResolveSchemaContext ¶
func ResolveSchemaContext(ctx context.Context, db *gorm.DB, tenantID string) (context.Context, error)
ResolveSchemaContext looks up tenantID's schema name and returns a context carrying it (via WithTenant) — for callers with no HTTP request to hang the tenant-resolving Middleware off of (e.g. a Kafka consumer), which otherwise have no way to get a tenant-scoped context for TenantBound model reads/writes.
func SchemaFromContext ¶
SchemaFromContext extracts just the resolved schema name, if any. This is what the tenant plugin uses; it does not require id/code to also be set.
Types ¶
type AuthorizeFunc ¶
AuthorizeFunc validates that the caller (typically resolved from a JWT earlier in the middleware chain) is actually a member of the requested tenant. It runs after the tenant is looked up and before it's placed in context. Returning false aborts the request with 403. When nil, the middleware only checks that the tenant exists and is active — callers that need membership enforcement (see the identity/auth module) must supply this.
type Module ¶
type Module struct {
// contains filtered or unexported fields
}
Module implements tenant schema registration, migrations, and request resolution.
func (*Module) Initialize ¶
Initialize registers the tenant-scoping GORM plugin. Call this once during app startup, after the db connection is established.
func (*Module) Middleware ¶
func (m *Module) Middleware() gin.HandlerFunc
Middleware returns a gin middleware that resolves the tenant named by the configured header, checks it exists and is active, runs the optional AuthorizeFunc, and places its schema into the request context for the tenant plugin to pick up. Requests without the header pass through unscoped — routes that require a tenant should guard for that themselves (a tenant-bound query with no schema in context fails closed, it does not silently serve public data).
func (*Module) MigrationDir ¶
MigrationDir implements module.MigrationProvider.
func (*Module) ProvidesMigrations ¶
ProvidesMigrations implements module.MigrationProvider.
type Option ¶
type Option func(*Module)
Option configures a Module.
func WithAuthorize ¶
func WithAuthorize(fn AuthorizeFunc) Option
WithAuthorize sets the membership check run after tenant lookup. See AuthorizeFunc.
func WithHeader ¶
WithHeader overrides the HTTP header the middleware reads to resolve the active tenant. Defaults to DefaultHeader.
type Plugin ¶
type Plugin struct{}
Plugin is a GORM plugin that schema-qualifies tenant-bound models based on the schema stored in the query context (see WithTenant / SchemaNameKey). Register it once on the *gorm.DB and every Create/Query/Update/Delete/Row against a model implementing model.TenantBound is routed to the caller's tenant schema automatically. Repositories and handlers never need to concatenate schema names onto table names themselves.
Raw SQL (db.Raw / db.Exec) is intentionally NOT covered — the caller wrote the SQL string, so schema-qualification is the caller's job. Use QualifiedTable(ctx, tableName) to build the right identifier by hand, and treat any raw SQL against a tenant-bound table as a review flag.
func (*Plugin) Initialize ¶
Initialize implements gorm.Plugin. It registers resolveSchema at the earliest point in each callback chain where Statement.Table is still safe to rewrite, before GORM builds any SQL from it.
type Tenant ¶
type Tenant struct {
ID string `gorm:"type:uuid;primary_key"`
Code string `gorm:"type:varchar(50);unique;not null"`
Name string `gorm:"type:varchar(255);not null"`
SchemaName string `gorm:"type:varchar(50);unique;not null"`
Active bool `gorm:"default:true"`
}
Tenant represents the schema-bearing unit in the system. In a two-level tenancy model (e.g. organization -> farm) this maps to the *leaf* level that actually owns a schema; the level above it is application-specific and lives outside this module.
func ProvisionSchema ¶
func ProvisionSchema(db *gorm.DB, dbCfg config.DatabaseConfig, tenantModules []module.OrderedModule, projectRoot, id, code, name, schemaName string) (Tenant, error)
ProvisionSchema is the generic half of "onboard a new tenant": create its Postgres schema, register it in the tenants table, and run every TenantNamespace module's migrations against it (tenantModules — e.g. from app.GetModulesInOrder(module.TenantNamespace), resolved once by the caller) — the mechanical, application-agnostic steps any schema-per-tenant onboarding flow needs, regardless of what the tenant is called in the calling app's domain language (farm, workspace, account, ...). Idempotent: safe to retry (CREATE SCHEMA IF NOT EXISTS, and each module's migration Up() is a no-op once current).
Callers own tenant-naming concerns entirely — schemaName here must already be validated and fully formed (e.g. "farm_acme"); this function does no slug validation or prefixing of its own, since that's application-specific, not framework-generic. Takes db/dbCfg/tenantModules directly rather than an app.App[T] handle, so callers deep inside application code (with only a *gorm.DB, not the app instance) can use it without threading the whole app through their own call chains.
type TenantAwareRepository ¶
type TenantAwareRepository[T model.TenantBound] struct { // contains filtered or unexported fields }
TenantAwareRepository provides tenant-scoped database operations. It does not know or store a schema itself: every method threads ctx through to GORM via WithContext, and the Plugin resolves the schema from context on each call. This is what keeps repositories free of schema string-building — construct one with NewTenantAware(db) and call it the same way regardless of which tenant is active.
func NewTenantAware ¶
func NewTenantAware[T model.TenantBound](db *gorm.DB) *TenantAwareRepository[T]
NewTenantAware creates a new tenant-aware repository for model T.
func (*TenantAwareRepository[T]) Create ¶
func (r *TenantAwareRepository[T]) Create(ctx context.Context, model *T) error
Create creates a new record with tenant scoping.
func (*TenantAwareRepository[T]) Delete ¶
func (r *TenantAwareRepository[T]) Delete(ctx context.Context, model *T) error
Delete deletes records with tenant scoping.
func (*TenantAwareRepository[T]) Find ¶
func (r *TenantAwareRepository[T]) Find(ctx context.Context, dest *[]T, conds ...interface{}) error
Find finds records with tenant scoping.
func (*TenantAwareRepository[T]) First ¶
func (r *TenantAwareRepository[T]) First(ctx context.Context, dest *T, conds ...interface{}) error
First finds the first record with tenant scoping.
func (*TenantAwareRepository[T]) Query ¶
func (r *TenantAwareRepository[T]) Query(ctx context.Context) *gorm.DB
Query returns a tenant-scoped query builder for further chaining.
func (*TenantAwareRepository[T]) Transaction ¶
func (r *TenantAwareRepository[T]) Transaction(ctx context.Context, fn func(tx *gorm.DB) error) error
Transaction executes operations in a transaction. The ctx passed in must carry the resolved tenant; it is applied to the transaction's *gorm.DB before fn runs so scoped queries inside fn resolve the same tenant.