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

ADR-007 — Domain tenant resolution + glue middleware

Data: 2026-06-22 Status: Aceito Contexto: DevPack 10.6.0 Pareado com: ADR-011 do template ITLab.Template.Next (consumo no canon) e ADR-006 (persistência claim-based) Base de referência: contributors + middleware portados de ITLab.WorkGuardian (estilo ABP)

Contexto

A 10.4.0/10.5.0 entregou as abstrações de resolução (ITenantResolver, ITenantResolveContributor, TenantResolveContext/Options/Result, ITenantAccessValidator) e a persistência claim-based (ADR-006), mas o pipeline de resolução era scaffolding dormente: nenhum contributor concreto, nenhuma glue middleware, nenhuma forma de resolver o tenant pelo host. Qualquer consumidor que quisesse acme.app.com → tenant "acme" teria que reescrever a extração de host, o matching por padrão e a middleware de validação — plumbing universal (G-01) que pertence ao DevPack.

ITLab.WorkGuardian já tinha esse plumbing maduro (HttpTenantResolveContributorBase, DomainTenantResolveContributor, MultiTenancyOptionsExtensions, TenantValidationMiddleware). As abstrações do DevPack são idênticas em assinatura às do WorkGuardian (Task-based, mesmos membros), então o port é cópia + troca de namespace, sem adapter.

Decisão

Adicionar o caminho de domínio como peça universal, opt-in por config, mantendo claim-based como default.

1. Contributors (Identity/MultiTenancy/)

  • HttpTenantResolveContributorBase — base para contributors que leem o HttpContext (resolvido via ITenantResolveContext.GetHttpContext(), que requer IHttpContextAccessor). Captura exceções e loga, para um contributor nunca quebrar o pipeline.
  • DomainTenantResolveContributor — extrai o host na ordem OriginRefererRequest.Host, remove o prefixo de protocolo, e casa contra AlternateDomains (overrides literais host→tenant, primeiro) e DomainFormats (padrões com placeholder via FormattedStringValueExtracter). Logger opcional para diagnóstico.
  • MultiTenancyOptionsExtensions.AddDomainTenantResolver(formats, alternateDomains, serviceProvider?) — insere o contributor no início da lista (Insert(0, …)), para resolução por host preceder estratégias posteriores.

2. Options (AppSettings/)

  • MultiTenancyOptions (IAppOptions, seção MultiTenancyOptions): EnableDomainValidation (default false), DefaultDevelopmentTenant, DomainFormats, AlternateDomains, ExcludedPaths.
  • AlternateDomainOption: Host + Tenant.

Reduzido em relação ao WorkGuardian: removido EnableMiddlewareValidation (gate redundante — EnableDomainValidation sozinho governa a middleware) e os ExcludedPaths default específicos de OAuth do WG (DevPack ship lista vazia; o consumidor configura).

3. Glue middleware no DevPack, não no consumidor

Middleware/TenantResolutionMiddleware (app.UseDevPackTenantResolution()) amarra o fluxo: gate em EnableDomainValidation → skip anônimo/ExcludedPathsITenantResolver.ResolveTenantAsync()ITenantAccessValidator.GetTenantIdByDomainNameAsyncValidateAccessAsynccross-check do id resolvido contra ICurrentUser.TenantId.

Decisão G-01: o WorkGuardian mantém a middleware no app, mas o fluxo é idêntico para qualquer consumidor que use as interfaces. Portá-la ao DevPack (parametrizada por MultiTenancyOptions + ITenantAccessValidator) deixa o consumidor só implementando o access validator e ligando a config. O acoplamento ao app é zero: o tenant do token vem de ICurrentUser (abstração do próprio DevPack), não de um claim hardcoded.

4. Cross-check via ICurrentUser, não re-lendo o claim

A middleware do WorkGuardian relê o claim Tenant (GlobalConsts.TenantClaim) e chama context.User.GetUserId(). A do DevPack usa ICurrentUser.TenantId/ICurrentUser.Id — já populados pelo bridge (UseDevPackIdentityContext, ADR-006). Isso (a) remove o acoplamento ao nome do claim (o bridge lê tenant/tid, mas a middleware não precisa saber) e (b) força a ordem correta: a middleware tem que rodar depois do bridge, o que o XML doc e o README deixam explícito.

5. Status codes

401 quando o token não traz tenant, o host não resolve, ou há divergência host-vs-token; 404 quando o host mapeia para nenhum tenant; 403 quando o usuário autenticado não tem membership no tenant resolvido. (WorkGuardian usava 401 também para "sem acesso"; 403 é semanticamente mais correto e alinha com o TenantAccessDenied → 403 do template.)

Alternativas consideradas

  • Glue middleware no template (como no WorkGuardian): descartada — o fluxo é universal; manter no template duplicaria em todo consumidor. O cross-check via ICurrentUser elimina o único acoplamento que justificaria mantê-la no app.
  • Re-ler o claim de tenant na middleware: descartado — acopla ao nome do claim e à ordem implicitamente; ICurrentUser torna a dependência explícita.
  • EnableMiddlewareValidation separado de EnableDomainValidation: descartado — dois gates para o mesmo efeito; um só (EnableDomainValidation) é suficiente.
  • Default EnableDomainValidation = true: descartado — quebraria o caminho claim-only de consumidores existentes e o probe dotnet new (sem subdomínios). Opt-in é o default seguro.

Consequências

  • Adição não-quebrante. Nenhum tipo existente muda. Com EnableDomainValidation = false (default) a middleware é no-op e a tenancy continua claim-based — consumidores da 10.5.0 não percebem.
  • Requer IHttpContextAccessor registrado quando o domínio é ativado (DomainTenantResolveContributor resolve o request por ele).
  • Primeiro consumidor: template ITLab.Template.Next (ADR-011) — Tenant.DomainName, TenantAccessValidator.GetTenantIdByDomainNameAsync, config opt-in.
  • Testes novos: DomainTenantResolveContributorTests (host/Origin/Referer, alternate domain, no-match, sem HttpContext) + TenantResolutionMiddlewareTests (disabled/anonymous/excluded passam; missing-token-tenant/unresolved/mismatch → 401; not-found → 404; no-membership → 403; match → next).

Referências