Skip to content

ADR 0007 — A permissão é a única fonte de verdade do alcance cross-tenant ​

  • Status: Aceito
  • Data: 2026-07-31
  • Nota de status (2026-08-01): nasceu Proposto, junto com o Épico 986, e é promovido a Aceito hoje porque as três features que o implementam estão entregues: F1 (portão único), F2 (capability verdadeira por construção) e F3 (SystemAdmin × Admin). Diferente do ADR 0006, que segue Proposto por falta de uma tela real exercitando o mecanismo, aqui não há nada a aguardar: cada decisão abaixo é exercida por teste sobre superfície HTTP existente, e a lista de onde está registrada na seção Verificação.
  • Nota de status (2026-09-06): a A3 foi generalizada ao entrar a primeira rota group-capable (o diretório de usuários). O gate de grupo avalia a permissão no tenant do grupo (ADR 0006, G4), e um contributor que lesse só o conjunto do tenant do token estaria apostando sobre esse gate — anunciando group a quem tem o direito na filial (403 na chamada) e calando para quem o tem só no grupo. A A3 continua valendo palavra por palavra: o contributor decide a partir dos conjuntos efetivos que recebe e de nada mais; o que mudou é que ele recebe um conjunto por tenant em que um gate avalia (EffectivePermissionSets: tenant do token + tenant do grupo), ambos do resolver do ADR 0002, o segundo pela mesma decisão cacheada que o endpoint usa. O teste estrutural (EveryContributor_TakesNothingButThePermissionEvaluator) segue intacto, e a bi-implicação de grupo é asserida em UserDirectoryGroupCapabilityTests.
  • Contexto: Fecha dois defeitos de parametrização nascidos no Épico 889 (dois trilhos /users ↔ /admin/users, decisões D1–D11) e verificados na branch feature/tenant-fix. Não são defeitos de enforcement — o enforcement estava correto e coberto por teste. O que estava errado é quantas coisas o operador precisava acertar para que a regra funcionasse, e quantos nomes diferentes a mesma palavra tinha. Relaciona-se com o ADR 0002 (uma pergunta, um lugar que responde) e com o ADR 0006 (leitura de grupo, que este ADR não toca).

Contexto ​

D-A — o alcance cross-tenant tinha DUAS fontes de verdade ​

Para alcançar /admin/users ou /admin/tenants, o caller precisava de duas coisas independentes:

  1. a permissão — admin:system, ou manage:all-users / manage:all-tenants —, verificada no atributo de política do endpoint e de novo no CrossTenantDataFilterBehavior;
  2. a coluna booleana Users.AllowCrossTenantAccess = true, verificada só no CrossTenantDataFilterBehavior, depois da permissão.

A coluna não tinha superfície de API. User.GrantCrossTenantAccess e User.RevokeCrossTenantAccess existiam no domínio e não tinham nenhum chamador em src/; os eventos UserCrossTenantAccessGrantedEvent e UserCrossTenantAccessRevokedEvent eram publicados e não tinham handler. Quatro tipos vivos e inalcançáveis — o que a premissa "tipo criado e não referenciado é defeito, não preparação" já classificava como código morto.

O que isso produzia, medido:

FatoConsequência
O único usuário com a flag ligada era o admin@local.com do seedNão existia caminho de produto para criar um segundo administrador global. Só UPDATE direto no banco, sem auditoria e sem passar pelo domínio — havia inclusive um scripts/auth/grant-cross-tenant-access.sql para isso
UsersCapabilityContributor e TenantsCapabilityContributor decidiam global olhando só permissõesA sessão anunciava users.default = "global", o frontend abria a área, e o backend respondia 403 em todo /admin/users. É exatamente a divergência anúncio × enforcement que o D5 do 889 ("Tell, Don't Ask") existe para impedir
A suíte de consistência não pegavaOs fixtures ligavam a flag antes de comparar os dois lados. O teste concordava com o sistema porque nunca exercitava o lado desligado
A flag tinha um segundo papel, no GrydAuthTokenProcessor: assumir o contexto de um tenant sem UserTenantQuase inalcançável — ver abaixo

