Appearance
Contrato de capabilities da sessão (D5)
A partir do Épico 889 (F6), o GET /api/v1/auth/session anuncia um bloco capabilities computado no servidor. É a fonte única para a UI decidir qual trilho chamar e o que renderizar — sem reimplementar a regra de autorização no cliente.
Regra de ouro: a UI decide navegação por capability, nunca por permissão crua. Reimplementar o
PermissionEvaluatorem TypeScript (if (perms.includes("admin:system"))) é deriva garantida: quando a regra do servidor muda — e ela já mudou duas vezes, commanage:all-usersna F9 do Épico 889 e com a remoção do segundo portão de alcance no ADR 0007 —, o cliente ficaria dessincronizado e mostraria portas que não abrem, ou esconderia portas que abrem. O servidor já fez essa conta pela mesma engrenagem do enforcement (Tell, Don't Ask).
Onde vem
GET /api/v1/auth/session (autenticado). O bloco descreve o caller no tenant do token — depois de um switch-tenant, chame /session de novo para reavaliar as capabilities no novo tenant.
Shape
json
{
"userId": "…",
"email": "…",
"roles": ["…"],
"permissions": ["…"],
"capabilities": {
"users": { "scopes": ["global"], "default": "global" },
"tenants": { "scopes": ["tenant"], "default": "tenant" },
"pendencias": { "scopes": ["tenant","group"], "default": "tenant" }
}
}capabilitiesé um mapa por domínio, não um objeto de propriedades fixas. Domínio novo entra como chave nova; consumidores existentes não quebram — leia apenas as chaves que conhece.- Cada entrada anuncia o conjunto (
scopes) e o padrão (default), não o máximo. É isso que permite ao shell saber se existe alternativa de escopo: comscopes: ["tenant"]o indicador simplesmente não aparece e o seletor não oferece grupo, sem o front inferir nada. - Domínio que a sessão não alcança é ausente do mapa — não vem com
scopes: []nem com"none". Ausente, vazio enoneseriam três grafias do mesmo fato; ausente é a única que o cliente trata sem caso especial (caps["users"]?.scopes ?? []). defaultestá sempre contido emscopes(validado no servidor).- Uma entrada pode trazer
fields: os campos opcionais que esta sessão pode pedir àquele domínio via?include=(hoje sóuserDirectoryoferece, comemail). Ausente quando não há nenhum — nunca[]— pelo mesmo motivo do domínio ausente. O que cada campo significa é contrato do domínio; o quefieldsdiz é só que esta sessão pode pedi-lo. Valor desconhecido: ignore. - Os valores são strings minúsculas (padrão v5), do vocabulário único
none | tenant | group | global— o mesmo que o cliente envia em?scope=. Trate valores desconhecidos de forma conservadora (degrade para o mais restrito).
Semântica de cada scope
O vocabulário
| Valor | Significa |
|---|---|
tenant | o tenant do token |
group | o grupo do tenant do token (ADR 0006) — nunca plataforma |
global | plataforma inteira: o trilho /admin/* |
none | sem alcance — na prática você não vê esse valor, porque o domínio some do mapa |
group e global são coisas diferentes: um fica dentro de um grupo, o outro cruza todos os tenants. E own não existe mais (US 4.1) — era outro nome para tenant.
users
default | Significa | Trilho da UI |
|---|---|---|
global | administra users de todos os tenants | trilho admin: /api/v1/admin/users… |
tenant | administra users apenas no tenant do token | trilho escopado: /api/v1/users… |
| (ausente) | não administra users | esconder a área de users |
Deriva (server-side): global ⇐ admin:system ou manage:all-users; tenant ⇐ qualquer read:users / create:users / update:users / delete:users; senão o domínio não aparece.
tenants
default | Significa | Trilho da UI |
|---|---|---|
global | administra tenants de toda a plataforma | trilho admin: /api/v1/admin/tenants… |
tenant | vê apenas os tenants do próprio escopo | trilho escopado: /api/v1/tenants… |
Deriva (server-side): global ⇐ admin:system ou manage:all-tenants; senão tenant. Este domínio nunca some do mapa: todo caller autenticado tem, no mínimo, a leitura escopada dos próprios vínculos (trilho D11/F7). Ver tenant-read-scoping-contract.md.
O que a capability garante (e o que não garante)
São duas afirmações, e desde o ADR 0007 elas têm forças diferentes.
Garante — alcance cross-tenant.
default == global⟺ o trilho/admin/*daquele domínio está acessível. É uma equivalência, não uma indicação: a permissão (admin:system,manage:all-users,manage:all-tenants) é a única fonte de verdade do alcance, e o cálculo da capability lê exatamente o mesmo conjunto efetivo que o portão lê.default == tenant⟹ o/admin/*daquele domínio responde 403.usersausente do mapa ⟹ nenhum dos trilhos de users é acessível.
O que mudou. Até o ADR 0007 o pipeline exigia a permissão e uma segunda condição por usuário, que os contributors de capability não podiam consultar. O resultado observado era o pior tipo de divergência: a sessão anunciava
users.default = "global", a UI abria a área, e todo/admin/usersrespondia 403. A segunda condição foi removida — não ensinada ao contributor —, e é isso que transforma "a capability é verdadeira" de promessa mantida por revisão em propriedade estrutural: não há segundo estado que o anúncio possa deixar de ler.
Não garante — o gate de leitura de cada endpoint. Cada rota mantém a própria permissão, e a capability nunca falou sobre isso:
read:usersparaGET /users,read:tenantsparaGET /tenants, e assim por diante. Ex.: um caller só comcreate:userstemusers.default == tenant, masGET /usersresponde 403 — ele cria, não lista. Ummanage:all-users"puro" temusers.default == global, entra em/admin/users, e o/userssegue 403. Garante — alcance de grupo. Desde o diretório de usuários (a primeira rota group-capable):scopescontémgroup⟺?scope=groupdaquele domínio está autorizado para este caller. O direito de leitura de grupo é avaliado no tenant do grupo (ADR 0006, G4), e é exatamente esse conjunto — o do tenant do grupo, resolvido pelo mesmo resolver, casado pelo mesmo evaluator — que o contributor lê para anunciargroup. Uma permissãoread:group-dataconcedida dentro de uma filial não anunciagroup, porque o resolver não a honraria; concedida no tenant do grupo anuncia, porque ele vai honrar. Tenant fora de hierarquia nunca anunciagroup(é o 422 dito em forma de capability). Ver group-scope-contract.md.Como no trilho
global, a rota mantém o próprio gate de leitura:groupanunciado diz que o escopo abre, não que toda rota do domínio responde 200.
Em uma frase: a capability diz onde bater, e para os escopos global e group diz também que você entra — nunca que toda rota daquele trilho responde 200.
Essa correspondência anúncio ⇄ enforcement é testada ponta a ponta (CapabilityEnforcementCoverageTests): mudar o gate de um endpoint sem atualizar o resolver (ou vice-versa) quebra a suíte. A primeira afirmação (default == global ⟺ /admin/* acessível) é verificada como bi-implicação, nas duas direções, para cada caller da tabela — inclusive os que a capability exclui.
Exemplos de payload
Admin global (admin:system):
json
{
"capabilities": {
"users": { "scopes": ["global"], "default": "global" },
"tenants": { "scopes": ["global"], "default": "global" }
}
}Admin de tenant (read:users + update:users, sem admin:system):
json
{
"capabilities": {
"users": { "scopes": ["tenant"], "default": "tenant" },
"tenants": { "scopes": ["tenant"], "default": "tenant" }
}
}Usuário comum (sem permissões de users; só leituras próprias) — repare que users não aparece:
json
{
"capabilities": {
"tenants": { "scopes": ["tenant"], "default": "tenant" }
}
}Uso no cliente
ts
const { capabilities } = await api.get("/auth/session").then(r => r.data);
// Roteamento por capability — nunca por permissão crua.
// Note o encadeamento opcional: domínio ausente é o caso normal, não um erro.
const usersBase =
capabilities.users?.default === "global" ? "/admin/users"
: capabilities.users?.default === "tenant" ? "/users"
: null; // ausente → não renderiza a área de users
const tenantsBase =
capabilities.tenants?.default === "global" ? "/admin/tenants" : "/tenants";
// Escopo de grupo (ADR 0006): o seletor só existe se houver alternativa.
const canGroup = capabilities.pendencias?.scopes.includes("group") ?? false;Escrita continua sempre nos endpoints escopados (/users, /tenants), gated por permissão no servidor — a capability governa navegação/roteamento, não substitui o enforcement.
Consistência e cache
As capabilities derivam do mesmo conjunto efetivo de permissões que o token e o /session já usam (resolver único, ADR 0002). Eventos que afetam autorização sobem o token_version e invalidam o token; o próximo /session recomputa o bloco. Não há caminho de cache separado a invalidar.
Para group, o /session lê dois conjuntos, porque os gates leem dois: o do tenant do token (atributos de rota e gate da pipeline) e o do tenant do grupo (resolver de escopo, G4). Qual tenant é o grupo vem da mesma decisão cacheada que o endpoint usa para autorizar ?scope=group (chave com token_version, TTL de 5 min), e o conjunto naquele tenant vem do mesmo resolver do ADR 0002. Um anúncio e um 403 discordarem só é possível dentro dessa janela de topologia — nunca por regra diferente.