Skip to content

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 CrossTenantDataFilterBehavior era, à é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.md e docs/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:

  1. Por tenant — o comportamento de hoje, dirigido pelo tenant do token.
  2. 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.

MecanismoO que ele realmente fazPor 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 acimaForma 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ãoN 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.
G2O transporte é ?scope=tenant|group na rota existente. Ausente ⇒ tenant. Valor desconhecido ⇒ 400. O cliente nunca envia ids de tenant.
G3Dois 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.
G4Os 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.
G5scope=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.
G6O 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-data

Ordem: 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çãoPosição
Filtro por conjuntoUma 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 profundaOFFSET 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.
COUNTA 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.
AgregadosSempre 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 grupoA 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 escopoLazy (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, IGroupTenantChildrenProvider e GroupTenantChild sã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-analytics ganha enforcement pela primeira vez — a mesma transição que o Épico 889 fez com read:group-tenants. read:group-data é uma entrada nova no catálogo (+ migration). Nenhuma das duas entra no template da role Admin; 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 do RoleAssignmentGuard impede que qualquer outro as conceda para cima. A US 2.2 substituiu a flag AssignToAdminRole por templates de permissão por role (RoleTemplate.Permissions), que é o que torna essa distinção possível — a flag só sabia falar do Admin.
  • 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 SessionCapabilityResolver viram um registro de ICapabilityContributor — 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 em docs/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í Accessible entra 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).

  1. Mecanismo no Core, sem domínio. TenantScope, filtro ciente de conjunto, TenantScopeBehavior, marcadores, abstração ITenantScopeResolver, 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.
  2. Resolução e direito de acesso (GrydAuth). Entrada read:group-data no catálogo + migration + semeadura na GroupAdmin + migração dos grupos existentes; resolver de membros do grupo extraído do GetTenantsQueryHandler e compartilhado; avaliação no tenant do grupo; cache por token_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.
  3. Contrato de capability. Enum unificado, registro ICapabilityContributor, scopes/default por 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.
  4. 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

Updated at:

Released under the MIT License.