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= oproduction_urldo app no catálogo do central (o mesmo campo que o/provisionjá 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 =@Controllerpúblico (@Secured(SecurityRule.IS_ANONYMOUS), rota exata — seguranca §Authn) lendo a config. - Ingest = upsert, nunca delete. Papel novo entra;
labelmudado 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). Ocliente_idsó 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ó
roleelabel. 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_iddo 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 oproduction_urlregistrado (o central segue um ponteiro do próprio catálogo — não aceite redirect para outro host),Content-TypeJSON, 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.jsonde docs: mistura autorização em manifesto de documentação/setup, e oapp.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 deroleque a UI exige — nunca a redação dolabel, 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).