Skip to content

Contrato de escopo de grupo — telas de negócio (scope=group) ​

Decisão: ADR 0006 (docs/adr/0006-group-scoped-reads.md), 2026-07-29. Este documento é o contrato para o time de frontend e o complemento do handoff "Escopo Tenant × Consolidado (GrydScopeIndicator)". O handoff continua válido na arquitetura do shell (dois eixos, capability como gate, indicador no slot central, TenantGuard intocado, token sempre tenant token). O que muda é a camada de dados — §§ 6 e 7 aqui. Base URL /api/v1. Erros em application/problem+json.

1. O que "consolidado" é — e o que não é ​

Consolidado = todo o grupo do tenant do token. O conjunto é o tenant do grupo mais seus filhos ativos (se o token já estiver no tenant do grupo: ele mesmo mais os filhos ativos). O backend resolve esse conjunto a partir do token; o frontend nunca envia ids de tenant.

Isso corrige o handoff, que dizia "Todas as organizações = os vínculos do usuário (caller-scoped)". Vínculos do caller podem cruzar grupos distintos e nesse caso "consolidado" perde o significado de grupo. A semântica de vínculos pode voltar depois como um modo adicional (accessible) — o enum foi desenhado aberto para isso — mas hoje não existe.

Consequência direta no texto da UI: o rótulo do indicador deve falar de grupo, não de "todas as organizações". Sugestão de chave i18n: scope.group.label = "Todo o grupo" / scope.group.sub = "{groupName} · {count} ORGANIZAÇÕES".

