Appearance
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
groupa 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 emUserDirectoryGroupCapabilityTests. - Contexto: Fecha dois defeitos de parametrização nascidos no Épico 889 (dois trilhos
/users↔/admin/users, decisões D1–D11) e verificados na branchfeature/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:
- a permissão —
admin:system, oumanage:all-users/manage:all-tenants—, verificada no atributo de política do endpoint e de novo noCrossTenantDataFilterBehavior; - a coluna booleana
Users.AllowCrossTenantAccess = true, verificada só noCrossTenantDataFilterBehavior, 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:
| Fato | Consequência |
|---|---|
O único usuário com a flag ligada era o admin@local.com do seed | Nã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ões | A 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 pegava | Os 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 UserTenant | Quase 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 plataformaRoleTemplate.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:
- 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 oRoleAssignmentGuardjá impunha o teto de menor privilégio na atribuição a usuário: ninguém concede o que não tem. - 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.
- 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 |
|---|---|
| A1 | A 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. |
| A2 | O 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. |
| A3 | Com 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. |
| A4 | Duas 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. |
| A5 | RoleTemplate 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). |
| A6 | A proteção de role de sistema deixa de comparar string literal e passa a ser derivada do catálogo (DefinesRole + IsSystemRole). |
| A7 | Sem retrocompatibilidade. Sem shim, sem flag de compat, sem [Obsolete] preventivo. Breaking change comunicado por release note. |
| A8 | Um 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-usersalcança 100% de/admin/userssem nenhum passo de banco — só atribuição de permissão pela API. O mesmo paramanage:all-tenantse/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/usersresponde 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á emAdmin, e aAdmindo tenant plataforma é umaAdmincomum. - Quatro tipos mortos e um script de
UPDATEdireto 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-usersoumanage:all-tenantspassa 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:
- 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;
- o
RoleAssignmentGuardimpõe o teto de menor privilégio ao atribuir role ou permissão a um usuário; - 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:systemnuma role era um ato de meio efeito: o portador ainda precisaria da coluna. Com a A1, a permissão basta sozinha — e o caminhoPOST /roles/{roleId}/permissions/{permissionId}era gated apenas porupdate: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 concederiaadmin:systema 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— hojeadmin:system,manage:all-users,manage:all-tenants), aplicado no endpoint dedicado e na reconciliação doPUT /roles/{id}. Permissão que o próprio tenant inventa para o negócio dele —product:createe 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 umaSystemAdmin,GroupAdminouVisitorde sistema passou a responder 409CONFLICT. Alargamento deliberado — "role de sistema" sempre quis dizer isso.AssignPermissionnã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.comdo seed não está mais na role chamadaAdmin.Automação que mandava
X-Tenant-IDarbitrário com o token do admin do seed para de funcionar (A2). O caminho hoje é ter vínculo — e só;switch:any-group-tenantnão existe mais (A8).
O que não muda
- A leitura de grupo do ADR 0006 é intocada. O caminho
IGroupScopableQuery→TenantScopeBehavior→ITenantScopeResolver→TenantScopeAppliernunca usou a flag; oTenantScopeResolverdecide comIEffectivePermissionResolver+IPermissionEvaluator+IGroupMembershipResolver. Os gatesread:group-analyticseread:group-dataseguem no catálogo e no templateGroupAdmin. 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_FORBIDDENcontinua 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 deTENANT_FORBIDDEN(recusa de identidade de tenant) e deMISSING_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ão | Onde está fixada |
|---|---|
| A1 | ManageAllUsersScopeTests / 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 |
| A2 | TenantIdentityGateTests (integração) e a região Tenant Access Validation de GrydAuthTokenProcessorTests — sem vínculo não há contexto de tenant, com qualquer permissão |
| A3 | CapabilityContributorTests.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 / A5 | AuthorizationCatalogDefinitionsTests — 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 |
| A6 | RoleTests — Theory sobre todas as roles do catálogo, mais o caso da role não-sistema homônima (que continua permitida) |
| A8 | GroupTenantSwitchTests (integração) — a troca e a requisição seguinte, que é onde handler e middleware discordavam |
| US 3.6 | RoleAssignmentEscalationGuardTests — 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-usersrequer 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.
PermissionReachdeixar de ser suficiente para descrever alcance. Hoje há dois valores,TenantePlatform, 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.mddocs/modules/auth/authorization.md— catálogo de permissões e enforcementdocs/guide/code-hygiene-gates.md— o gate de identificadores banidos