Pular para conteúdo

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ó ATIVO emite 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 claim role do 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: apps do app catalogado + app_roles semeado antes do 1º approve.