Pular para o conteúdo principal
Versão: main (dev) 🚧

ADR-009 — Multi-tenancy row-level com resolução híbrida (claim default + contributors plugáveis)

Date: 2026-06-19 (aceito 2026-06-20) Status: Accepted Pareado com: DevPack ADR-006Tenant global query filter + stamping interceptor Relacionado: ADR-006 (auth local dual-scheme — emissora do claim), ADR-001 (vertical slices), plano Base de referência: padrões portados de ITLab.WorkGuardian (estilo ABP)

Context

O canon do template é hoje single-tenant: nenhuma entidade implementa IMultiTenant, não há global query filter por tenant, e o bridge UseDevPackIdentityContext popula ICurrentUser.TenantId sempre como 0. Ao mesmo tempo, a maquinaria de tenancy já existe no DevPack 10.4.0 — IMultiTenant (int? TenantId), ICurrentUser.TenantId, UserInfo.TenantId, EntityHelper.TrySetTenantId, e um pipeline de resolução completo (ITenantResolver + contributors + ITenantAccessValidator). Falta apenas (a) o lado de persistência (applicator de filtro + interceptor de stamping) e (b) a materialização no canon.

Precisamos decidir, para o canon: como o tenant é resolvido, qual o modelo de isolamento, o tipo do identificador e o escopo do que se entrega como referência viva — sem reintroduzir o peso do caminho domain-based do WorkGuardian (subdomínio + DNS + tabela de domínios + middleware de cross-check) onde ele não é necessário.

Decision

1. Isolamento: shared-DB, row-level

Um único banco, coluna TenantId em cada entidade IMultiTenant, isolamento por global query filter do EF Core. DB-per-tenant e schema-per-tenant ficam fora de escopo (extensão futura). É o único modelo que a maquinaria existente suporta sem reescrita e cobre a esmagadora maioria dos casos.

2. Identificador de tenant: int?

Reusa IMultiTenant.TenantId (int?) e ICurrentUser.TenantId do DevPack sem alteração. Order.Id permanece Guid — chave de entidade e identificador de tenant são conceitos distintos e não precisam coincidir. Tornar IMultiTenant genérico em TKey seria mudança invasiva no DevPack sem ganho funcional. TenantId == 0/null denota "sem tenant" (escopo host/global).

3. Resolução: híbrida — claim por padrão, contributors plugáveis

  • Padrão do canon: o tenant viaja como claim tenant no principal autenticado. AuthenticatedUserClaims.BuildIdentity (ponto único, compartilhado por cookie BFF e Bearer) emite o claim a partir de AppUser.TenantId; o bridge UseDevPackIdentityContext lê o claim → ICurrentUser.TenantId. Daí o global query filter e o stamping ativam sem qualquer mudança nos handlers. Convive naturalmente com a auth local do ADR-006.
  • Extensível: o pipeline ITenantResolver + ITenantResolveContributor do DevPack permanece de primeira classe e documentado. Consumidores que precisem de resolução por subdomínio/domínio (estilo WorkGuardian) ou por header (X-Tenant-Id) plugam um contributor no ponto de registro já existente, sem reescrever o canon.

4. Escopo: vertical completa com Tenant aggregate

Entrega-se um canon vivo e imitável (não placeholder), no estilo Orders:

  • Tenant aggregate (AuditedSoftDeletableEntity<int>, IAggregateRoot) + slices CreateTenant/GetTenantById/ListTenants.
  • Membership 1 usuário → 1 tenant via AppUser.TenantId (multi-tenant por usuário via join TenantUser fica como extensão documentada).
  • Order passa a IMultiTenant; stamping automático, factory Order.Create sem mudança de assinatura.
  • Seed de tenant default + associação do admin de bootstrap; migração AddMultiTenancy; teste de isolamento entre tenants.

5. Enforcement em duas camadas

  • Leitura: global query filter aplicado por reflexão a toda entidade IMultiTenant, combinado com o filtro de soft-delete quando aplicável (EF Core admite só 1 filtro por entidade — a combinação é obrigatória, não aditiva).
  • Escrita: MultiTenantEntityInterceptor stampa TenantId no insert e guarda contra escrita cross-tenant no update/delete (o query filter de leitura não cobre o caso de uma entidade carregada num escopo e salva em outro). Escrita cruzada é tratada como bug → lança.