Por que o segundo papel era quase inalcançável. O atalho do middleware só dispara sobre o tenant que a requisição nomeia, e ExtractTenantId lê as fontes nesta ordem: claim tenant_id, header X-Tenant-ID, query tenantId. O claim vence sempre que existe — e ele só é emitido por login ou switch-tenant, que já validam vínculo ativo. Header e query, portanto, só são consultados quando o token não traz tenant_id, ou seja, num token global (o seletor de tenant). O atalho existia, era real, e o caminho para alcançá-lo era estreito o bastante para que ninguém soubesse que ele existia — que é a pior combinação possível num portão de segurança.

D-B — a role Admin significava duas coisas diferentes ​

Uma única RoleTemplate chamada Admin carregava um condicional para significar coisas distintas conforme o tenant:

csharp
new(AdminRoleSeedId, AdminRoleName, "...",
    AdminRolePermissions,        // 13 granulares → em QUALQUER tenant
    [SystemAdminPermission]),    // admin:system   → SÓ no tenant plataforma

RoleTemplate.PermissionsFor(tenantId) era um if disfarçado de dado. O efeito prático: "colocar o usuário na role Admin" produzia um administrador de plataforma ou um administrador de tenant conforme qual Admin, e nada no nome dizia qual. Foi esse o erro de parametrização que originou a análise.

Agravava: Role.RemovePermission protegia a role por string literal (IsSystemRole && Name.Equals("Admin", …)). Qualquer separação ingênua inverteria quem está protegido — passaria a blindar o administrador de tenant e a deixar o operador da plataforma exposto a perder justamente o admin:system que o torna operador.

D-C — trocar de tenant: o handler e o middleware discordavam ​

Verificado ao checar se a remoção da flag comprometeria o escopo de grupo do ADR 0006 (não compromete — aquele caminho nunca usou a flag). O SwitchTenantCommandHandler concedia a troca para um tenant irmão sem UserTenant quando o caller tinha switch:any-group-tenant avaliada no tenant pai. O middleware, porém, valida acesso a tenant por UserTenantRepository.HasAccessAsync — lookup puro de vínculo, sem noção alguma de grupo. Resultado: quem tinha a permissão trocava de tenant com sucesso, recebia um token, e levava 403 em toda requisição seguinte. Havia teste unitário do handler; não havia teste de integração que fosse até a requisição seguinte, que é exatamente onde a discordância aparece.

Opções ​

A. Manter a flag como segunda barreira — defense in depth ​

O argumento é real e merece ser registrado inteiro. Duas condições independentes protegem contra um erro em qualquer uma delas. Concretamente: se algum dia manage:all-users entrasse por engano num template de role — digamos, num RoleTemplate novo copiado de outro —, a flag desligada seguraria o estrago, porque nenhum usuário a tem por padrão. Uma permissão distribuída por engano seria inócua até alguém explicitamente ligar a coluna.

Rejeitada, e é a rejeição mais difícil deste ADR. Três razões, em ordem de peso:

  1. A ameaça de que ela protege já é fechada em outro lugar, e melhor. As permissões de alcance de plataforma não estão em nenhum template do catálogo, e isso é decisão explícita e fixada por teste (AuthorizationCatalogDefinitionsTests, por nome e por contagem). Distribuí-las por engano exige editar o catálogo, que é o arquivo mais lido da autorização. E o RoleAssignmentGuard já impunha o teto de menor privilégio na atribuição a usuário: ninguém concede o que não tem.
  2. O custo é permanente e o benefício é marginal. A flag era uma segunda fonte de verdade sem tela, sem endpoint, sem evento de auditoria e com cache de 5 minutos. Um estado invisível que decide autorização é, ele próprio, uma superfície de erro: o modo de falha observado não foi "alguém entrou sem poder", foi "quem podia não entrava, e a sessão dizia que entraria". Uma barreira que só falha na direção do falso negativo, e cujo falso negativo mente para o cliente, não está pagando o próprio preço.
  3. Ela quebrava a capability por construção. Enquanto existisse uma condição que os contributors não podem consultar, o anúncio da sessão seria uma aposta. A alternativa — ensinar o contributor a ler a coluna — é a opção B, e tem problema próprio.

