Pular para conteúdo

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_access que já referenciam aquele role — 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 sem role é 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 POST dos 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 @Scheduled chamando o mesmo serviço.

Consequências

  • Papel novo no app vira: publicar o roles-catalog.json no deploy do app + um POST .../roles/sync. O runbook semear o catálogo passa a ter o sync como caminho preferido e o POST unitá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_unreachable quando o app não responde, 422 quando o base_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).