Skip to content

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 PermissionEvaluator em TypeScript (if (perms.includes("admin:system"))) é deriva garantida: quando a regra do servidor muda — e ela já mudou duas vezes, com manage:all-users na 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: com scopes: ["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 e none seriam três grafias do mesmo fato; ausente é a única que o cliente trata sem caso especial (caps["users"]?.scopes ?? []).
  • default está sempre contido em scopes (validado no servidor).
  • Uma entrada pode trazer fields: os campos opcionais que esta sessão pode pedir àquele domínio via ?include= (hoje só userDirectory oferece, com email). Ausente quando não há nenhum — nunca [] — pelo mesmo motivo do domínio ausente. O que cada campo significa é contrato do domínio; o que fields diz é 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 ​

ValorSignifica
tenanto tenant do token
groupo grupo do tenant do token (ADR 0006) — nunca plataforma
globalplataforma inteira: o trilho /admin/*
nonesem 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 ​

defaultSignificaTrilho da UI
globaladministra users de todos os tenantstrilho admin: /api/v1/admin/users…
tenantadministra users apenas no tenant do tokentrilho escopado: /api/v1/users…
(ausente)não administra usersesconder 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 ​

defaultSignificaTrilho da UI
globaladministra tenants de toda a plataformatrilho admin: /api/v1/admin/tenants…
tenantvê apenas os tenants do próprio escopotrilho 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.
  • users ausente 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/users respondia 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:users para GET /users, read:tenants para GET /tenants, e assim por diante. Ex.: um caller só com create:users tem users.default == tenant, mas GET /users responde 403 — ele cria, não lista. Um manage:all-users "puro" tem users.default == global, entra em /admin/users, e o /users segue 403. Garante — alcance de grupo. Desde o diretório de usuários (a primeira rota group-capable):

  • scopes contém group ⟺ ?scope=group daquele 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 anunciar group. Uma permissão read:group-data concedida dentro de uma filial não anuncia group, porque o resolver não a honraria; concedida no tenant do grupo anuncia, porque ele vai honrar. Tenant fora de hierarquia nunca anuncia group (é 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: group anunciado 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.

Released under the MIT License.