0025 — Catálogo de papéis declarado pelo app, ingerido pelo central¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-04 · Decidido em: 2026-09-04
Contexto¶
O role de um usuário é o eixo de autorização do ecossistema: vai verbatim no claim role do JWT e
é o que a sync rule do PowerSync consome para filtrar dado. Os valores aceitos por app vivem em
app_roles (V12) e alimentam o picker de quem aprova um acesso — o
central recusa atribuir papel fora do catálogo (422 role_not_in_catalog, decisão
0024).
Até aqui esse catálogo era curado à mão, via POST /api/admin/apps/{app}/roles (runbook
semear o catálogo). Quem sabe quais papéis existem é o
app — é o código dele que faz o gate por papel. O central sabia por cópia manual, e cópia manual
drifta: no e2e-staging o bi-comercial já gateava DIRETORIA e o papel não aparecia no picker do
central, então ninguém conseguia conceder um acesso que o app já sabia respeitar. O sintoma é
silencioso — não há erro, só uma opção que falta numa tela.
Decisão¶
O app declara; o central ingere. O app publica os papéis que aceita num arquivo estático:
GET <base>/roles-catalog.json
{"app_id": "bi-comercial",
"roles": [{"role": "ADMIN", "label": "Administrador"},
{"role": "DIRETORIA", "label": "Diretoria"}]}
e o central puxa e faz upsert em app_roles por
POST /api/admin/apps/{appId}/roles/sync (corpo opcional {"base_url": "..."}, que sobrepõe a
apps.production_url para ambientes onde ela não é alcançável — e2e local).
Três propriedades decidem o desenho:
- Upsert, nunca delete. Papel que sumiu da declaração fica no catálogo. Apagá-lo deixaria
órfãos os
app_user_accessque já referenciam aquelerole— o grant é escopado a(cliente, app, subject, role). Remover é ação manual e deliberada (DELETE /api/admin/apps/{app}/roles/{role}), depois de tratar os grants. roleé machine-code, normalizado para MAIÚSCULA na ingestão;labelé só o rótulo humano do picker e pode mudar sem consequência. Entrada semroleé ignorada.- Pull, não push. O central puxa quando mandam sincronizar; o app não precisa de credencial nem saber que o central existe. O arquivo é público — nome de papel não é segredo.
O endpoint é admin-gated (AdminAuthFilter: JWT xadm_admin ou o shared secret do integrador).
Como o base_url é entrada do chamador, o cliente HTTP recusa esquema fora de http(s) e URL sem
host — sem isso o sync seria um primitivo de chamada de saída arbitrária a partir do servidor.
Alternativas consideradas¶
- Manter a curadoria manual. Rejeitada: é exatamente o drift que motivou a decisão, e ele é silencioso (nada falha; uma opção só não aparece).
- O app faz
POSTdos papéis no central (push). Rejeitada: exigiria dar credencial de escrita a cada app e um passo de deploy em cada app. O pull mantém o app burro sobre o central. - Deduzir os papéis dos grants existentes. Rejeitada: inverteria a fonte da verdade — o catálogo passaria a refletir o que já foi concedido, e um papel novo nunca poderia ser concedido a ninguém.
- Sincronizar automaticamente (agendado). Deferido: o gatilho manual basta enquanto papel novo é
evento de release. Vale reavaliar se a frota crescer — o custo seria um
@Scheduledchamando o mesmo serviço.
Consequências¶
- Papel novo no app vira: publicar o
roles-catalog.jsonno deploy do app + umPOST .../roles/sync. O runbook semear o catálogo passa a ter o sync como caminho preferido e oPOSTunitário como saída manual. - O central ganha uma dependência de saída para os apps (era só o inverso). A falha é isolada e
explícita:
502 roles_catalog_unreachablequando o app não responde,422quando obase_urlé inválido ou o app declara um papel maior que a coluna. - O arquivo vira contrato de plataforma: um app da casa que participa do modelo de papéis deve servi-lo. Candidato a virar norma no repo central (§6).