E continua valendo, agora com força de decisão (G5): scope=group nunca é visão de plataforma. Mesmo um admin:system numa tela de negócio recebe o grupo do token. Visão plataforma existe só nas rotas /admin/* do Épico 889 — que são administração, não telas de negócio.

2. Como a tela pede o escopo ​

Query parameter na mesma rota:

GET /api/v1/pendencias?scope=tenant   →  (default, pode omitir)
GET /api/v1/pendencias?scope=group

Não existe rota gêmea /pendencias/consolidado. O handoff assumia isso no §10.1; a decisão foi por parâmetro na rota existente para que o mecanismo seja transversal — uma tela nova nasce com os dois modos sem nada de novo no backend.

ValorResultado
ausentetenant
tenantescopo do tenant do token (comportamento atual)
groupgrupo do token, se a rota declarar que serve grupo e se autorizado
qualquer outro400 SCOPE_INVALID

Comparação é case-insensitive e o valor é trimado — ?scope=%20GROUP funciona.

A rota precisa declarar que aceita. Um endpoint anuncia os modos que serve com [ScopeCapable(...)], e o Swagger daquele endpoint mostra scope com o enum exato dele. Pedir group a uma rota que não declarou é 400 SCOPE_INVALID, não 403: é um fato sobre a rota, não sobre os direitos de quem chamou — a recusa não diz se existe grupo nem se você poderia lê-lo. Na prática o front nunca deve chegar nesse 400: quem decide é a capability do §3.

3. Capabilities — vocabulário unificado ​

O GET /api/v1/auth/session passa a usar um vocabulário para escopo: none | tenant | group | global. O bloco anuncia, por domínio, o conjunto permitido e o default:

json
{
  "capabilities": {
    "users":      { "scopes": ["global"],           "default": "global" },
    "tenants":    { "scopes": ["tenant"],           "default": "tenant" },
    "pendencias": { "scopes": ["tenant", "group"],  "default": "tenant" },
    "financeiro": { "scopes": ["tenant"],           "default": "tenant" }
  }
}

Um domínio que a sessão não alcança é ausente do mapa — nunca scopes: [] nem "none" (US 4.3). Leia com encadeamento opcional: capabilities[dom]?.scopes ?? [].

Três mudanças em relação ao que está escrito hoje:

  1. consolidated não existe — o valor é group. O handoff propôs consolidated|tenant|none; o Épico 889 já havia emitido global|tenant|none para users e global|own para tenants. Eram três dicionários para o mesmo conceito e nenhum tinha consumidor ainda; ficou um só.
  2. own virou tenant e o global de users/tenants continua global (é plataforma, e é coisa diferente de group).
  3. scope singular virou scopes + default. Anunciar o conjunto — e não o máximo — é o que permite a rota both do shell degradar certo: com scopes: ["tenant"] o indicador simplesmente não aparece e o seletor não oferece a opção de grupo, sem o front precisar inferir nada.

O roteamento do §2 do handoff vira:

ts
const caps = (await api.get("/auth/session")).data.capabilities["pendencias"];
const canGroup = caps?.scopes.includes("group") ?? false;
const scope    = effectiveScope.mode === "group" && canGroup ? "group" : "tenant";
// mesma rota, sempre:
api.get("/pendencias", { params: { ...filters, scope } });

Continua valendo o "Tell, Don't Ask": o front nunca olha permissions.

4. Quem enxerga o modo grupo ​

Server-side, e são duas permissões — porque somar não é o mesmo ato que ler as linhas das empresas irmãs:

PermissãoLibera
read:group-analytics (já existia no catálogo)sumários/agregados do grupo: totais, contagens, breakdown por organização
read:group-data (nova)listagem de registros de outros tenants do grupo

Ambas são avaliadas no tenant do grupo — quem lê os dados do grupo tem papel no grupo (a role GroupAdmin recebe as duas no provisionamento). Um Admin de uma filha não passa a ver as irmãs.

Efeito prático para o front: um mesmo domínio pode ter scopes: ["tenant","group"] para o card de sumário e ficar só em tenant para a tabela. Se o seu domínio usar as duas coisas na mesma tela, trate-as como duas capabilities (ex. pendencias.summary e pendencias) e peça o par ao backend do domínio.

5. Erros ​

SituaçãoHTTPcodeO que o front faz
scope desconhecido400SCOPE_INVALIDbug de cliente — reportar
token sem grupo (tenant fora de hierarquia)422SCOPE_GROUP_UNAVAILABLEnão deveria acontecer: a capability não teria anunciado group. Degrada para tenant e reporta
sem a permissão do modo403SCOPE_NOT_AUTHORISEDidem — a capability lê o mesmo direito, no tenant do grupo, que este gate avalia; só uma revogação entre o /session e a chamada chega aqui. Degradar para tenant + re-GET /auth/session
registro fora do escopo404anti-disclosure"não existe", nunca "sem acesso"
invalidação de token401—refresh → re-/session → re-derivar capabilities (o escopo pode ter mudado)

O caminho de degradação silenciosa do handoff (§6, perda de capability) continua certo e agora tem os códigos acima como gatilho secundário.

5.1 Forma da resposta ​

Os três saem em application/problem+json com code, detail, traceId e o mesmo type:

jsonc
{
  "type":    "https://gryd.io/errors/scope-error",
  "title":   "Requisição Inválida",
  "status":  400,
  "detail":  "Escopo desconhecido. Valores aceitos: tenant, group.",
  "instance":"/api/v1/pendencias",
  "code":    "SCOPE_INVALID",
  "traceId": "…"
}

Duas garantias para o front:

  • Ramifique pelo code, nunca pelo detail. title e detail são localizados (pt-BR, en, es-ES) e seguem a cultura negociada na requisição — são texto para humano, não contrato. code e type são estáveis.
  • type é scope-error nos três, inclusive no 403. Uma recusa de escopo é distinguível de uma recusa de autorização genérica sem olhar o code.

O detail do 400 lista os valores aceitos; o do 403 não nomeia a permissão que faltou nem nenhum tenant do grupo; o do 422 não nomeia o tenant do token.

6. Payloads ​

6.1 Sumário / agregado ​

Uma chamada devolve o total e o detalhamento por organização — não peça N vezes, um por tenant:

jsonc
{
  "scope": { "mode": "group", "groupId": "…", "groupName": "Grupo Acme", "tenantCount": 7 },
  "total":    { "pending": 4812, "overdue": 311 },
  "byTenant": [
    { "tenantId": "…", "tenantName": "Acme SP",  "pending": 1204, "overdue": 88 },
    { "tenantId": "…", "tenantName": "Acme RS",  "pending":  902, "overdue": 41 }
  ]
}

byTenant existe justamente para alimentar a exigência do design (§10.3 do handoff): em escopo consolidado o dado tem que dizer de onde veio.

Garantias do bloco scope, iguais nos dois payloads:

CampoEm mode: "tenant"Em mode: "group"
mode"tenant""group" — string, nunca número
groupId / groupNamenullo tenant do grupo e o nome dele
tenantCount1tamanho do conjunto (pode ser 0: grupo sem filha ativa devolve zero linha, e continua sendo group)
truncatedausenteausente, ou true se a leitura foi cortada

Sobre truncated (US 5.3): o backend limita quantos tenants uma leitura de grupo pode tocar (MultiTenancyOptions.MaxGroupScopeTenants, default 200). O limite é folgado de propósito — a hierarquia de um nível deixa um grupo em dezenas de empresas —, então na prática o campo nunca aparece. Quando aparece:

  • ele vem true, nunca false: leitura completa omite o campo. Trate a presença como o sinal, em vez de comparar com um default;
  • tenantCount passa a ser o limite aplicado, não o tamanho do grupo. Os dois números só coincidem quando truncated está ausente;
  • o resultado é parcial e a UI precisa dizer isso. Um total consolidado que não cobriu o grupo inteiro, exibido como se tivesse coberto, é a pior forma de errar um número que vai alimentar decisão de gestão;
  • o tenant do grupo está sempre no conjunto cortado; o corte é determinístico, então duas páginas seguidas concordam sobre de quais empresas vieram.

byTenant vem ordenado por nome do tenant, com desempate por tenantId — duas organizações de mesmo nome (ou ambas sem nome) não fazem o snapshot da UI oscilar entre execuções. A ordenação é aplicada pelo backend em todos os domínios, não é responsabilidade da tela.

6.2 Listagem de registros ​

Mesmo envelope PagedResult<T> do modo tenant — mesmos filtros, mesma ordenação, mesma paginação — com dois acréscimos:

  • cada item carrega tenantId e tenantName (a coluna/legenda de organização passa a ser obrigatória na tabela em modo grupo);
  • a resposta carrega o mesmo bloco scope do §6.1.

A forma escolhida é campo no envelope, não envelope novo — vale para todos os domínios:

jsonc
{
  "items": [ { "id": "…", "tenantId": "…", "tenantName": "Acme SP", "…": "…" } ],
  "totalCount": 4812, "pageNumber": 1, "pageSize": 20,
  "totalPages": 241, "hasNext": true, "hasPrevious": false,
  "scope": { "mode": "group", "groupId": "…", "groupName": "Grupo Acme", "tenantCount": 7 }
}

scope é ausente/null em endpoint que não é scope-capable, que hoje é a maioria: uma rota que nunca ofereceu escolha de escopo não responde como se tivesse feito uma. Não trate a ausência como erro — trate como "esta rota tem um modo só".

Aviso de performance para combinar com o time de backend por domínio: paginação profunda em modo grupo pode migrar para cursor em domínios de volume alto. Se isso acontecer no seu domínio, o contrato de paginação daquele endpoint muda e será documentado no próprio domínio — não presuma page/pageSize eternos em modo grupo.

7. Query keys ​

Confirmado e obrigatório (era o §10.2 do handoff, com o vocabulário ajustado):

ts
// tenant: ['pendencias', 'tenant', tenantId, filters]
// grupo:  ['pendencias', 'group',  groupId,  filters]

O groupId na key (e não uma string fixa) evita cache cruzado quando o usuário faz switch-tenant para um tenant de outro grupo.

8. O que permanece exatamente como no handoff ​

Nada abaixo muda — é bom que esteja escrito para ninguém reabrir:

  • token é sempre tenant token; escopo não é claim, não é header, não é re-autenticação. O api-client não muda;
  • sempre existe tenant ativo; TenantGuard intocado; rota de grupo não redireciona para seleção de tenant;
  • escopo é apresentação/navegação, autorização é do backend;
  • RouteScopeCapability (tenant|global|both) do shell segue sendo declaração de rota — só troque o nome do valor global por group para não colidir com global = plataforma no vocabulário de capabilities;
  • indicador nunca fica invisível, colapsa no mobile; sem deslocamento de layout; aria-live; SSOT de breakpoints; tokens no gryd-tokens.css;
  • fatias 1-3 do plano do front seguem desbloqueadas com capability mockada — agora com o formato do §3 acima. A fatia 4 depende da fatia 4 do backend (domínio de referência).

Released under the MIT License.