Pular para conteúdo

0029 — Catálogo de papéis: o app declara, o central ingere

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-04 · Decidido em: 2026-09-04

Contexto

Quem concede acesso é o central-backend (grant por (cliente, app, subject, role) — Central de Apps), mas quem sabe quais papéis existem é o app: o @Secured("ROLE_…") do endpoint, o gate de UI e a sync rule vivem no repo dele. Até aqui o catálogo de papéis do central era curadoria manual — alguém digitava os papéis de cada app no painel. Consequência observada em staging: papel novo no app não aparecia no picker do central, papel morto continuava ofertável, e ninguém era avisado — drift silencioso entre o que o app cobra e o que o central sabe conceder.

A casa já tem a família "o receptor dita o contrato, o consumidor consome" — 0012 (rota checada contra o OpenAPI do dono) e 0020 (corpo tipado ditado pelo receptor). Papel é o mesmo formato de problema, com os papéis invertidos: o app é o dono do vocabulário, o central é o consumidor.

Decisão

Todo app com login de usuário DECLARA seus papéis num manifesto que ele próprio serve; o central-backend INGERE por upsert.

  • Contrato: GET <base>/roles-catalog.json → {"app_id": "<id>", "roles": [{"role": "…", "label": "…"}]}. base = o production_url do app no catálogo do central (o mesmo campo que o /provision já exige — Central de Apps).
  • Como servir, por stack: Flutter web (sem backend) = JSON estático em web/, servido pelo nginx do próprio app (zero código). Micronaut = @Controller público (@Secured(SecurityRule.IS_ANONYMOUS), rota exata — seguranca §Authn) lendo a config.
  • Ingest = upsert, nunca delete. Papel novo entra; label mudado atualiza; papel que sumiu do catálogo NÃO é apagado — vira inativo (deixa de ser ofertável no picker, os grants existentes seguem valendo) e o resultado do sync lista o que inativou. Delete apagaria grant de usuário; sumiço mudo recriaria o drift que esta decisão existe para matar.
  • Escopo da chave: catálogo por app_id; grant por (cliente, app, subject, role). O cliente_id só entra no catálogo se um app compartilhado (uma instância, N clientes) precisar de papel custom por cliente — hoje não há caso.
  • Gatilho: hoje manual (POST /api/admin/apps/{app}/roles/sync); o alvo é o control-plane de deploy (0026) — o mesmo evento que deploya o app reconcilia o catálogo dele.

Guardrails do ingest (o catálogo é público e vem de fora)

  • O catálogo é conteúdo PÚBLICO (JSON no nginx ou rota anônima): carrega só role e label. Nada de regra de negócio, nome de cliente, descrição de escopo ou qualquer coisa que não se queira no ar sem autenticação.
  • O app_id do corpo tem de casar o app alvo do sync — senão um app declara papel de outro. Divergência = recusa, não merge.
  • Fetch defensivo: só https, host exatamente o production_url registrado (o central segue um ponteiro do próprio catálogo — não aceite redirect para outro host), Content-Type JSON, timeout curto e limite de tamanho. Sem isso o ingest vira um buscador de URL arbitrária rodando dentro da rede do central.
  • App sem catálogo = zero papel ofertável (fail-closed), com o erro visível no sync — não um catálogo vazio silencioso.

Alternativas descartadas

  • Curadoria manual no painel (o estado anterior): é a fonte do drift.
  • Campo no app.json de docs: mistura autorização em manifesto de documentação/setup, e o app.json é build-time — papéis evoluem sem rebuild.
  • Campo no /provision: amarraria papel a re-provisionar o app toda vez que a lista mudasse.

Consequências

  • O app passa a ter uma obrigação de contrato: publicar e manter o roles-catalog.json. Papel novo só é concedível depois do sync.
  • O central deixa de inventar vocabulário: o picker mostra o que o app cobra.
  • O gate por papel só é fail-closed se nenhuma query escapar. Do lado do dado (sync rule do PowerSync), query nova sem o filtro de papel não quebra — entrega o dado a quem não tem o papel, em silêncio. Por isso a obrigação é estática e verificável: toda query do stream gateado cita o parâmetro de papel, cobrado no lint do repo da stack (powersync §Config).
  • O dono trava o artefato por teste. O catálogo é um artefato de deploy: o teste lê o arquivo ou a rota que vai ao ar (não uma cópia em test/) e afirma o conjunto de role que a UI exige — nunca a redação do label, que é editorial e mudaria o contrato do consumidor a cada correção de palavra na tela (receita Flutter em flutter §Testes).
  • Pendência declarada (deferida, REGRA Nº 3): papel que restringe escopo (ex. um segmento de produto) segue espelhado em três lados — sync rule do PowerSync (dado), gate de UI (navegação) e catálogo (concessão) —, cada um escrito à mão no seu repo (a forma que o PowerSync aceita no filtro por papel — igualdade encadeada, não auth.parameter(…) IN (lista) — está em powersync §Config). A extensão natural é declarar papel→escopo uma vez e derivar os três; não entra agora (precisa do caso real de um segundo app para não normatizar no escuro, §1.2).