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-006 — Tenant 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
tenantno principal autenticado.AuthenticatedUserClaims.BuildIdentity(ponto único, compartilhado por cookie BFF e Bearer) emite o claim a partir deAppUser.TenantId; o bridgeUseDevPackIdentityContextlê 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+ITenantResolveContributordo 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:
Tenantaggregate (AuditedSoftDeletableEntity<int>,IAggregateRoot) + slicesCreateTenant/GetTenantById/ListTenants.- Membership 1 usuário → 1 tenant via
AppUser.TenantId(multi-tenant por usuário via joinTenantUserfica como extensão documentada). Orderpassa aIMultiTenant; stamping automático, factoryOrder.Createsem 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:
MultiTenantEntityInterceptorstampaTenantIdno 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/globalprecisa de disciplina: entidades de auth/JWKS/gestão-de-tenant não sãoIMultiTenant(ou usamIgnoreQueryFilters()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(ICurrentUserinjetado) — 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
TenantIdem dados pré-existentes antes deIsRequired. - 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 genericizarIMultiTenant/ICurrentUser.TenantId(mudança invasiva no DevPack) sem ganho funcional. - Só mecanismo de isolamento (sem
Tenantaggregate): rejeitada — deixaria a feature "pela metade" (sem origem dotenant_idno 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
DbContextviaIMultiTenantDbContext, não umFunc<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.AppDbContextimplementaIMultiTenantDbContext.CurrentTenantId => _currentUser.TenantIde chamamodelBuilder.ApplyMultiTenantQueryFilters(this). Ver DevPack ADR-006. - Roles são host/global.
BaseIdentityRoledo DevPack éIMultiTenant; para mantê-las compartilhadas entre tenants (autorização global viaRolePermissions),AppDbContextdeclaraEntity<AppRole>().HasQueryFilter(_ => true)antes do applicator — o sweep pula entidades com filtro já declarado (escape hatch). Tenantaggregate não éIMultiTenant(é o próprio escopo) — host/global, gerenciável por qualquer admin comTenants.*. ÉISoftDeletable, então o applicator lhe dá só o filtro de soft-delete.- Tenant default + admin de bootstrap:
IdentitySeedercria idempotentemente o tenantDefault(primeiro insert numa tabelaTenantsnova → id 1) e associa o admin de bootstrap a ele (AppUser.TenantId). A migraçãoAddMultiTenancyfaz backfill dosOrders/Userspré-existentes para o tenant 1 (sem FK acoplando a coluna). - Testes de isolamento:
TestAuthenticationHandler(DevPack.Testing) emite o claimtenanta partir do headerX-Test-Tenant;TenantIsolationTestscobre 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.