Skip to content

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:

RecursoDescrição
🔒 Isolamento AutomáticoDados filtrados automaticamente pelo tenant atual
📝 Auto-preenchimentoTenantId definido automaticamente em entidades novas
🛡️ Proteção de ModificaçãoTenantId imutável após criação
🔓 Bypass ControladoAcesso 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:

csharp
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:

csharp
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:

csharp
// 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:

csharp
// 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:

csharp
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:

csharp
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:

csharp
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:

csharp
[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

csharp
[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)

csharp
var product = new Product { Name = "New Product" };
context.Products.Add(product);
await context.SaveChangesAsync();
// product.TenantId é automaticamente definido pelo interceptor

EntityState.Modified (Atualização)

csharp
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

csharp
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:

csharp
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:

sql
SELECT * FROM Products 
WHERE TenantId = @p0 
AND IsDeleted = 0

Integração com GrydAuth

Quando usando GrydAuth, a resolução de tenant é automática:

csharp
// 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

csharp
// 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

csharp
// 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 admin

Troubleshooting

Query não está filtrando por tenant

  1. Verifique se a entidade implementa IHasTenant
  2. Verifique se o DbContext implementa ITenantQueryContext
  3. Verifique se ApplyTenantQueryFilters(this) foi chamado no OnModelCreating
  4. Verifique se o middleware está definindo o tenant antes das operações

TenantId não está sendo preenchido

  1. Verifique se o TenantSaveChangesInterceptor está registrado
  2. Verifique se MultiTenancyOptions.IsEnabled = true
  3. 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:

  1. Ordem dos middlewares no Program.cs
  2. Se a rota está autenticada e o JWT contém o claim de tenant
  3. Se o tenant está sendo extraído corretamente

Próximos Passos

Released under the MIT License.