Registro do que se perde: não há mais um freio independente da permissão. Isso é consequência aceita, não descuido, e a seção Consequências diz o que a substitui.

B. Manter a flag, dar-lhe API e fazer a capability consultá-la ​

A opção intermediária, e a mais tentadora, porque resolve o sintoma reclamado: um endpoint de concessão da flag (com evento e auditoria) fecharia o "não existe caminho de produto", e um contributor que lesse a coluna faria o anúncio voltar a bater com o enforcement.

Rejeitada porque resolve o sintoma e institucionaliza a causa. Depois dela, "este usuário administra através de tenants" continua sendo duas coisas que precisam concordar, para sempre — duas telas, dois eventos, dois caminhos de invalidação, dois lugares onde um deploy pode errar. E piora o contrato do contributor: passar a injetar um repositório ou um cache num ICapabilityContributor é abrir a porta para que o próximo contributor injete outra coisa, e a invariante "o anúncio olha exatamente o que o portão olha" deixa de ser verificável estruturalmente e volta a ser disciplina.

Vale nomear o que ela teria de bom: seria a única opção que preserva defense in depth e conserta a divergência. Se a resposta a "que ameaça a segunda barreira detém?" fosse diferente da do item A.1, esta seria a opção certa.

C. A permissão é a única fonte de verdade — escolhida ​

Um portão por pergunta. admin:system, manage:all-users e manage:all-tenants decidem sozinhas o alcance de dados; a coluna, os métodos de domínio, os dois eventos e a chave de cache são removidos. Sem shim, sem flag de compatibilidade, sem [Obsolete] preventivo — a mesma postura dos Épicos 889 e 925, com o breaking change comunicado por release note.

A propriedade que isso compra, e que nenhuma das outras compra: com uma condição, a capability não pode divergir do enforcement, porque não existe segundo estado que o anúncio possa deixar de consultar. "Verdadeiro por construção" passa a ser uma propriedade estrutural em vez de uma promessa mantida por revisão de código.

Decisão ​