Consequences

Positivas

  • Handlers e factories de domínio permanecem tenant-agnósticos — isolamento é transparente e enforced na borda do EF Core.
  • Reuso máximo do DevPack: zero mudança no contrato IMultiTenant/ICurrentUser; só somam-se applicator + interceptor + leitura do claim no bridge (todos universais → DevPack).
  • Caminho de adoção mínimo: tornar uma feature multi-tenant = implementar IMultiTenant + Property(...).IsRequired(). O resto é automático.
  • Caminho domain-based do WorkGuardian disponível como opt-in sem custo para quem não precisa.

Negativas / trade-offs

  • A semântica TenantId == 0/null = host/global precisa de disciplina: entidades de auth/JWKS/gestão-de-tenant não são IMultiTenant (ou usam IgnoreQueryFilters() deliberado). Super-admin global vs admin-de-tenant exige decisão explícita de seed.
  • Filtro dinâmico referencia membro de instância do DbContext (ICurrentUser injetado) — exige que o current user esteja resolvido no escopo; quebra silenciosa se o bridge não rodar antes da query.
  • Migração exige backfill de TenantId em dados pré-existentes antes de IsRequired.
  • InMemory (testes) respeita query filters mas não rowversion — isolamento é testável; concorrência cross-tenant não.

Alternativas consideradas

  • Resolução domain-based como padrão (WorkGuardian): rejeitada para o canon — exige subdomínio/DNS, tabela domínio→tenant, cache e middleware de cross-check token×domínio. Peso desproporcional; mantida como contributor opt-in.
  • TenantId Guid: rejeitada — alinharia à convenção de chaves do template, mas exigiria genericizar IMultiTenant/ICurrentUser.TenantId (mudança invasiva no DevPack) sem ganho funcional.
  • Só mecanismo de isolamento (sem Tenant aggregate): rejeitada — deixaria a feature "pela metade" (sem origem do tenant_id no login); contraria o princípio de canon vivo do template.
  • Example opt-in (estilo examples/Customers): rejeitada — multi-tenancy é decisão estrutural transversal, não um padrão complementar copiável feature-a-feature.

Notas de implementação (as built)

Decisões concretizadas na implementação, ratificadas com o dono em 2026-06-20:

  • Applicator referencia a instância do DbContext via IMultiTenantDbContext, não um Func<int?>. O EF Core só re-avalia um query filter por query quando o predicado referencia a instância do contexto; um delegate capturado funceletiza para constante congelada no modelo cacheado. AppDbContext implementa IMultiTenantDbContext.CurrentTenantId => _currentUser.TenantId e chama modelBuilder.ApplyMultiTenantQueryFilters(this). Ver DevPack ADR-006.
  • Roles são host/global. BaseIdentityRole do DevPack é IMultiTenant; para mantê-las compartilhadas entre tenants (autorização global via RolePermissions), AppDbContext declara Entity<AppRole>().HasQueryFilter(_ => true) antes do applicator — o sweep pula entidades com filtro já declarado (escape hatch).
  • Tenant aggregate não é IMultiTenant (é o próprio escopo) — host/global, gerenciável por qualquer admin com Tenants.*. É ISoftDeletable, então o applicator lhe dá só o filtro de soft-delete.
  • Tenant default + admin de bootstrap: IdentitySeeder cria idempotentemente o tenant Default (primeiro insert numa tabela Tenants nova → id 1) e associa o admin de bootstrap a ele (AppUser.TenantId). A migração AddMultiTenancy faz backfill dos Orders/Users pré-existentes para o tenant 1 (sem FK acoplando a coluna).
  • Testes de isolamento: TestAuthenticationHandler (DevPack.Testing) emite o claim tenant a partir do header X-Test-Tenant; TenantIsolationTests cobre invisibilidade cross-tenant (leitura por id + listagem) e isolamento do host scope. O guard de escrita cross-tenant é coberto por testes unitários no DevPack.