Multi-Tenancy¶
Isolamento de dados para aplicações multi-tenant com configuração via Settings.
Isolamento de Dados¶
flowchart TB
subgraph "Request"
REQ[Request + X-Tenant-ID]
end
subgraph "Middleware"
TM[TenantMiddleware]
CTX[set_tenant_context]
end
subgraph "Data Layer"
QS[QuerySet]
FILTER[Auto-filter by tenant_id]
end
subgraph "Database"
T1[(Tenant A Data)]
T2[(Tenant B Data)]
T3[(Tenant C Data)]
end
REQ --> TM
TM --> CTX
CTX --> QS
QS --> FILTER
FILTER --> |tenant_id=A| T1
style TM fill:#fff3e0
style FILTER fill:#e3f2fd
Resolução de Tenant¶
flowchart LR
subgraph "Métodos de Resolução"
H[Header<br/>X-Tenant-ID]
S[Subdomínio<br/>tenant.app.com]
P[Path<br/>/tenant/...]
T[Token<br/>JWT claim]
end
H --> MW[TenantMiddleware]
S --> MW
P --> MW
T --> MW
MW --> CTX[Tenant Context]
style MW fill:#fff3e0
style CTX fill:#c8e6c9
Configuração via Settings¶
# src/settings.py
class AppSettings(Settings):
# Habilita multi-tenancy
tenancy_enabled: bool = True
# Campo FK nos models
tenancy_field: str = "workspace_id"
# Atributo do usuário com tenant ID
tenancy_user_attribute: str = "workspace_id"
# Header HTTP para tenant (fallback)
tenancy_header: str = "X-Tenant-ID"
# Rejeitar requests sem tenant
tenancy_require: bool = False
Settings de Tenancy¶
| Setting | Tipo | Default | Descrição |
|---|---|---|---|
tenancy_enabled |
bool |
False |
Habilita multi-tenancy automático |
tenancy_field |
str |
"workspace_id" |
Nome do campo de tenant nos models |
tenancy_user_attribute |
str |
"workspace_id" |
Atributo do usuário com tenant ID |
tenancy_header |
str |
"X-Tenant-ID" |
Header HTTP para tenant (fallback) |
tenancy_require |
bool |
False |
Rejeitar requests sem tenant |
Model de Tenant¶
from strider import Model, Field
from sqlalchemy.orm import Mapped
class Workspace(Model):
__tablename__ = "workspaces"
id: Mapped[int] = Field.pk()
name: Mapped[str] = Field.string(max_length=100)
slug: Mapped[str] = Field.string(max_length=50, unique=True)
TenantMixin¶
Adicione aos models que pertencem a um tenant:
from strider import Model, Field
from strider.tenancy import TenantMixin, TenantManager
from sqlalchemy.orm import Mapped
class Project(Model, TenantMixin):
__tablename__ = "projects"
objects = TenantManager["Project"]()
id: Mapped[int] = Field.pk()
name: Mapped[str] = Field.string(max_length=200)
# workspace_id é adicionado pelo TenantMixin
Queries¶
Filtrar por Tenant¶
# Tenant explícito
projects = await Project.objects.using(db).for_tenant(workspace_id).all()
# Do contexto (definido pelo middleware)
projects = await Project.objects.using(db).for_tenant().all()
Query Cross-Tenant¶
# Todos os projetos (uso admin)
all_projects = await Project.objects.using(db).all()
TenantMiddleware¶
Auto-define contexto de tenant a partir do request:
# src/settings.py
class AppSettings(Settings):
middleware: list[str] = [
"tenant", # ou "tenancy"
"auth",
]
O middleware extrai tenant de:
1. Header X-Tenant-ID
2. Tenant padrão do usuário
3. Query parameter ?tenant_id=
Definir Contexto de Tenant¶
No Middleware¶
from strider.tenancy import set_tenant_context
class CustomTenantMiddleware(ASGIMiddleware):
async def before_request(self, scope, request):
tenant_id = extract_tenant(request)
set_tenant_context(tenant_id)
return None
Na View¶
from strider.tenancy import set_tenant_context
async def my_view(request, db):
set_tenant_context(request.user.workspace_id)
# Agora for_tenant() usa este contexto
projects = await Project.objects.using(db).for_tenant().all()
Integração com ViewSet¶
from strider import ModelViewSet
from strider.tenancy import TenantMixin
class ProjectViewSet(ModelViewSet):
model = Project
async def get_queryset(self, db):
# Auto-filtra por tenant
return Project.objects.using(db).for_tenant(
self.request.state.tenant_id
)
async def perform_create(self, instance, validated_data, db):
instance.workspace_id = self.request.state.tenant_id
await instance.save(db)
Nas actions CRUD, o ViewSet expõe self.request (e self.action / self.kwargs); a assinatura recomendada de perform_create inclui db para save com a sessão async correta.
Tenancy por Subdomínio¶
class SubdomainTenantMiddleware(ASGIMiddleware):
async def before_request(self, scope, request):
host = request.headers.get("host", "")
subdomain = host.split(".")[0]
workspace = await Workspace.objects.using(db).get_or_none(
slug=subdomain
)
if workspace:
set_tenant_context(workspace.id)
request.state.workspace = workspace
return None
Tenancy por Path¶
# Rotas: /workspaces/{workspace_id}/projects/
class ProjectViewSet(ModelViewSet):
model = Project
async def get_queryset(self, db, workspace_id: int):
return Project.objects.using(db).for_tenant(workspace_id)
FlexibleTenantMixin¶
Para models que podem ser tenant-scoped ou globais:
from strider.tenancy import FlexibleTenantMixin
class Template(Model, FlexibleTenantMixin):
__tablename__ = "templates"
id: Mapped[int] = Field.pk()
name: Mapped[str] = Field.string(max_length=100)
# workspace_id é nullable
# Templates globais (workspace_id = NULL)
global_templates = await Template.objects.using(db).filter(
workspace_id__isnull=True
).all()
# Templates do tenant
tenant_templates = await Template.objects.using(db).for_tenant(workspace_id).all()
# Ambos
all_templates = await Template.objects.using(db).filter(
Q(workspace_id__isnull=True) | Q(workspace_id=workspace_id)
).all()
Com Soft Delete¶
from strider.models import TenantSoftDeleteManager
class Project(Model, TenantMixin, SoftDeleteMixin):
__tablename__ = "projects"
objects = TenantSoftDeleteManager["Project"]()
# Filtra por tenant + exclui deletados
projects = await Project.objects.using(db).for_tenant(workspace_id).all()
# Incluir deletados
projects = await Project.objects.using(db).for_tenant(workspace_id).with_deleted().all()
Campo de Tenant Customizado¶
class Project(Model, TenantMixin):
__tablename__ = "projects"
# Sobrescreve nome do campo padrão
tenant_field = "organization_id"
organization_id: Mapped[int] = Field.foreign_key("organizations.id")
Exemplo Completo¶
# src/settings.py
class AppSettings(Settings):
tenancy_enabled: bool = True
tenancy_field: str = "workspace_id"
tenancy_user_attribute: str = "workspace_id"
tenancy_require: bool = True
middleware: list[str] = [
"timing",
"tenant",
"auth",
]
# src/apps/workspaces/models.py
from strider import Model, Field
from sqlalchemy.orm import Mapped
class Workspace(Model):
__tablename__ = "workspaces"
id: Mapped[int] = Field.pk()
name: Mapped[str] = Field.string(max_length=100)
slug: Mapped[str] = Field.string(max_length=50, unique=True)
# src/apps/projects/models.py
from strider import Model, Field
from strider.tenancy import TenantMixin, TenantManager
from sqlalchemy.orm import Mapped
class Project(Model, TenantMixin):
__tablename__ = "projects"
objects = TenantManager["Project"]()
id: Mapped[int] = Field.pk()
name: Mapped[str] = Field.string(max_length=200)
# src/apps/projects/views.py
from strider import ModelViewSet
class ProjectViewSet(ModelViewSet):
model = Project
async def get_queryset(self, db):
return Project.objects.using(db).for_tenant(
self.request.state.tenant_id
)
async def perform_create(self, instance, validated_data, db):
instance.workspace_id = self.request.state.tenant_id
await instance.save(db)
Próximos Passos¶
- Soft Delete — Deleção lógica
- QuerySets — Consultas de dados
- Settings — Todas as configurações