Appearance
ADR 0006 — Leitura com escopo de grupo em telas de negócio (scope=group)
- Status: Proposto
- Data: 2026-07-29
- Nota de status (2026-07-30): o mecanismo está implementado e verificado — F1 a F5 e F7 entregues, com o épico 925 ainda Ativo. O que falta para a promoção a Aceito é a F6, o domínio de referência ponta a ponta: hoje nenhuma rota é group-capable, e um ADR que se declara aceito sem uma tela real exercitando a decisão estaria afirmando mais do que foi verificado. Todos os critérios das features entregues foram exercidos no nível do pipeline e do módulo de auditoria, e esse desvio está declarado em cada US. A F6 foi adiada por decisão do time; quando entrar, o Status vira Aceito com a data do dia.
- Nota de leitura (2026-08-01): a seção O que já existe descreve o sistema em 2026-07-29 e é mantida assim de propósito — é o diagnóstico que justifica ter construído um caminho separado para a leitura de grupo. Uma parte dele caducou: o portão do
CrossTenantDataFilterBehaviorera, à época, permissão mais uma coluna booleana por usuário; o ADR 0007 removeu a coluna, e hoje a permissão decide sozinha. Nada disso muda este ADR: o caminho de grupo nunca consultou aquela coluna, e o motivo de ele existir — o portão do alcance de plataforma é fail-open e pede uma autoridade que um gestor de grupo não tem — continua valendo palavra por palavra. - Contexto: Estende o modelo de dois trilhos de administração do Épico 889 (sem ADR próprio; ver
docs/frontend/admin-scoping-guide.mdedocs/frontend/tenant-read-scoping-contract.md) da administração para as telas de negócio. Responde ao handoff do frontend "Escopo Tenant × Consolidado (GrydScopeIndicator)", 2026-07-29, cujo §12 declara justamente a dependência de backend que este ADR resolve. Contrato companheiro:docs/frontend/group-scope-contract.md.
Contexto
O Épico 889 resolveu quem administra o quê: um trilho de tenant (/users, /tenants, escopado ao caller) e um trilho de admin (/admin/*, visão de plataforma, com gate admin:system | manage:all-users | manage:all-tenants). Usuários e tenants são as duas únicas entidades do framework que são inerentemente globais — não carregam TenantId próprio —, e é exatamente por isso que elas precisaram daquela separação.
O requisito agora é de outra natureza. Entidades de negócio têm TenantId, e o produto precisa de telas que as leiam em dois modos:
- Por tenant — o comportamento de hoje, dirigido pelo tenant do token.
- Por grupo — a mesma tela sumarizando todos os valores de todos os tenants do grupo, ou listando os registros de todos os tenants do grupo.
O time de design entregou a linguagem visual (GrydScopeIndicator) e o time de frontend a arquitetura do shell. O que falta é a metade de backend: uma forma de o frontend dizer "gere isso para o grupo inteiro" que não seja reinventada por tela, para que uma tela criada daqui a seis meses nasça group-capable por construção.
O que já existe, e por que nada disso serve
O framework tem três marcadores de tenancy e um behavior morto. Nenhum se encaixa.
| Mecanismo | O que ele realmente faz | Por que não é isto |
|---|---|---|
Caminho padrão (ITenantAware, filtro global do EF) | e.TenantId == ctx.CurrentTenantId — igualdade num Guid único (TenantQueryFilterExtensions) | Não existe, em nenhum ponto do read path, representação de um conjunto de tenants. |
ICrossTenantRequest (+ ICrossTenantAuthorizedBy, ITenantTargetedRequest) | CrossTenantDataFilterBehavior desliga o filtro de tenant (ITenantDataFilter.Disable()) após exigir admin:system (ou permissão nomeada) e, à época deste ADR, uma segunda condição por usuário — ver a nota de leitura acima | Forma errada e grant errado. É fail-open: com o filtro fora, o handler é a única coisa entre o caller e a plataforma inteira. E exige autoridade de plataforma, que um gestor de grupo nunca terá. |
IGroupTenantRequest + GroupTenantBehavior (Core) | Código morto — zero referências em produção. Executa next() uma vez por filho direto via ICurrentTenant.Change(...) e o AggregateResults devolve results[0] por padrão | N execuções do handler e N× round-trips por request; sem ORDER BY global, sem COUNT global, sem paginação sobre o conjunto; recebe o GroupTenantId do request (cliente informa → IDOR por construção); só filhos diretos, o tenant do grupo fica de fora. |
| Escopo do caller (Épico 889) | GetTenantsQueryHandler resolve "os tenants que o caller pode ver" inline: UserRoles.GetTenantIdsWithRoleAsync(caller, GroupAdmin) + Tenants.GetPagedSummariesForUserAsync(...) | Lógica correta, não reutilizável: vive dentro de um handler, sobre linhas de Tenant, e é invisível para qualquer outro módulo. |
Dois fatos do código moldam o desenho de segurança:
O claim group_id é topologia, não direito de acesso. O SwitchTenantCommandHandler emite group_id a partir de tenant.ParentTenantId sem condição alguma, e o AuthenticationTenantInfoMapper.MapFromTenantEntity faz o mesmo no login. Todo usuário de uma company filha carrega group_id, seja GroupAdmin ou visitante. O claim responde qual é o grupo; ele nunca pode responder se o caller pode lê-lo. Qualquer gate na forma "tem group_id" vaza o grupo inteiro para qualquer usuário de qualquer filha.
Permissão é por tenant, então "em qual tenant a permissão é avaliada" é uma decisão de verdade, não um detalhe de implementação. O RoleAssignmentGuard resolve as permissões efetivas do caller no tenant do token e aplica um teto de subconjunto — o que de fato fecha o auto-escalonamento (o Admin de uma filha não detém uma permissão de grupo, logo não pode concedê-la a si mesmo). Mas se o gate consolidado também fosse avaliado no tenant do token, conceder essa permissão dentro de uma filha passaria a expor as empresas irmãs. O caminho da concessão seria legítimo e o raio de alcance, ainda assim, surpreendente.
Um problema de vocabulário já em andamento
O SessionCapabilityResolver do Épico 889 anuncia users.scope como global | tenant | none e tenants.scope como global | own. O handoff do frontend então propõe um terceiro dicionário para domínios de negócio, consolidated | tenant | none. Três vocabulários para um conceito, e nenhum deles entregue a um consumidor ainda. Este ADR trata a unificação como estando em escopo, porque o custo de fazer isso agora é renomear um enum e o custo de fazer depois é um breaking change em toda tela.
Opções
A. Uma rota gêmea por domínio (/pendencias/consolidado) — o §10.1 do handoff
Cada domínio que ganhar visão consolidada recebe uma segunda rota, uma segunda query, um segundo handler, uma segunda suíte de testes. É explícito e trivialmente auditável: dá para ler a tabela de rotas e ver exatamente que dado pode sair de um tenant.
Rejeitada: torna o mecanismo por domínio em vez de transversal, que é o oposto do objetivo declarado ("novas interfaces nascem com a possibilidade"). Cada tela nova re-paga o custo, e cada re-pagamento é uma chance de errar de forma sutil a resolução do escopo — a lógica relevante para segurança é copiada N vezes em vez de ser centralizada uma. Também duplica código de filtro, ordenação e paginação que o trabalho de DRY do Épico 889 (F1) tinha acabado de consolidar.
B. Uma intenção de escopo na rota existente (?scope=group) — recomendada
Uma rota por domínio serve os dois modos. O cliente envia uma intenção — uma palavra, nunca um id de tenant — e o servidor resolve o conjunto, verifica o direito de acesso e estreita a leitura.
- A URL identifica a representação: cacheável, visível em log e em relato de bug, cai direto na query key do TanStack que o frontend já planeja usar (§10.2 do handoff), documentada pelo Swagger, trivialmente reproduzível com
curl. - O mecanismo é transversal: implementado uma vez no Core e herdado por todo domínio, inclusive pelos endpoints gerados do
GrydCrud. - Sem duplicação de rota, de CQRS ou de testes.
C. Um header de request (X-Gryd-Scope: group)
Transversal sem tocar rota nem assinatura de query.
Rejeitada: mesma URL, corpo diferente. Quebra a semântica de cache HTTP, desaparece do log de acesso e do relato de bug do usuário ("a lista está errada", sem como saber qual modo produziu aquilo), e é o tipo de coisa que um proxy ou um SDK afoito descarta. Um header que muda silenciosamente quais linhas existem é uma arma apontada para o próprio pé; o tenant já viaja no token porque é identidade, o que é categoria diferente de um parâmetro de query que molda um resultado.
D. Ressuscitar o GroupTenantBehavior (fan-out por filho)
Rejeitada por aritmética. Um grupo de 12 companies transforma uma query em 12 execuções de handler e 12+ round-trips, e ainda assim não produz uma página ordenada e contada globalmente. Dada a latência já medida no pipeline de request (6-10 round-trips seriais por request autenticado), multiplicar execuções de handler é a pior direção disponível. A primitiva correta para "ler N tenants" é uma query sobre um conjunto, não N queries sobre um tenant.
Decisão (proposta)
Opção B, com seis decisões fechadas com o dono do produto em 2026-07-29:
| # | Decisão |
|---|---|
| G1 | "Consolidado" significa o grupo do tenant do token: o tenant do grupo mais seus filhos ativos (se o tenant do token é o grupo, ele mesmo mais seus filhos ativos). Determinístico, derivado do claim group_id que já existe, nunca de entrada do cliente. TenantScopeMode é um enum aberto, para que Accessible (os vínculos do caller, possivelmente cruzando grupos) possa entrar depois sem quebra de contrato. |
| G2 | O transporte é ?scope=tenant|group na rota existente. Ausente ⇒ tenant. Valor desconhecido ⇒ 400. O cliente nunca envia ids de tenant. |
| G3 | Dois gates. read:group-analytics (já no catálogo, nunca enforçada até aqui) autoriza agregados — contagens, somas, breakdown. Uma nova read:group-data autoriza listagem de registros de outros tenants do grupo. Ver que o grupo tem 4 812 pendências não é o mesmo ato que ler 4 812 linhas pertencentes a pessoas jurídicas irmãs; um gate só não expressa as duas coisas. |
| G4 | Os dois gates são avaliados no tenant do grupo, via IEffectivePermissionResolver.ResolveAsync(userId, groupTenantId). Quem lê os dados do grupo tem autoridade no grupo. A segregação entre empresas irmãs passa a ser estrutural, em vez de ser consequência de quem concedeu o quê dentro de uma filha. |
| G5 | scope=group não muda nada para o operador de plataforma. Em tela de negócio, até um admin:system recebe o grupo do token — nunca a plataforma. Leitura de plataforma segue exclusiva do trilho /admin/* do Épico 889. |
| G6 | O vocabulário de capability é unificado agora, antes do merge do Épico 889, num único enum: none | tenant | group | global. O /auth/session anuncia, por domínio, o conjunto permitido e o default: {"pendencias": {"scopes": ["tenant","group"], "default": "tenant"}}. Anunciar o conjunto (e não o máximo) é o que permite à rota both do frontend degradar corretamente. |
O mecanismo
Cinco peças, cada uma na camada que lhe pertence. Nada neste desenho permite que um handler esqueça de ser seguro.
1. TenantScope — um value object em Gryd.Domain.Tenancy.
csharp
public enum TenantScopeMode { Tenant = 0, Group = 1 } // aberto para Accessible/Global
public sealed record TenantScope
{
public TenantScopeMode Mode { get; }
public IReadOnlyList<Guid> TenantIds { get; } // SEMPRE materializado
public Guid? GroupTenantId { get; }
public string? GroupName { get; }
public static TenantScope Single(Guid tenantId);
public static TenantScope Group(Guid groupTenantId, string groupName, IReadOnlyList<Guid> memberIds);
}A invariante que sustenta a segurança de todo o desenho: conjunto vazio significa "nenhuma linha", nunca "sem filtro". É exatamente esse bit que o ICrossTenantRequest tem invertido.
2. Filtro de query ciente de conjunto — Gryd.Infrastructure.Tenancy.
ITenantQueryContext ganha IReadOnlyList<Guid>? ScopeTenantIds, alimentado por um novo TenantContextAccessor.ScopeTenantIds (AsyncLocal, mesmo padrão e mesma justificativa já documentados naquela classe). O filtro de tenant obrigatório passa a ser:
csharp
e => !ctx.IsTenantFilterEnabled
|| (ctx.ScopeTenantIds != null
? ctx.ScopeTenantIds.Contains(e.TenantId) // ← modo grupo
: ctx.CurrentTenantId == null
? !ctx.RequireTenantOnRead
: e.TenantId == ctx.CurrentTenantId); // ← caminho single-tenant, inalteradoÉ o null (não o vazio) que seleciona o caminho single-tenant, então toda query existente mantém exatamente o SQL e o plano de execução atuais — superfície de regressão zero. No EF Core 10 / .NET 10, um Contains sobre um parâmetro de coleção é traduzido como um único parâmetro de array, então o reuso de plano não degrada com a aridade do grupo (a clássica explosão de plan cache com IN (@p0..@pN) não se aplica aqui). O filtro de IOptionalTenant recebe o mesmo tratamento.
3. TenantScopeBehavior<TRequest,TResponse> — Gryd.Application.Behaviors.
Roda para requests que implementam o novo marcador, resolve uma vez, aplica pela duração do handler e restaura no dispose:
csharp
public interface IGroupScopableQuery { TenantScopeMode RequestedScope { get; } }
public interface IGroupAggregateQuery : IGroupScopableQuery { } // gate read:group-analytics
public interface IGroupRecordQuery : IGroupScopableQuery { } // gate read:group-dataOrdem: depois do TenantInjectionBehavior, antes da validação. As guardas, todas fail-closed: RequestedScope == Tenant ⇒ passa direto, sem tocar em nada (custo zero para os requests de hoje — o caminho de grupo é opt-in por request, o que importa dado o perfil de latência conhecido); um request que implemente IGroupScopableQuery e ICrossTenantRequest ⇒ InvalidOperationException (os dois são mutuamente exclusivos por construção); sem grupo no contexto ⇒ 422 SCOPE_GROUP_UNAVAILABLE; sem o direito de acesso ⇒ 403 SCOPE_NOT_AUTHORISED.
4. ITenantScopeResolver — abstração no Core, implementação no GrydAuth.
csharp
Task<Result<TenantScope>> ResolveAsync(TenantScopeMode requested, ScopeKind kind, CancellationToken ct);A implementação no GrydAuth é onde G1 e G4 moram, e é também onde a dívida de DRY do Épico 889 é paga: a primitiva "tenants deste grupo" sai de dentro do GetTenantsQueryHandler para um serviço que tanto a listagem de tenants quanto toda leitura escopada por grupo consomem. Essa extração compra uma invariante que vale dizer em voz alta: o conjunto de tenants que o switcher lista e o conjunto de tenants que uma tela consolidada agrega são calculados pelo mesmo código, então não podem divergir.
Cache: chave (userId, groupTenantId, tokenVersion), dobrada na entrada de cache por usuário que o pipeline já lê, de modo que o escopo de grupo custa nenhum round-trip adicional com cache quente. A invalidação pega carona no bump de token_version que já existe — o mesmo hook que já cobre mudança de permissão, desvínculo de tenant e desativação. Nenhuma superfície nova de invalidação.
5. Escrita é estruturalmente impossível sob escopo de grupo.
Duas travas independentes. Estaticamente, só existem marcadores de query — não existe IGroupScopableCommand, e um teste de arquitetura garante que nenhum tipo que implemente IGroupScopableQuery implemente também um marcador de escrita. Em tempo de execução, o TenantSaveChangesInterceptor lança quando ScopeTenantIds != null e o change tracker tem qualquer entidade Added/Modified/Deleted. Um handler futuro que tentar escrever com escopo de grupo ativo falha nos testes, imediatamente, com mensagem clara — em vez de gravar silenciosamente uma linha no tenant errado.
Contratos de resposta
Agregados devolvem o total e o detalhamento por tenant a partir de uma única query GROUP BY TenantId — nunca N queries. O breakdown não é um extra simpático: o §10.3 do handoff do frontend já exige coluna/legenda de organização em modo consolidado, então o dado precisa dizer de onde veio.
jsonc
{
"scope": { "mode": "group", "groupId": "…", "groupName": "Grupo Acme", "tenantCount": 7 },
"total": { "pending": 4812, "overdue": 311 },
"byTenant": [ { "tenantId": "…", "tenantName": "Acme SP", "pending": 1204, "overdue": 88 } ]
}Listagens de registros mantêm PagedResult<T> — mesmo envelope, mesmo problem+json, mesmos filtros — com tenantId/tenantName em cada item e o mesmo bloco scope.
Performance
| Preocupação | Posição |
|---|---|
| Filtro por conjunto | Uma query, WHERE TenantId = ANY(@ids). Exige que os índices de leitura do domínio comecem por TenantId e carreguem a chave de ordenação: (TenantId, <sortKey>, Id). Isso é um requisito de índice por domínio, e pertence ao definition of done do domínio, não ao Core. |
| Paginação profunda | OFFSET sobre N tenants degrada de forma multiplicativa. Modo grupo em domínio de volume alto deve usar paginação por keyset/cursor; a saída é documentada por domínio, em vez de imposta a todos. |
COUNT | A metade caro do PagedResult em modo grupo. Onde total exato não vale o custo, um domínio pode expor countMode=exact|estimated; o default segue exato. |
| Agregados | Sempre um GROUP BY, nunca fan-out. Esse é o erro específico que o GroupTenantBehavior codificou e é a razão de ele ser deletado, em vez de consertado. |
| Aridade do grupo | A hierarquia de um nível limita um grupo aos seus filhos diretos — dezenas, não milhares. O resolver ainda assim limita o conjunto e loga quando o limite truncar: um limite silencioso é lido como "cobrimos tudo" quando não cobriu. |
| Resolução do escopo | Lazy (só para scope=group), cacheada por token_version, dobrada na entrada de cache existente. Requests em modo tenant ficam byte a byte inalterados. |
Observabilidade e auditoria
Toda leitura com escopo de grupo enriquece o trace e o log com gryd.scope.mode, gryd.scope.group_id e gryd.scope.tenant_count (os ids em si não são logados — a contagem basta para detectar anomalia, e o id do grupo já não é segredo). Leituras de registro em escopo de grupo (read:group-data) também emitem evento de auditoria: quem leu registros de quais empresas irmãs, e quando. Sob G3, essa trilha é justamente o ponto — acesso a registro entre pessoas jurídicas distintas dentro de um grupo é exatamente o que o jurídico de um cliente vai perguntar.
Consequências
- Uma tela se torna group-capable declarando uma interface marcadora e um índice. Sem rota nova, sem handler gêmeo, sem permissão nova por domínio.
GroupTenantBehavior,IGroupTenantRequest,IGroupTenantChildrenProvidereGroupTenantChildsão deletados. Remover do Core uma abstração morta e errada é parte desta decisão, não um recado à parte: deixá-la lá é convite para alguém usá-la.read:group-analyticsganha enforcement pela primeira vez — a mesma transição que o Épico 889 fez comread:group-tenants.read:group-dataé uma entrada nova no catálogo (+ migration). Nenhuma das duas entra no template da roleAdmin; as duas entram no template da role GroupAdmin, de modo que o administrador do grupo as tem de fábrica, e o teto de subconjunto doRoleAssignmentGuardimpede que qualquer outro as conceda para cima. A US 2.2 substituiu a flagAssignToAdminRolepor templates de permissão por role (RoleTemplate.Permissions), que é o que torna essa distinção possível — a flag só sabia falar doAdmin.- Um vocabulário de capability substitui três. Isso precisa entrar antes do merge do Épico 889, ou o frontend nasce com dois dicionários.
- Os ramos hardcoded de users/tenants do
SessionCapabilityResolverviram um registro deICapabilityContributor— um domínio se registra em vez de editar o resolver (OCP). A suíte de consistência capability × enforcement que já existe (F6/US 916) se estende a todo contributor registrado, e é isso que mantém o anúncio honesto conforme os domínios se multiplicam. - O handoff do frontend precisa de seis correções (rota gêmea → query param; "todas as organizações" → "todo o grupo"; vocabulário; contrato
scopes/default; envelope de agregado; coluna de tenant). Estão itemizadas emdocs/frontend/group-scope-contract.md. - Nova obrigação por domínio: o índice acima. Uma tela com escopo de grupo sem ele funciona em dev e cai no maior tenant em produção.
O que mudaria esta decisão
- Uma tela realmente precisar dos vínculos do caller cruzando grupos distintos. Aí
Accessibleentra no enum; o determinismo de G1 é trocado pela semântica original de "todas as organizações" do frontend. O enum é aberto precisamente para que isso custe um branch no resolver e um valor de capability, não um redesenho. - A aridade do grupo deixar de ser limitada (hierarquias de múltiplos níveis, grupos de centenas). Aí filtro por conjunto mais breakdown por tenant deixa de ser o read model correto e isso passa a ser questão de view materializada / read model.
- Um domínio precisar de escrita com escopo de grupo. Nada aqui suporta isso, deliberadamente. É outro ADR, e ele começa explicando qual tenant é dono da linha.
- Avaliar o gate no tenant do grupo se mostrar grosseiro demais — por exemplo, um cliente querer que um usuário veja os números do grupo em um domínio, mas não em outro. Aí G3 ganha uma convenção de sufixo por domínio; o resolver já recebe a chave do domínio, então a costura existe.
Plano de implementação
Alinhado às quatro fatias do frontend, para os dois lados avançarem incrementalmente. As fatias 1-2 desbloqueiam as fatias 1-3 do frontend (que ele já pode desenvolver com capability mockada).
- Mecanismo no Core, sem domínio.
TenantScope, filtro ciente de conjunto,TenantScopeBehavior, marcadores, abstraçãoITenantScopeResolver, trava de escrita no interceptor, deleção do behavior de grupo morto, teste de arquitetura. Aceite: suíte inteira verde sem mudança no SQL de nenhuma query existente. - Resolução e direito de acesso (GrydAuth). Entrada
read:group-datano catálogo + migration + semeadura na GroupAdmin + migração dos grupos existentes; resolver de membros do grupo extraído doGetTenantsQueryHandlere compartilhado; avaliação no tenant do grupo; cache portoken_version; auditoria + enriquecimento de trace. Aceite: matriz de autorização — visitante de uma filha, Admin de uma filha, GroupAdmin,admin:system(G5), caller sem grupo; caminhos 403/422 anti-disclosure. - Contrato de capability. Enum unificado, registro
ICapabilityContributor,scopes/defaultpor domínio no/auth/session, suíte de consistência estendida,docs/frontend/*atualizados. Aceite: nenhuma capability pode anunciar um escopo que seus endpoints não honram. - Domínio de referência. Uma tela real ponta a ponta — agregado + listagem de registros, o índice, a decisão de paginação, a fatia 4 do frontend. Aceite: o checklist de QA do §15 do handoff do frontend mais um teste de vazamento cross-tenant com duas empresas irmãs e um grupo não relacionado.
Referências
- Backlog do Épico 889 e
docs/frontend/admin-scoping-guide.md(modelo de dois trilhos, capabilities) docs/frontend/tenant-read-scoping-contract.md(D11, leitura de tenants escopada ao caller)- ADR 0002 / ADR 0003 — resolução de permissões efetivas (o resolver do qual G4 depende)
- Handoff do frontend "Escopo Tenant × Consolidado (GrydScopeIndicator)", 2026-07-29, §12
docs/modules/auth/authorization.md— catálogo de permissões e enforcement