#Decisão
A1A permissão é a única fonte de verdade do alcance cross-tenant. admin:system, manage:all-users e manage:all-tenants decidem sozinhas. Users.AllowCrossTenantAccess é removida — coluna, propriedade, métodos de domínio, eventos e entrada de cache.
A2O atalho de "super admin" do middleware cai junto. Assumir o contexto de um tenant continua exigindo UserTenant ativo, para todo mundo, inclusive admin:system. Alcance de DADOS (/admin/*, filtro do EF) e identidade de tenant (qual tenant eu sou) são conceitos separados, cada um com seu portão.
A3Com um portão só, a capability volta a ser verdadeira por construção: um ICapabilityContributor decide a partir do conjunto de permissões efetivas recebido e de nada mais, e não existe segunda condição que ele possa deixar de consultar.
A4Duas RoleTemplate distintas: SystemAdmin (só no tenant plataforma, admin:system sozinho) e Admin (em todo tenant, inclusive o plataforma, as 13 granulares). PermissionsFor(tenantId) deixa de existir.
A5RoleTemplate passa a declarar onde nasce (RoleProvisioningScope). O loop de provisionamento itera o catálogo e continua sem conhecer nome de role nem a existência de um tenant especial (OCP).
A6A proteção de role de sistema deixa de comparar string literal e passa a ser derivada do catálogo (DefinesRole + IsSystemRole).
A7Sem retrocompatibilidade. Sem shim, sem flag de compat, sem [Obsolete] preventivo. Breaking change comunicado por release note.
A8Um modelo por tipo de ato. Ler consolidado (?scope=group) continua por herança avaliada no tenant do grupo (ADR 0006, G4). Assumir identidade (switch de tenant) exige vínculo UserTenant explícito, sempre. Consequência: switch:any-group-tenant é removida.

Por que a A8 é parte desta decisão, e não um adendo ​

A A8 fecha o defeito D-C, e o fecha escolhendo um lado: handler e middleware passam a fazer a mesma pergunta. A alternativa era ensinar o middleware a entender grupo — descartada porque manteria "em quais tenants este usuário age?" sem resposta em SQL, e porque criar um tenant filho continuaria expandindo alcance silenciosamente, sem evento de concessão e sem trilha. É a mesma crítica que o ADR 0006 fez ao claim group_id: é topologia, não direito.

A outra alternativa — exigir UserTenant em todos os tenants do grupo para ler consolidado — foi descartada por dois efeitos verificados no código: consome MaxUsers em cada filial (Tenant.CanAcceptMoreUsers é enforced na vinculação); e o trilho de tenant opera sobre o vínculo (D4 do 889 — DELETE /users/{id} é unlink), então o admin de uma filial poderia desvincular o controller do grupo e tirar a própria filial do consolidado. Inversão de governança.

Sem a A8, a A2 seria uma deleção que quebraria um caminho que alguém poderia estar usando. Com ela, a A2 é uma deleção limpa de um caminho que já não funcionava.

Consequências ​

O que melhora ​

  • Um usuário com manage:all-users alcança 100% de /admin/users sem nenhum passo de banco — só atribuição de permissão pela API. O mesmo para manage:all-tenants e /admin/tenants. Existe, pela primeira vez, um caminho de produto para criar um segundo administrador global.
  • A sessão para de mentir. capabilities.users.default == "global" ⟺ GET /admin/users responde 200. A equivalência é asserida nas duas direções, sobre o que de fato aconteceu de cada lado.
  • O nome da role passa a dizer o que ela é. Quem opera a plataforma está em SystemAdmin; quem administra um tenant está em Admin, e a Admin do tenant plataforma é uma Admin comum.
  • Quatro tipos mortos e um script de UPDATE direto saem do repositório, junto com a única forma que existia de conceder autoridade global sem auditoria.

O que piora — e o que sustenta o que piora ​

  • Não há mais um freio independente da permissão. Um erro de concessão de manage:all-users ou manage:all-tenants passa a ter efeito imediato: antes, o portador precisaria também de uma coluna que ninguém ligava. Isto é a consequência negativa central desta decisão e não deve ser lida como detalhe.

    O que a substitui, e por que se considerou suficiente:

    1. o catálogo não põe nenhuma das três permissões de alcance de plataforma em template de role nenhum, e isso é fixado por teste por nome e por contagem — distribuí-las por engano exige editar o catálogo;
    2. o RoleAssignmentGuard impõe o teto de menor privilégio ao atribuir role ou permissão a um usuário;
    3. desde a US 3.6, o mesmo teto passou a valer ao conceder permissão a uma role — ver abaixo, porque é consequência direta desta decisão.
  • Distribuir permissão de alcance de plataforma deixou de ser decisão de tenant. Enquanto a flag existia, pôr admin:system numa role era um ato de meio efeito: o portador ainda precisaria da coluna. Com a A1, a permissão basta sozinha — e o caminho POST /roles/{roleId}/permissions/{permissionId} era gated apenas por update:roles, que está nas 13 granulares de todo Admin de tenant. Ou seja, a A1 transformou um caminho inócuo num caminho de escalada: um Admin de tenant concederia admin:system a uma role e viraria administrador global.

    A resposta foi um teto de menor privilégio restrito às permissões que o catálogo declara de alcance de plataforma (PermissionReach.Platform — hoje admin:system, manage:all-users, manage:all-tenants), aplicado no endpoint dedicado e na reconciliação do PUT /roles/{id}. Permissão que o próprio tenant inventa para o negócio dele — product:create e afins — o administrador compõe livremente nas roles dele: gateia nada no GrydAuth e não alcança fora do tenant que a possui. O alcance é dado no catálogo, não lista dentro da guard, pelo mesmo motivo da A5: uma quarta permissão global entra no teto no momento em que é declarada.

    Registrar isto aqui é o ponto: é a consequência negativa mais concreta da A1, e ela custou uma US inteira que o épico não previa.

  • A proteção de role de sistema alargou. Derivar do catálogo (A6) fez a guarda cobrir toda role que o catálogo possui, e não mais só Admin. Revogar permissão de uma SystemAdmin, GroupAdmin ou Visitor de sistema passou a responder 409 CONFLICT. Alargamento deliberado — "role de sistema" sempre quis dizer isso. AssignPermission não ganhou simetria, porque é exatamente o método pelo qual o seed e o bootstrap convergem uma role para o template: uma guarda ali proibiria a plataforma de fazer o próprio trabalho.

  • Quem referencia role por nome em cliente ou automação precisa ajustar. O admin@local.com do seed não está mais na role chamada Admin.

  • Automação que mandava X-Tenant-ID arbitrário com o token do admin do seed para de funcionar (A2). O caminho hoje é ter vínculo — e só; switch:any-group-tenant não existe mais (A8).

O que não muda ​

  • A leitura de grupo do ADR 0006 é intocada. O caminho IGroupScopableQuery → TenantScopeBehavior → ITenantScopeResolver → TenantScopeApplier nunca usou a flag; o TenantScopeResolver decide com IEffectivePermissionResolver + IPermissionEvaluator + IGroupMembershipResolver. Os gates read:group-analytics e read:group-data seguem no catálogo e no template GroupAdmin. O ADR 0006 construiu um caminho separado porque o caminho da flag era inadequado — a tabela de estado do Épico 925 dizia isso com todas as letras.
  • CROSS_TENANT_FORBIDDEN continua existindo e continua alcançável, agora com uma causa só: um endpoint cuja política é mais larga que o alcance que o request exige. Ele segue distinto de TENANT_FORBIDDEN (recusa de identidade de tenant) e de MISSING_PERMISSION (recusa da política do endpoint), e os três têm teste de contrato.
  • Ninguém perde acesso pela A1. Quem alcançava /admin/* tinha flag e permissão; a permissão basta. Quem tinha só a flag nunca alcançou.

Relação com o ADR 0002 ​

É a mesma ideia aplicada a outra pergunta.

O ADR 0002 encontrou "quais são as permissões efetivas deste usuário neste tenant?" respondida de quatro formas que discordavam entre si, e a resolveu introduzindo IEffectivePermissionResolver como porta única — não porque uma das quatro fosse melhor, mas porque duas respostas para uma pergunta é o defeito, independentemente de quais sejam.

Este ADR encontra "este caller alcança dados de outros tenants?" respondida em dois lugares que podiam discordar, e faz o mesmo: um lugar responde, os demais perguntam. A diferença é que aqui a consolidação foi por remoção e não por introdução — não havia um resolver a criar, havia uma segunda condição a apagar.

A ligação é operacional, não só temática: o conjunto que os ICapabilityContributor recebem é exatamente o que o resolver do ADR 0002 devolve, e é o mesmo que o token e o /session reportam. A capability ser verdadeira por construção (A3) depende de o ADR 0002 já ter garantido que existe um único conjunto efetivo a consultar. Sem ele, o portão único deste ADR consultaria uma fonte que poderia divergir da que o anúncio consulta, e o problema teria apenas mudado de andar.

O ADR 0003 (permissões como claim × resolução server-side) segue Proposto e independente: ele discute onde o conjunto efetivo vive, não quantas perguntas se faz sobre ele.

Verificação ​

Cada decisão é exercida por teste; nenhuma depende de leitura de código para ser confiada.

DecisãoOnde está fixada
A1ManageAllUsersScopeTests / ManageAllTenantsScopeTests — a permissão sozinha abre as duas superfícies, sem passo de banco. CrossTenantDataFilterBehaviorTests — o colaborador que lia a segunda condição saiu do construtor, então reintroduzir um segundo portão quebra a compilação da classe inteira
A2TenantIdentityGateTests (integração) e a região Tenant Access Validation de GrydAuthTokenProcessorTests — sem vínculo não há contexto de tenant, com qualquer permissão
A3CapabilityContributorTests.EveryContributor_TakesNothingButThePermissionEvaluator (reflexão sobre o assembly) e CapabilityEnforcementCoverageTests — 12 linhas, bi-implicação asserida antes dos valores esperados, para a falha relatar a violação arquitetural e não o sintoma. Para o escopo group, UserDirectoryGroupCapabilityTests — a mesma bi-implicação (scopes ∋ group ⟺ ?scope=group responde 200) sobre a topologia de referência, com o direito concedido ora na filial, ora no grupo
A4 / A5AuthorizationCatalogDefinitionsTests — o catálogo tem exatamente quatro roles, por nome e por contagem; SystemAdmin é o wildcard sozinho e é a única PlatformTenantOnly. TenantAuthorizationBootstrapTests — tenant comum recebe conjunto exato, inclusive por HTTP
A6RoleTests — Theory sobre todas as roles do catálogo, mais o caso da role não-sistema homônima (que continua permitida)
A8GroupTenantSwitchTests (integração) — a troca e a requisição seguinte, que é onde handler e middleware discordavam
US 3.6RoleAssignmentEscalationGuardTests — inclusive o positivo de product:create anexada por quem não a possui

O gate de identificadores banidos (scripts/check-banned-identifiers.sh, build/banned-identifiers.txt) impede que os nomes removidos voltem. Ele ignora ocorrências em comentário de propósito: os nomes sobrevivem na prosa que explica por que foram removidos, e banir a memória empurraria as pessoas a apagar a explicação. Ver docs/guide/code-hygiene-gates.md.

O que mudaria esta decisão ​

  • Um cliente exigir aprovação em duas etapas para autoridade de plataforma — "conceder manage:all-users requer dois operadores". Isso não é a flag de volta: a flag era um segundo estado, e o que faltaria é um segundo ato, com trilha. Seria um mecanismo de aprovação sobre a concessão, não uma segunda condição sobre o uso.
  • As permissões de alcance de plataforma passarem a entrar em templates de role. A rejeição da opção A depende disso não acontecer. Se acontecer, o teto do catálogo deixa de ser a mitigação e a discussão volta — com informação nova, que é o que este ADR existe para permitir.
  • PermissionReach deixar de ser suficiente para descrever alcance. Hoje há dois valores, Tenant e Platform, e a fronteira que importa é "sai do tenant ou não". Um terceiro alcance real — grupo, por exemplo — mudaria o teto da US 3.6 de uma comparação para uma ordem parcial.
  • A identidade de tenant precisar de um caminho de exceção auditado (suporte assumindo o tenant de um cliente para diagnóstico). A A2 fecha isso deliberadamente. Reabrir seria um ato explícito, com vínculo temporário e trilha — não um booleano.

Referências ​

  • Épico 986 e a discussion "Adendo de decisões — A8 e dívida de governança"
  • ADR 0002 — resolução única de permissões efetivas (a porta única da qual a A3 depende)
  • ADR 0003 — onde as permissões efetivas vivem (Proposto, independente)
  • ADR 0006 — leitura com escopo de grupo (o caminho que esta decisão não toca)
  • Épico 889 — dois trilhos de administração, decisões D1–D11
  • docs/frontend/admin-scoping-guide.md, docs/frontend/session-capabilities-contract.md
  • docs/modules/auth/authorization.md — catálogo de permissões e enforcement
  • docs/guide/code-hygiene-gates.md — o gate de identificadores banidos

Updated at:

Released under the MIT License.