tenant

package
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 15, 2026 License: MIT Imports: 15 Imported by: 0

README

Tenant Module

The tenant module provides multi-tenancy support for NebularCore applications using PostgreSQL schema-based isolation. Each tenant gets its own PostgreSQL schema, ensuring data isolation between tenants.

Features

  • Schema-based tenant isolation
  • Tenant-aware repositories for data access
  • Automatic schema creation and management
  • Middleware for tenant context injection
  • Support for tenant-bound models

Installation

The tenant module is included in NebularCore by default. No additional installation is required.

Usage

1. Module Registration

While the tenant module can be used standalone, registering it as a NebularCore module provides several important benefits:

  • Lifecycle Management: Proper initialization, shutdown, and resource cleanup
  • Configuration Management: Automatic loading and validation of tenant settings
  • Dependency Resolution: Ensures correct module initialization order
  • Migration Control: Automated schema creation and migration sequencing
  • Integration Points: Event hooks, middleware registration, and service discovery

To register the tenant module:

package main

import (
    "github.com/volvlabs/nebularcore/core"
    "github.com/volvlabs/nebularcore/modules/tenant"
)

func main() {
    app := core.NewApp()
    
    // Register tenant module
    tenantModule := tenant.NewModule()
    app.RegisterModule(tenantModule)
    
    // Initialize app after registering all modules
    if err := app.Initialize(); err != nil {
        panic(err)
    }
}
2. Configuration

Add tenant configuration to your config file:

tenant:
  default_schema: "public"  # Default schema for non-tenant operations
  migrations_dir: "modules/tenant/migrations"  # Directory containing tenant migrations
3. Creating Tenant-Bound Models

Models that should be tenant-aware must implement the model.TenantBound interface:

package myapp

import "github.com/volvlabs/nebularcore/core/model"

type MyModel struct {
    ID        uint   `gorm:"primarykey"`
    Name      string
    TenantID  string
}

// Implement TenantBound interface
func (MyModel) IsTenantBound() bool {
    return true
}
4. Creating Tenant-Aware Repositories

Use the TenantAwareRepository to create repositories for tenant-bound models:

package myapp

import (
    "github.com/volvlabs/nebularcore/modules/tenant"
    "gorm.io/gorm"
)

type MyModelRepository struct {
    *tenant.TenantAwareRepository[MyModel]
}

func NewMyModelRepository(db *gorm.DB, schema string) *MyModelRepository {
    return &MyModelRepository{
        TenantAwareRepository: tenant.NewTenantAware[MyModel](db, schema),
    }
}
5. Using Tenant-Aware Repositories
func (r *MyModelRepository) CreateModel(ctx context.Context, model *MyModel) error {
    return r.Create(ctx, model)
}

func (r *MyModelRepository) FindModels(ctx context.Context) ([]MyModel, error) {
    var models []MyModel
    err := r.Find(ctx, &models)
    return models, err
}
6. Middleware Usage

Add the tenant middleware to your HTTP routes to automatically extract tenant information:

package main

import (
    "github.com/gin-gonic/gin"
    "github.com/volvlabs/nebularcore/modules/tenant"
)

func setupRoutes(r *gin.Engine) {
    // Add tenant middleware
    r.Use(tenant.Middleware())
    
    // Your routes here
    r.GET("/api/v1/models", handleGetModels)
}
7. Migrations

Create tenant-specific migrations in your migrations directory:

