Multi-Tenancy no GrydCrud
Multi-Tenancy permite que sua aplicação sirva múltiplos clientes (tenants) usando a mesma base de código, isolando dados automaticamente por tenant.
Visão Geral
O GrydCrud integra-se nativamente com o sistema de multi-tenancy do Gryd.Infrastructure, proporcionando:
| Recurso | Descrição |
|---|---|
| 🔒 Isolamento Automático | Dados filtrados automaticamente pelo tenant atual |
| 📝 Auto-preenchimento | TenantId definido automaticamente em entidades novas |
| 🛡️ Proteção de Modificação | TenantId imutável após criação |
| 🔓 Bypass Controlado | Acesso cross-tenant para cenários administrativos |
Como Funciona
┌─────────────────────────────────────────────────────────────────────┐
│ Request Flow │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Request │───▶│ Middleware/Adapter│───▶│ TenantContext │ │
│ │ (JWT) │ │ extrai TenantId │ │ Accessor │ │
│ └──────────┘ └──────────────────┘ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ EF Core DbContext │ │
│ │ ┌─────────────────────┐ ┌─────────────────────────────┐ │ │
│ │ │ Query Filters │ │ SaveChanges Interceptor │ │ │
│ │ │ WHERE TenantId = X │ │ SET TenantId = X on INSERT │ │ │
│ │ └─────────────────────┘ └─────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘Configuração Passo a Passo
1. Entidade com Tenant
Implemente IHasTenant em entidades que devem ser isoladas por tenant:
using Gryd.Domain.Primitives;
using Gryd.Domain.Abstractions;
public class Product : AggregateRoot, IHasTenant
{
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
// Propriedade exigida por IHasTenant
public Guid TenantId { get; private set; }
}Entidades Globais
Entidades que não implementam IHasTenant são globais e visíveis para todos os tenants (ex: categorias globais, configurações do sistema).
2. DbContext com Query Filters
Configure seu DbContext para aplicar os filtros de tenant automaticamente:
using Gryd.Infrastructure.Tenancy;
using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext, ITenantQueryContext
{
public DbSet<Product> Products => Set<Product>();
// Implementação obrigatória de ITenantQueryContext
// Lê do TenantContextAccessor no momento da query
public Guid? CurrentTenantId => TenantContextAccessor.TenantId;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Aplica filtros automaticamente para TODAS as entidades IHasTenant
modelBuilder.ApplyTenantQueryFilters(this);
// Opcional: aplicar filtro de soft delete também
modelBuilder.ApplySoftDeleteQueryFilters();
}
}Importante
O DbContext DEVE implementar ITenantQueryContext. O EF Core parametriza corretamente a propriedade CurrentTenantId e avalia seu valor no momento da execução da query.
3. Registrar o Interceptor
Configure o interceptor para auto-preenchimento de TenantId:
// Program.cs
using Gryd.Infrastructure.Tenancy;
using Gryd.Infrastructure.Services;
using Gryd.Application.Abstractions.Tenancy;
var builder = WebApplication.CreateBuilder(args);
// Configurar opções de multi-tenancy
builder.Services.AddSingleton(new MultiTenancyOptions
{
IsEnabled = true,
AllowTenantIdModification = false // Previne alteração de TenantId
});
// Registrar contexto de tenant
builder.Services.AddScoped<ITenantContextWriter, TenantContext>();
builder.Services.AddScoped<ITenantContext>(sp => sp.GetRequiredService<ITenantContextWriter>());
// Registrar o interceptor
builder.Services.AddScoped<TenantSaveChangesInterceptor>();
// Configurar DbContext com interceptor
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
options.UseSqlServer(connectionString);
options.AddInterceptors(sp.GetRequiredService<TenantSaveChangesInterceptor>());
});4. Configurar Resolução de Tenant
O tenant deve ser definido antes de qualquer operação de banco de dados. Com GrydAuth, isso é automático via middleware. Para cenários customizados:
// Middleware customizado de tenant
public class TenantMiddleware
{
private readonly RequestDelegate _next;
public TenantMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(
HttpContext context,
ITenantContextWriter tenantContext)
{
// Extrair tenant do JWT, header, subdomínio, etc.
var tenantId = ExtractTenantId(context);
if (tenantId.HasValue)
{
tenantContext.SetTenant(tenantId.Value, tenantName: null);
}
await _next(context);
}
private Guid? ExtractTenantId(HttpContext context)
{
// Exemplo: extrair do claim do JWT
var claim = context.User.FindFirst("tenant_id");
return claim != null ? Guid.Parse(claim.Value) : null;
}
}
// Program.cs
app.UseMiddleware<TenantMiddleware>();Componentes Disponíveis
TenantContextAccessor (Static)
Armazenamento estático thread-safe usando AsyncLocal:
using Gryd.Infrastructure.Tenancy;
// Definir tenant (normalmente feito pelo middleware)
TenantContextAccessor.SetTenant(tenantId, "Empresa ABC");
// Ler tenant atual
Guid? currentTenant = TenantContextAccessor.TenantId;
string? tenantName = TenantContextAccessor.TenantName;
bool hasTenant = TenantContextAccessor.HasTenant;
// Limpar contexto
TenantContextAccessor.Clear();ITenantContext (Read-only)
Interface para leitura do contexto de tenant em serviços:
public class ProductService
{
private readonly ITenantContext _tenantContext;
public ProductService(ITenantContext tenantContext)
{
_tenantContext = tenantContext;
}
public async Task<Product> CreateProductAsync(CreateProductDto dto)
{
// Verificar se há tenant
if (!_tenantContext.HasTenant)
throw new InvalidOperationException("Tenant is required");
// Obter TenantId obrigatório (lança exceção se não houver)
var tenantId = _tenantContext.GetRequiredTenantId();
// O TenantId será definido automaticamente pelo interceptor
var product = new Product { Name = dto.Name, Price = dto.Price };
return product;
}
}ITenantFilterBypass
Para cenários administrativos que precisam acessar dados de todos os tenants:
using Gryd.Infrastructure.Tenancy;
public class AdminReportService
{
private readonly ITenantFilterBypass _tenantBypass;
private readonly AppDbContext _context;
public AdminReportService(
ITenantFilterBypass tenantBypass,
AppDbContext context)
{
_tenantBypass = tenantBypass;
_context = context;
}
public async Task<List<Product>> GetAllProductsAcrossTenantsAsync()
{
// Executa query SEM filtro de tenant
return await _tenantBypass.ExecuteWithoutTenantFilterAsync(async () =>
{
return await _context.Products.ToListAsync();
});
}
}
// Registrar no DI
builder.Services.AddScoped<ITenantFilterBypass, TenantFilterBypass>();Cuidado
Use ITenantFilterBypass apenas em cenários administrativos controlados. O bypass remove a proteção de isolamento de dados entre tenants.
Uso com GrydCrud Controllers
Os controladores CRUD herdam automaticamente o comportamento de multi-tenancy:
[Route("api/[controller]")]
public class ProductsController : CrudController<Product, CreateProductDto, UpdateProductDto, ProductDto>
{
public ProductsController(ICrudService<Product, CreateProductDto, UpdateProductDto, ProductDto> service)
: base(service)
{
}
// GET /api/products - retorna apenas produtos do tenant atual
// POST /api/products - TenantId é definido automaticamente
// PUT /api/products/{id} - só atualiza se pertencer ao tenant
// DELETE /api/products/{id} - só deleta se pertencer ao tenant
}Operação Administrativa Cross-Tenant
[Route("api/admin/products")]
[Authorize(Roles = "SuperAdmin")]
public class AdminProductsController : ControllerBase
{
private readonly ITenantFilterBypass _tenantBypass;
private readonly AppDbContext _context;
[HttpGet]
public async Task<IActionResult> GetAllProducts()
{
var products = await _tenantBypass.ExecuteWithoutTenantFilterAsync(
() => _context.Products.ToListAsync()
);
return Ok(products);
}
}Comportamento do Interceptor
O TenantSaveChangesInterceptor gerencia automaticamente o TenantId:
EntityState.Added (Nova Entidade)
var product = new Product { Name = "New Product" };
context.Products.Add(product);
await context.SaveChangesAsync();
// product.TenantId é automaticamente definido pelo interceptorEntityState.Modified (Atualização)
var product = await context.Products.FindAsync(id);
product.Name = "Updated Name";
// Tentar alterar TenantId lança exceção se AllowTenantIdModification = false
// product.TenantId = Guid.NewGuid(); // ❌ Lança exceção
await context.SaveChangesAsync();Configuração de Opções
builder.Services.AddSingleton(new MultiTenancyOptions
{
// Habilita/desabilita multi-tenancy globalmente
IsEnabled = true,
// Permite modificação de TenantId (não recomendado em produção)
AllowTenantIdModification = false,
// Permite criar entidades sem tenant (cenários especiais)
AllowNullTenantId = false,
// Lança exceção se tenant não estiver definido
RequireTenantForOperations = true
});Query Filters Combinados
O ApplyTenantQueryFilters pode ser combinado com soft delete:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Ordem importa: tenant filter primeiro, depois soft delete
modelBuilder.ApplyTenantQueryFilters(this);
modelBuilder.ApplySoftDeleteQueryFilters();
}Isso gera queries como:
SELECT * FROM Products
WHERE TenantId = @p0
AND IsDeleted = 0Integração com GrydAuth
Quando usando GrydAuth, a resolução de tenant é automática:
// O GrydAuthTenantContextAdapter já está configurado
// O middleware de autenticação extrai o TenantId do JWT
// Você não precisa configurar nada adicional
builder.Services.AddGrydAuth(options =>
{
options.MultiTenancy.Enabled = true;
// Tenant é extraído automaticamente do claim "tenant_id"
});Boas Práticas
✅ Recomendado
// Usar ITenantContext para leitura em serviços
public class MyService(ITenantContext tenantContext) { }
// Usar GetRequiredTenantId() quando tenant é obrigatório
var tenantId = _tenantContext.GetRequiredTenantId();
// Aplicar filtros via extension method
modelBuilder.ApplyTenantQueryFilters(this);❌ Evitar
// Não acessar TenantContextAccessor diretamente em serviços
var tenantId = TenantContextAccessor.TenantId; // Use ITenantContext
// Não definir TenantId manualmente
product.TenantId = tenantId; // Deixe o interceptor fazer isso
// Não usar bypass sem necessidade real
_tenantBypass.ExecuteWithoutTenantFilterAsync(...); // Apenas para adminTroubleshooting
Query não está filtrando por tenant
- Verifique se a entidade implementa
IHasTenant - Verifique se o DbContext implementa
ITenantQueryContext - Verifique se
ApplyTenantQueryFilters(this)foi chamado noOnModelCreating - Verifique se o middleware está definindo o tenant antes das operações
TenantId não está sendo preenchido
- Verifique se o
TenantSaveChangesInterceptorestá registrado - Verifique se
MultiTenancyOptions.IsEnabled = true - Verifique se o tenant está definido antes do
SaveChangesAsync()
Erro "Tenant context is required but no tenant is set"
O middleware de tenant não está sendo executado antes da operação. Verifique:
- Ordem dos middlewares no
Program.cs - Se a rota está autenticada e o JWT contém o claim de tenant
- Se o tenant está sendo extraído corretamente
Próximos Passos
- CRUD Operations - Operações CRUD básicas
- Lifecycle Hooks - Customizar comportamento do CRUD
- Validation - Validação com FluentValidation