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

ADR-006 — Tenant global query filter + stamping interceptor

Data: 2026-06-19 Status: Aceito Contexto: DevPack 10.5.0 Pareado com: ADR-009 do template ITLab.Template.Next (decisão primária — canon claim-based + Tenant aggregate) Base de referência: padrão de query filter por combinação de interfaces portado de ITLab.WorkGuardian

Contexto

A maquinaria de tenancy do DevPack já existia na 10.4.0 — IMultiTenant (int? TenantId), ICurrentUser.TenantId, UserInfo.TenantId, EntityHelper.TrySetTenantId e o pipeline de resolução (ITenantResolver + contributors). Faltava o lado de persistência: o applicator que aplica o global query filter de tenant (leitura) e o interceptor que stampa/guarda o tenant na escrita. Sem isso, qualquer consumidor (incluindo o template) teria que reescrever no seu DbContext a mesma reflexão sobre o model — um helper universal (G-01).

Decisão

Adicionar três peças universais em Infrastructure/EfCore/, e ler o claim de tenant no bridge de identidade.

1. MultiTenantModelBuilderExtensions.ApplyMultiTenantQueryFilters(this ModelBuilder, IMultiTenantDbContext)

Varre modelBuilder.Model.GetEntityTypes() e, por entidade, aplica uma única lambda de query filter combinando tenant + soft-delete, despachando por reflexão via um switch de 4 vias (isMultiTenant, isSoftDeletable):

IMultiTenantISoftDeletableFiltro aplicado
e.TenantId == ctx.CurrentTenantId && !e.IsDeleted
e.TenantId == ctx.CurrentTenantId
!e.IsDeleted
(nenhum)

Reduz o switch de 8 vias do WorkGuardian (o terceiro eixo IHasDateRestrictionPolicy é específico daquele produto, não universal) e remove o Console.WriteLine de debug.

2. Filtro referencia a instância do DbContext via IMultiTenantDbContext, não um delegate

IMultiTenantDbContext { int? CurrentTenantId { get; } } é a fonte do tenant na lambda. Decisão técnica crítica, ratificada após evidência empírica: o EF Core só re-avalia um query filter por query quando o predicado referencia a instância do DbContext. Um valor capturado atrás de um Func<int?> (ou de qualquer objeto que não seja o contexto) é "funcletizado" para uma constante assada no modelo compilado — e como o modelo é cacheado por tipo de contexto e reusado entre instâncias, o filtro congela no tenant que estava corrente quando o modelo foi construído pela primeira vez. Testes cross-context comprovaram o congelamento com a abordagem Func<int?> e a correção com IMultiTenantDbContext.

Nota: a API foi inicialmente esboçada (plano) como ApplyMultiTenantQueryFilters(Func<int?>). Trocada para IMultiTenantDbContext — mesma abordagem ratificada (applicator combina tenant+soft-delete por reflexão), correção apenas do mecanismo de captura para satisfazer a semântica de re-avaliação do EF. O consumidor implementa a interface no AppDbContext (public int? CurrentTenantId => _currentUser.TenantId;) e chama modelBuilder.ApplyMultiTenantQueryFilters(this).

3. Não clobberar filtros pré-existentes (escape hatch)

O applicator pula entidades que já têm filtro declarado (entityType.GetDeclaredQueryFilters().Count > 0). Isso (a) preserva o filtro de soft-delete que ApplyDevPackIdentityConventions aplica ao usuário de Identity (HasQueryFilter sobrescreve, não acumula — o sweep cego o apagaria), e (b) dá ao consumidor um escape hatch: declarar um filtro explícito numa entidade IMultiTenant a remove do sweep de tenant.

Implicação para entidades IMultiTenant do próprio DevPack: BaseIdentityRole : IMultiTenant não tem filtro pré-existente, então o applicator assumiria o filtro de tenant nas roles. Consumidores cujo canon usa roles globais (ex.: o template, com RolePermissions semeado globalmente) devem declarar um filtro explícito na entidade de role para opt-out — documentado no template (ADR-009).

4. MultiTenantEntityInterceptor (write-side)

sealed, primary ctor (IServiceProvider), resolve ICurrentUser de um escopo por-call (interceptor é Singleton). No-op quando não há tenant corrente (null/0 = host/global). Para entidades IMultiTenant rastreadas:

  • Added com TenantId nulo/0 → stampa o tenant corrente via ObjectHelper.TrySetProperty (set reflexivo no setter privado, mesma mecânica de EntityHelper.TrySetTenantId). Defesa em profundidade além do ctor Entity().
  • Modified/Deleted com TenantId != tenant correntelança InvalidOperationException. O query filter de leitura não cobre uma entidade carregada num escopo e salva em outro; escrita cross-tenant é tratada como bug, não fluxo.

Exposto no builder via WithMultiTenantInterceptor(). Ordem recomendada: após WithSoftDeleteInterceptor() e antes de WithDispatchDomainEventsInterceptor().

5. Bridge lê o claim tenant

DevPackIdentityContextMiddleware.DefaultMap passa a derivar UserInfo.TenantId do claim tenant (ou tid) do principal, em vez de hardcodar 0. Retrocompatível: ausência/valor não-numérico → 0 (comportamento single-tenant anterior). O mapper custom (Func<HttpContext, UserInfo?>) permanece como escape hatch para layouts de claim não-padrão.

Alternativas consideradas

  • Named query filters do EF Core 10 (HasQueryFilter("Tenant", ...) + HasQueryFilter("SoftDelete", ...)): permitiriam desabilitar seletivamente via IgnoreQueryFilters(["SoftDelete"]). Descartado para manter a combinação explícita numa só lambda (premissa do plano e paridade com a referência WorkGuardian), e porque a semântica all-or-nothing de IgnoreQueryFilters() é suficiente para o canon. Pode ser revisitado se surgir demanda por desabilitar tenant e soft-delete independentemente.
  • Func<int?> na assinatura do applicator: descartado — funcletizado para constante (ver Decisão 2).
  • IMultiTenant<TKey> genérico: descartado — int? cobre o canon; genericizar seria invasivo sem ganho.
  • Guard cross-tenant silencioso (normalizar em vez de lançar): descartado — escrita cruzada é bug. Documentado como alternativa para quem quiser tolerância.

Consequências

  • Adição não-quebrante. Nenhum tipo existente muda de contrato. IMultiTenant/ICurrentUser intactos. Consumidores single-tenant não percebem: sem claim tenant, TenantId = 0 e nenhuma entidade IMultiTenant → applicator/interceptor são no-op.
  • O template ITLab.Template.Next é o primeiro consumidor (ADR-009): AppDbContext : IMultiTenantDbContext, Order : IMultiTenant, roles globais via filtro explícito de opt-out.
  • InMemory respeita os query filters (testáveis); rowversion/concorrência cross-tenant não.
  • Testes novos: MultiTenantTests (applicator: filtro por tenant, combinação com soft-delete, escape hatch, dinamismo cross-context; interceptor: stamp, no-overwrite, guard modify/delete, no-op sem tenant, contexto nulo) + DevPackIdentityContextMiddlewareTests (claim tenant/tid, ausência, não-numérico).

Referências