-- 001_tenant.up.sql
CREATE TABLE IF NOT EXISTS tenants (
    id UUID PRIMARY KEY,
    code VARCHAR(50) UNIQUE NOT NULL,
    name VARCHAR(100) NOT NULL,
    schema_name VARCHAR(50) UNIQUE NOT NULL,
    active BOOLEAN DEFAULT true,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- Function to create tenant schemas
CREATE OR REPLACE FUNCTION public.create_tenant_schema(schema_name text)
RETURNS void AS $$
BEGIN
    EXECUTE format('CREATE SCHEMA IF NOT EXISTS %I', schema_name);
END;
$$ LANGUAGE plpgsql;

-- 001_tenant.down.sql
DROP FUNCTION IF EXISTS public.create_tenant_schema(text);
DROP TABLE IF EXISTS tenants;
8. Accessing Current Tenant

Get tenant information from the context in your handlers:

func handleGetModels(c *gin.Context) {
    tenantID, tenantCode, schema, ok := tenant.GetTenantFromContext(c)
    if !ok {
        c.JSON(http.StatusBadRequest, gin.H{"error": "No tenant context"})
        return
    }
    
    // Use the tenant information
    repo := NewMyModelRepository(db, schema)
    models, err := repo.FindModels(c)
    // ...
}

Best Practices

  1. Always Use Context: Pass the context through all repository operations to maintain tenant scoping.
  2. Model Design: Keep tenant-specific fields (like TenantID) in your models for additional validation.
  3. Schema Management: Use the provided schema creation functions instead of creating schemas manually.
  4. Error Handling: Always check for tenant context before performing database operations.
  5. Testing: Test your repositories with different tenant contexts to ensure proper isolation.

Dependencies

  • PostgreSQL 9.6 or later
  • GORM v1.25.0 or later
  • Gin Web Framework

Standalone Usage

While registering as a NebularCore module is recommended, you can use the tenant module independently:

package main

import (
    "context"
    "github.com/gin-gonic/gin"
    "gorm.io/gorm"
    "github.com/volvlabs/nebularcore/modules/tenant"
)

func main() {
    // Setup your own DB connection
    db, err := gorm.Open(/* your db config */)
    if err != nil {
        panic(err)
    }

    // Create repositories directly
    repo := tenant.NewTenantAware[MyModel](db, "tenant_schema")

    // Use middleware manually
    r := gin.New()
    r.Use(tenant.Middleware())

    // Handle tenant operations yourself
    r.POST("/api/models", func(c *gin.Context) {
        model := &MyModel{}
        if err := repo.Create(c, model); err != nil {
            c.JSON(500, gin.H{"error": err.Error()})
            return
        }
        c.JSON(200, model)
    })
}

However, you'll need to handle:

  • Schema migrations manually
  • Tenant lifecycle events
  • Configuration management
  • Resource cleanup
  • Module dependencies

Contributing

Please refer to the NebularCore contribution guidelines when contributing to this module.

Documentation

Index

Constants

View Source
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

View Source
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

func GetTenantFromContext(ctx context.Context) (id, code, schema string, ok bool)

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 NewTenantMigrateCommand[T config.Settings](app core.App[T], dbCfg config.DatabaseConfig) *cobra.Command

func QualifiedTable

func QualifiedTable(ctx context.Context, table string) (string, error)

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

func SchemaFromContext(ctx context.Context) (schema string, ok bool)

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.

func WithTenant

func WithTenant(ctx context.Context, id, code, schema string) context.Context

WithTenant adds tenant information to context.

Types

type AuthorizeFunc

type AuthorizeFunc func(c *gin.Context, tenant Tenant) bool

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 New

func New(db *gorm.DB, migrationsDir string, opts ...Option) *Module

New creates a new tenant module.

func (*Module) Initialize

func (m *Module) Initialize(ctx context.Context) error

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

func (m *Module) MigrationDir() string

MigrationDir implements module.MigrationProvider.

func (*Module) ProvidesMigrations

func (m *Module) ProvidesMigrations() bool

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

func WithHeader(header string) Option

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 NewPlugin

func NewPlugin() *Plugin

NewPlugin constructs the tenant scoping plugin.

func (*Plugin) Initialize

func (p *Plugin) Initialize(db *gorm.DB) error

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.

func (*Plugin) Name

func (p *Plugin) Name() string

Name implements gorm.Plugin.

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.

func (Tenant) TableName

func (Tenant) TableName() string

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.

func (*TenantAwareRepository[T]) Update

func (r *TenantAwareRepository[T]) Update(ctx context.Context, model *T, attrs interface{}) error

Update updates records with tenant scoping.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL