Skip to content
GitHubDiscord

Tenancy (lexigram-tenancy)

Multi-tenant resolution, lifecycle, and isolation for the Lexigram Framework.


lexigram-tenancy provides a composable resolver chain (JWT claim, header, subdomain, path) for tenant identification, ASGI enforcement middleware, three data-isolation strategies (row-level, schema, database), tenant lifecycle CRUD with domain event emission, and per-tenant config overrides — all wired through Lexigram’s DI/IoC container.


Terminal window
uv add lexigram lexigram-tenancy
# With SQL tenant store
uv add "lexigram-tenancy[sql]"
from lexigram import Application
from lexigram.di.module import Module, module
from lexigram.tenancy import TenancyModule
from lexigram.tenancy.config import ResolutionConfig, TenancyConfig
from lexigram.contracts.tenancy.protocols import TenantProviderProtocol
@module(
imports=[
TenancyModule.configure(
TenancyConfig(
resolution=ResolutionConfig(
resolvers=["jwt_claim", "header"],
header_name="x-tenant-id",
jwt_claim_key="tenant_id",
validator_cache_ttl=300,
),
)
)
]
)
class AppModule(Module):
pass
async def main() -> None:
async with Application.boot(modules=[AppModule]) as app:
provider = await app.container.resolve(TenantProviderProtocol)
tenants = await provider.list_tenants()
print(f"Active tenants: {len(tenants)}")
if __name__ == "__main__":
import asyncio
asyncio.run(main())

Zero-config usage: Call TenancyModule.configure() with no arguments to use all defaults.

application.yaml
tenancy:
resolution:
resolvers: ["jwt_claim", "header"]
header_name: "x-tenant-id"
jwt_claim_key: "tenant_id"
lifecycle:
isolation_strategy: "row_level"
auto_provision_isolation: true
Section titled “Option 2 — Profiles + Environment Variables (recommended)”
Terminal window
export LEX_TENANCY__ENABLED=true
export LEX_TENANCY__RESOLUTION__RESOLVERS=["jwt_claim", "header"]
from lexigram.tenancy import TenancyModule
from lexigram.tenancy.config import TenancyConfig, ResolutionConfig, LifecycleConfig
TenancyModule.configure(
TenancyConfig(
resolution=ResolutionConfig(
resolvers=["jwt_claim", "header"],
header_name="x-tenant-id",
jwt_claim_key="tenant_id",
),
lifecycle=LifecycleConfig(isolation_strategy="schema"),
)
)
FieldDefaultEnv varDescription
resolution.resolvers["jwt_claim", "header", "subdomain", "path"]LEX_TENANCY__RESOLUTION__RESOLVERSOrdered resolver list; first match wins
resolution.header_name"x-tenant-id"LEX_TENANCY__RESOLUTION__HEADER_NAMEHTTP header read by HeaderTenantResolver
resolution.subdomain_patternnullLEX_TENANCY__RESOLUTION__SUBDOMAIN_PATTERNBase domain for subdomain extraction
resolution.jwt_claim_key"tenant_id"LEX_TENANCY__RESOLUTION__JWT_CLAIM_KEYJWT payload claim key
resolution.validator_cache_ttl300LEX_TENANCY__RESOLUTION__VALIDATOR_CACHE_TTLSeconds a validated TenantInfo is cached
lifecycle.isolation_strategy"row_level"LEX_TENANCY__LIFECYCLE__ISOLATION_STRATEGY"row_level", "schema", or "database"
lifecycle.auto_provision_isolationtrueLEX_TENANCY__LIFECYCLE__AUTO_PROVISION_ISOLATIONRun isolation strategy on tenant creation
overrides.cache_ttl60LEX_TENANCY__OVERRIDES__CACHE_TTLSeconds a tenant’s config dict is cached
integration.cache_key_prefixtrueLEX_TENANCY__INTEGRATION__CACHE_KEY_PREFIXPrefix cache keys with t:{tenant_id}:
integration.sql_context_bridgetrueLEX_TENANCY__INTEGRATION__SQL_CONTEXT_BRIDGEPropagate TENANT_ID to lexigram-sql context
MethodDescription
TenancyModule.configure(config)Configure with explicit TenancyConfig
TenancyModule.stub()Minimal config for testing (exports TenantProviderProtocol and TenantConfigProviderProtocol)
  • Resolver chain — JWT claim, header, subdomain, and path resolvers in priority order
  • ASGI middleware — TenantContextMiddleware resolves tenant on every HTTP/WebSocket request
  • Three isolation strategies — row-level (default), schema-per-tenant, database-per-tenant
  • Tenant lifecycle CRUD — create, activate, deactivate, suspend with domain event emission
  • Per-tenant config overrides — key-value overrides with defaults and TenantConfigChanged events
  • Cache key prefixing — wraps CacheBackendProtocol with tenant-prefixed keys automatically
  • lexigram-sql integration — TenantSQLContextBridge enables TenantScope and multi_tenant=True filtering
import pytest
from lexigram import Application
from lexigram.tenancy import TenancyModule
from lexigram.contracts.tenancy.commands import CreateTenantCommand
from lexigram.tenancy.lifecycle.service import TenantLifecycleService
@pytest.mark.asyncio
async def test_tenant_lifecycle() -> None:
async with Application.boot(modules=[TenancyModule.stub()]) as app:
lifecycle = await app.container.resolve(TenantLifecycleService)
result = await lifecycle.create_tenant(
CreateTenantCommand(slug="acme", name="ACME Corp")
)
assert result.is_ok()
assert result.unwrap().slug == "acme"
FileWhat it contains
src/lexigram/tenancy/module.pyTenancyModule.configure(), .stub()
src/lexigram/tenancy/config.pyTenancyConfig, ResolutionConfig, LifecycleConfig
src/lexigram/tenancy/di/provider.pyTenancyProvider bundle and sub-providers
src/lexigram/tenancy/resolution/chain.pyCompositeResolver
src/lexigram/tenancy/enforcement/middleware.pyTenantContextMiddleware
src/lexigram/tenancy/enforcement/guard.pyTenantGuard
src/lexigram/tenancy/lifecycle/service.pyTenantLifecycleService
src/lexigram/tenancy/isolation/registry.pyIsolationStrategyRegistry
src/lexigram/tenancy/config_overrides/service.pyTenantConfigService