0023 — Login social: modelo status×role e auto-governança por app¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-02 · Decidido em: 2026-09-02
Status: aceita · Data: 2026-09-02 · Contexto: habilitar Sign-in-with-Google/Apple
(Firebase) em apps PowerSync da casa (1º consumidor: bi-comercial/Onpetro), com acesso pendente,
papel por app e gestão do acesso pela própria empresa. Linhagem .ia/015-* (prompt→spec→plan).
Decisão¶
1. Dois eixos: status (lifecycle) × role (papel), não uma coluna só¶
oauth_access_requests.status deixou de misturar acesso e papel. Agora:
status∈{PENDENTE, REJEITADO, ATIVO, INATIVO}— sóATIVOemite JWT; os outros 3 → 403 + tela de espera.INATIVO= soft-revoke (foi ATIVO, perdeu acesso).role(coluna nova, nullable) — papel data-driven por app, emitido verbatim no claimroledo JWT. É o valor que a sync rule do PowerSync consome para filtrar o bucket por papel — a segurança de dado mora aí, não na UI.
Migração V11 (prod tinha 0 linhas → schema-puro, risco zero): mapeou o modelo antigo
ADMIN→ATIVO+role=ADMIN, USER→ATIVO+role=DIRETORIA, SPAM→REJEITADO, PENDENTE→PENDENTE.
Diferença ADMIN×DIRETORIA: ambos veem tudo; só ADMIN administra usuários.
Preterido: reanimar a tabela ociosa oauth_access_grants como "acesso ativo". Recusado — o par
status+role numa tabela (com INATIVO=revogação) expressa os dois eixos sem 2ª tabela e sem 2
queries no login. oauth_access_grants foi dropada (V11); classes Java e teste saíram no mesmo PR.
2. Catálogo de papéis = tabela app_roles, semeada como dado (não migration)¶
app_roles(app_id→apps, role, label) (V12, criada vazia). Cresce sem deploy via CRUD admin
(/api/admin/apps/{app}/roles). role uppercase (machine-code). O approve valida contra o catálogo
(422 role_not_in_catalog).
Preterido: seed na migration (colaria metadado de app numa migration global; risco de FK) e string livre (typo → bucket errado silencioso). Migration é schema, não dado — o catálogo de cada app é semeado por runbook/CRUD antes do 1º approve (runbook).
3. @xadm no client-login: super-admin implícito, sem gravar¶
@xadm que faz client-login recebe token ATIVO+role=ADMIN (aud=cliente, claims oauth),
sem criar linha na fila. O firebaseAdminLogin (aud=xadm, para o central-ui) continua separado —
o token dele é inútil para o app (aud errado), por isso o client-login precisa do seu próprio caminho
@xadm. (Antes o client-login rejeitava @xadm.)
4. Auto-governança do tenant: superfície /api/apps/** (não uma nova /api/app/**)¶
O usuário role=ADMIN de um app gere os próprios usuários (listar pAbast∪oauth, aprovar, conceder
papel, ativar/inativar). Reusa o @ServerFilter("/api/apps/**") + AppUserAuthFilter existentes
(já validam o JWT de app e rejeitam token sem oauth_app_id — pAbast/anônimo/xadm → 403); o gate
role=ADMIN é por-endpoint (o mesmo prefixo serve os corretores, que são qualquer app-user).
Escopo sempre pelo claim (oauth_cliente_id, oauth_app_id) — nunca body/path (tenancy pelo claim).
- Escalonamento total do tenant: o ADMIN concede qualquer papel do catálogo, inclusive
ADMIN/DIRETORIA— modelo SaaS org-admin; a empresa cria o 2º admin sem depender de xadm. xadm é meta-admin acima (via/api/admin/**). - Guard de auto-lockout: recusa (
409 last_admin) a operação que zeraria os ADMIN ativos do par.
Preterido: criar /api/app/** novo (duplicaria o prefixo /api/apps/** da casa) e pôr role=ADMIN
no filtro global (quebraria os corretores app-user). Decisão do pivô confirmada com o dev na
implementação.
5. Notificação de novo pendente via Resend (fail-open)¶
Ao criar um PENDENTE novo (1ª tentativa social num app; UNIQUE garante 1×), o backend
notifica os aprovadores por email (Resend, padrão da casa — RESEND_API_KEY/RESEND_REMETENTE).
Destinatários = env AUTH_OAUTH_NOTIFY_EMAILS ∪ ADMINs ativos do (cliente, app). Fail-open: sem
chave é no-op; falha de envio nunca falha o login (computação de destinatários síncrona, envio HTTP
assíncrono com erro capturado/logado). Cliente HTTP = java.net.http.HttpClient (molde da casa; não
OkHttp nem @Client — evita metadata no native-image, decisão 0018).
Consequências¶
- O JWT social carrega
oauth_cliente_id,oauth_app_id,role(verbatim) — contrato consumido por apps + sync rules. Ver api-rest e modelagem. - Enforcement de papel diverge por camada de dados: PowerSync (bi-comercial) usa sync rule pela
claim
role; um app Firestore (ex.: precos, futuro) precisaria do role como custom claim do Firebase — fora de escopo agora. - Precondição operacional:
appsdo app catalogado +app_rolessemeado antes do 1º approve.