Pular para conteúdo

Documentação Completa — Central de Aplicações e Autenticação (backend)

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15

O sistema como ele é hoje, em ordem de raciocínio: o problema, o que entra, como os dados são guardados, como a autenticação funciona e, no fim, os contratos que outros sistemas consomem. Quem só integra pode pular direto para o cap. 7. O histórico ("como chegamos aqui") está no apêndice; o porquê de cada escolha, nas Decisões.

1. Contexto e problema

Os apps Flutter e as telas web do ecossistema xadm.biz precisam saber quem é o usuário e o que ele pode ver antes de sincronizar dados. Cada app não deve reimplementar validação de senha nem guardar chaves — essa responsabilidade vivia embutida em cada instância do integrador, com um par de chaves RSA e um JWKS por instância, exigindo coordenação a cada cliente novo.

A Central de Aplicações e Autenticação centraliza isso num único serviço (auth.xadm.biz, Java/Micronaut): valida credenciais, emite um JWT RS256 assinado e publica a chave pública (JWKS) que o PowerSync usa para conferir os tokens. Um único endpoint de validação por ambiente, sem coordenar chaves entre instâncias.

São três formas de autenticar, todas terminando num JWT assinado pela mesma chave:

  • pAbast — usuário do ERP, com senha de 4 dígitos + complementar de 3 (scope=full);
  • Firebase (equipe xadm) — login Google @xadm.com.br → JWT com xadm_admin=true (scope=admin), que abre a área administrativa;
  • Firebase (usuário de app terceiro) — login Google de quem não é xadm, sujeito a aprovação da equipe (scope=full com papel USER/ADMIN).

Fora do escopo: validar JWT (o serviço emite; o PowerSync valida), gerir cadastro de usuários do ERP (a fonte é o X-Adm) e sincronizar dados de BI (responsabilidade do PowerSync + integrador).

2. Dados de entrada

O serviço não ingere arquivos — recebe requisições HTTP/JSON e lê o banco db_auth. As entradas que importam:

  • Login pAbast (POST /api/auth/pabast/login): cliente, id_usuario, senha (4 dígitos), senha_compl (3 dígitos). A senha é conferida contra o hash em pabast_senhas pelo algoritmo pAbast — o serviço nunca vê senha de usuário em texto claro persistido.
  • Login Firebase (/api/auth/firebase/login e /client-login): um Firebase ID Token no header Authorization: Bearer (rejeitado se > 8 KB), validado pelo JWKS público do Google. O client-login exige ainda cliente e app_id no corpo.
  • Refresh (POST /api/auth/refresh): só o JWT de app-user vigente no Authorization: Bearer, sem corpo — cliente e app saem do próprio token.
  • Token anônimo (POST /api/auth/anon?cliente=X): só o cliente. Depreciado (ver cap. 5).
  • Admin (/api/admin/**): um JWT próprio com xadm_admin=true no header, validado pelo AdminAuthFilter.
  • Heartbeat do integrador-client (POST /api/integrador/heartbeat): o sinal de vida de cada instalação on-premise do integrador-client — cliente_id, versão, fluxos, hostname —, repassado pelo integrador-server do cliente com o Bearer CENTRAL_API_TOKEN (cap. 5).

3. Modelo de dados

O schema abaixo é a fonte da verdade — toda migration que evolui o banco atualiza este modelo no mesmo PR.

O schema vive no PostgreSQL db_auth — banco de propriedade exclusiva do central-backend —, versionado por Flyway (V1..V16; migration aplicada é congelada, nunca editada — sempre uma nova). O PostgreSQL é 18+: as PKs surrogate usam uuidv7() (time-sortable) nativo.

Tabelas de acesso/identidade: o catálogo de clientes, os hashes de senha do pAbast em pabast_senhas, o acesso unificado do usuário ao app app_user_access (pАbast ∪ oauth, carrega status + role) e o catálogo de papéis por app app_roles. (A oauth_access_grants foi dropada na V11; a oauth_access_requests virou app_user_access na V13 — ver abaixo.) O catálogo de apps (apps, app_provider_resources) é descrito na seção da Central de Apps, e as instalações do integrador-client (integrador_instancias), na seção do heartbeat.

Relacionamentos (overview)

erDiagram
    clientes ||--o{ app_user_access : "cliente_id (FK)"
    clientes ||--o| integrador_instancias : "cliente_id (FK)"
    apps ||--o{ app_roles : "app_id (FK)"
    clientes {
        uuid id PK
        varchar cliente_id UK
    }
    pabast_senhas {
        varchar id "ChaveUsu ERP — sem PK"
        varchar cliente_id
    }
    app_user_access {
        uuid id PK
        varchar subject_type "oauth|pabast"
        varchar subject_key "firebase_uid | id ChaveUsu"
        varchar status "PENDENTE|REJEITADO|ATIVO|INATIVO"
        varchar role "catálogo por app (nullable)"
    }
    app_roles {
        varchar app_id FK
        varchar role
    }
    integrador_instancias {
        varchar cliente_id PK
        timestamptz ultimo_heartbeat_em
        timestamptz alertado_em "não nulo = silêncio aberto"
    }

pabast_senhas não tem FK para clientes. A tabela foi recriada no layout do ERP (V3) como tabela independente; o vínculo com o cliente é lógico (coluna cliente_id), e a unicidade do par (cliente_id, id) é mantida em software (decisão 0008), não por constraint.

Dicionário por tabela

clientes — catálogo de clientes do central-backend

Coluna Tipo Chave Nulo? Desde Nota
id uuid PK NOT NULL V1 uuidv7()
cliente_id varchar(50) UK NOT NULL V1 ID de negócio (vantroba, onpetro); vai no sub/URLs
audience varchar(100) NOT NULL V1 aud do JWT; deve casar com o client_auth.audience do PowerSync
active boolean NOT NULL V1 false → login e token anônimo retornam 403
created_at timestamptz NOT NULL V1 NOW()
auth_methods text[] NOT NULL V2 métodos habilitados; default {pabast}; oauth_firebase libera o client-login
  • Removido na V2: db_host, db_port, db_name, db_user, db_pass — a centralização de pabast_senhas (decisão 0003) tornou a conexão por cliente desnecessária. Com ela saiu o uso do AES-256-GCM (db_pass cifrada).

pabast_senhas — hashes de senha pAbast, no layout do ERP · sem PRIMARY KEY

Coluna Tipo Chave Nulo? Desde Nota
id varchar(7) — V3 ChaveUsu do ERP; era PK, dropada na V6
cliente_id varchar(50) NOT NULL V3 default 'vantroba'; parte da chave natural lógica
id_empresa varchar(4) V3
id_usuario varchar(3) V3 usado no login (findByClienteIdAndIdUsuario)
nome_usuario varchar(70) V3
senha varchar(5) V3 hash pAbast (nunca texto claro)
senha_compl varchar(5) V3 hash complementar pAbast
status varchar(1) V3
data_hora varchar(14) V3 AAAAMMDDHHMMSS do ERP (sem conversão)
  • Chave natural lógica (cliente_id, id) — garantida em software (AdminController + PabastSenhaRepository sempre olham o par). Sem constraint no banco desde a V6 (a mesma ChaveUsu aparece em clientes diferentes). Ver decisão 0008.

app_user_access — acesso do usuário ao app (pAbast ∪ oauth, unificado)

Evolui a antiga oauth_access_requests (V13, 016): uma tabela só para o acesso de qualquer usuário — social (oauth) ou staff ERP (pАbast) — carregando status (lifecycle) + role.

Coluna Tipo Chave Nulo? Desde Nota
id uuid PK NOT NULL V13 uuidv7()
cliente_id varchar(50) FK NOT NULL V13 → clientes(cliente_id)
app_id varchar(100) NOT NULL V13 app do acesso
subject_type varchar(16) UK NOT NULL V13 CHECK oauth\|pabast
subject_key varchar(128) UK NOT NULL V13 firebase_uid (oauth) | id/ChaveUsu (pabast, [I1] do 016)
email varchar(320) V13 nullable: pАbast não tem email
nome varchar(255) V13 exibição
status varchar(32) NOT NULL V13 lifecycle; default PENDENTE
role varchar(50) V13 papel (catálogo app_roles); NULL exceto quando ATIVO
created_at/updated_at timestamptz NOT NULL V13 NOW()
  • UK (subject_type, subject_key, cliente_id, app_id) — um acesso por usuário/cliente/app.
  • CHECK status IN ('PENDENTE','REJEITADO','ATIVO','INATIVO'). Só ATIVO emite JWT (claim role verbatim, consumido pela sync rule do PowerSync); os outros 3 → sem acesso. INATIVO = soft-revoke.
  • oauth: row criada PENDENTE no 1º client-login; approve → ATIVO+role. pАbast: row criada só na atribuição de papel (login não cria); sem row = sem papel = fail-closed (token pАbast sem claim role, mas login 200). email NULL para pАbast.
  • Migração V13: as linhas de oauth_access_requests viraram subject_type='oauth', subject_key=firebase_uid; oauth_access_requests foi dropada (prod tinha 0 linhas).
  • subject_key é sempre trimado. pabast_senhas.id vem do ERP com espaço na borda (1 0999) e o login compara com id.trim(); grant gravado com o id cru dava match_login=0 — o usuário via "não foi liberado" mesmo com papel atribuído. Os controllers trimam na escrita, e a V14 é um data-fix que normaliza o que já estava gravado (resolvendo antes eventual colisão de UK, pela linha de updated_at maior). Não há CHECK impondo isso: a constraint quebraria o app da versão anterior num rollback por redeploy.
  • Índice em status.

oauth_access_grants foi DROPADA na V11 (ledger ocioso); oauth_access_requests foi renomeada/evoluída para app_user_access na V13 (unificação pАbast+oauth, 016). O contrato admin oauth preserva a chave JSON firebase_uid (= subject_key das linhas oauth) para o central-ui.

oauth_access_grants foi DROPADA na V11. Era um ledger ocioso (o client-login sempre leu o papel de oauth_access_requests, nunca dos grants). O par status + role em oauth_access_requests — com INATIVO como soft-revoke — a substitui. As classes Java (OauthAccessGrant/repo) e o teste correspondente saíram no mesmo PR.

app_roles — catálogo de papéis por app (data-driven) · PK (app_id, role)

Papéis que um app oferece (ex.: ADMIN, DIRETORIA, COMERCIAL, LUBRIFICANTE). Cresce sem deploy via CRUD admin (POST /api/admin/apps/{app}/roles e DELETE /api/admin/apps/{app}/roles/{role}) ou, preferencialmente, pelo POST /api/admin/apps/{app}/roles/sync, que ingere o roles-catalog.json declarado pelo próprio app (upsert, nunca deleta — decisão 0025). Schema criado vazio na V12 — o catálogo de cada app é populado por dado, antes do 1º approve daquele app.

Coluna Tipo Chave Nulo? Desde Nota
app_id varchar(100) PK, FK NOT NULL V12 → apps(app_id)
role varchar(50) PK NOT NULL V12 machine-code do papel (uppercase); vai no claim role do JWT
label varchar(100) V12 rótulo humano para o picker (central-ui / tela do app)

apps — catálogo da Central de Apps (build-time) · PK natural app_id

Populado pelo broker (POST /api/setup/provision) a partir do app.json do app; fonte do dashboard da central. Ver decisão 0010.

Coluna Tipo Chave Nulo? Desde Nota
app_id varchar(100) PK NOT NULL V7 kebab; namespace único com os grants
nome varchar(200) NOT NULL V7
descricao varchar(500) V7
grupo varchar(20) NOT NULL V7 CHECK xadm\|cliente
cliente_id varchar(50) FK V7 → clientes; NULL p/ xadm
slug varchar(100) V7 link do portal de docs
repo_url varchar(300) V7 link do source (Forgejo)
production_url varchar(300) V7
stack varchar(100) V7
toolchain_json jsonb V7 ex. {"java":"25"}
active boolean NOT NULL V7 default true
created_at/updated_at timestamptz NOT NULL V7

app_provider_resources — recurso por (app, provider) · estado + refs

Coluna Tipo Chave Nulo? Desde Nota
id uuid PK NOT NULL V8 uuidv7()
app_id varchar(100) FK NOT NULL V8 → apps(app_id)
provider varchar(20) NOT NULL V8 CHECK glitchtip\|garage\|aptabase\|metabase ¹
state varchar(20) NOT NULL V8 CHECK desligada\|provisionando\|ligada\|falha
resource_ref jsonb V8 referência no provedor
client_config jsonb V8 client-grade (DSN/key/bucket)
secret_ref jsonb V8 {env_name, valor_cifrado, coolify_resource_ref}
last_error text V8
created_at/updated_at timestamptz NOT NULL V8
  • UNIQUE (app_id, provider); índice em app_id. Recursos de build são por app (não por par); os "pares (cliente, app)" derivam dos grants.
  • ¹ metabase está reservado no CHECK (V8, congelada) mas foi descopado da feature analytics — é BI independente, o app não o consome (decisão 0013). Providers vivos: glitchtip, garage, aptabase.

Re-ancoragem (V9): oauth_access_grants.app_id ganhou FK → apps(app_id) — os acessos de terceiros passavam a exigir o app catalogado. oauth_access_requests não recebe FK (o firebase client-login cria pedidos para apps ainda não catalogados). O backfill V9 cataloga um apps por app_id distinto dos acessos (grupo='cliente', curadoria posterior). Nota (V11): com o drop de oauth_access_grants, essa FK saiu junto; o catálogo app_roles (V12) é que agora referencia apps(app_id).

Control-plane de deploy

Tabelas do control-plane de deploy M2M (decisão 0021, 0022, 0026). Não têm FK para apps — a âncora é o recurso Coolify real (slug de imagem ou de git repo), que pode divergir do app_id do catálogo; a referência é mole, por metadado, de propósito (imune a rebrand do catálogo).

app_deploy_targets — mapa reconciliado app → recurso Coolify

Coluna Tipo Chave Nulo? Desde Nota
id uuid PK NOT NULL V10 uuidv7()
registry_slug varchar(100) UK¹ NOT NULL V10 último segmento do docker_registry_image_name (recurso por imagem) ou do git repo, sem .git (recurso compose — V15)
target varchar(20) UK¹ NOT NULL V10 CHECK jar\|native\|web (V10) + compose (V15). Prefixo da tag <target>-amd64; compose é fixo para build-from-git
instance varchar(100) UK¹ V10 do FQDN int.<cli>.xadm.biz ou int-jar.<cli>.xadm.biz (o jar da troca, mesma instância) → <cli>; NULL p/ app 1:1 e canary
coolify_uuid varchar(100) NOT NULL V10 alvo do POST /deploy?uuid=
fqdn varchar(300) V10 primeiro FQDN do recurso (auditoria/diagnóstico)
created_at/updated_at timestamptz NOT NULL V10
  • ¹ UNIQUE de expressão uq_app_deploy_targets (registry_slug, target, COALESCE(instance,'')) — trata instance NULL como '' para virar alvo do ON CONFLICT do upsert do reconcile. Uma linha lógica = um recurso deployável.
  • A tabela é derivada, nunca escrita à mão: o DeployReconcileService a recompõe a partir de GET /applications do Coolify (on-boot, on-demand e on-miss com debounce). Um coolify_uuid novo por cutover blue-green re-ancora a mesma linha; um slug novo cria linha nova. Perder a tabela é inócuo — o próximo reconcile a reconstrói.

deploy_audit — uma linha por recurso disparado

Coluna Tipo Chave Nulo? Desde Nota
id uuid PK NOT NULL V10 uuidv7()
token_ref varchar(100) V10 referência não-reversível ao token (sha256:<12 hex>) — nunca o token cru
registry_slug varchar(100) V10 âncora resolvida
target varchar(20) V10 sem CHECK (auditoria registra o que veio, inclusive valor recusado)
instance varchar(100) V10
coolify_uuid varchar(100) V10 recurso disparado
deployment_uuid varchar(100) V10 devolvido pelo Coolify; NULL se o disparo falhou
resultado varchar(20) V10 CHECK ok\|parcial\|erro
criado_em timestamptz NOT NULL V10
  • Índice idx_deploy_audit_slug_target (registry_slug, target). Append-only — nada apaga.

Heartbeat do integrador-client

Estado atual de cada instalação do integrador-client (jar on-premise), alimentado pelo heartbeat que o integrador-server do cliente repassa (decisão 0028). Sem histórico: só o último heartbeat interessa para "está vivo?".

integrador_instancias — uma instalação por cliente · PK cliente_id

Coluna Tipo Chave Nulo? Desde Nota
cliente_id varchar(50) PK, FK NOT NULL V16 → clientes(cliente_id), sem ON DELETE CASCADE (a exclusão de cliente é lógica)
versao varchar(40) NOT NULL V16 versão do jar no último heartbeat
fluxos text[] NOT NULL V16 zim.fluxos ativos; default {}
hostname varchar(255) V16 máquina do client; NULL quando ele não resolve
motivo varchar(20) V16 inicio|periodico — validado na API, sem CHECK
iniciado_em timestamptz V16 início do processo do client; o offset original não é guardado
primeiro_heartbeat_em timestamptz NOT NULL V16 1º heartbeat — o upsert nunca o toca
ultimo_heartbeat_em timestamptz NOT NULL V16 relógio do central, nunca o do client
alertado_em timestamptz V16 não nulo = episódio de silêncio aberto (o job alertou); o próximo heartbeat zera
  • Carimbos sem DEFAULT now(): quem carimba é a aplicação, com o Clock injetado — um relógio só para gravar, reivindicar e contar horas úteis.
  • Escrita por upsert (INSERT … ON CONFLICT (cliente_id) DO UPDATE), sem transação: a linha nasce no 1º heartbeat (registro automático) e cada heartbeat atualiza tudo menos primeiro_heartbeat_em, zerando alertado_em.
  • Claim do job de silêncio: UPDATE … SET alertado_em = :agora WHERE alertado_em IS NULL AND ultimo_heartbeat_em = :visto — só uma réplica vira a linha, e um heartbeat que chegou depois da leitura faz o claim falhar.
  • Remoção só pelo DELETE /api/admin/integradores/{clienteId} ("parar de acompanhar"); se o jar ainda roda, o próximo heartbeat recria a linha.

4. Mapeamento entrada↔dados

Entrada Lê / escreve Resultado
Login pAbast lê clientes (audience, active) + pabast_senhas (hash) JWT scope=full
Token anônimo lê clientes (audience, active) JWT scope=pabast_only (depreciado)
Firebase xadm valida token no Google; não lê banco JWT scope=admin, xadm_admin=true
Firebase client-login lê clientes (auth_methods, active); lê/grava oauth_access_requests JWT scope=full se aprovado; senão cria pedido + 403
Refresh valida o JWT; lê clientes + pabast_senhas (pAbast) + app_user_access JWT novo com status e papel atuais; 403 se revogado ou além do teto de sessão
Admin OAuth lê/atualiza oauth_access_requests.status aprova/rejeita/altera papel
Heartbeat do integrador-client lê clientes; upsert em integrador_instancias 204; o 1º heartbeat registra a instalação
Job de silêncio (a cada 15 min) lê integrador_instancias ⋈ clientes; marca alertado_em 1 evento no GlitchTip por episódio
Admin integradores lê integrador_instancias ⋈ clientes; DELETE apaga a linha lista com situacao / parar de acompanhar

O audience de cada cliente entra no claim aud do JWT e deve casar com o client_auth.audience do PowerSync daquele cliente (cap. 6).

5. Fluxos e processamento

Componentes

  • AuthController — emissão de tokens e JWKS. CORS em todos os endpoints, preflight OPTIONS nos POST, rate limiting por IP.
  • JwtService — carrega o par RSA no @PostConstruct; emite os JWT (issueToken, issueAnonToken, issueXadmAdminToken, issueOauthFirebaseUserToken) e expõe o JWKS com alg=RS256.
  • JwtValidator + AdminAuthFilter (@ServerFilter({"/api/admin/**", "/test/glitchtip"})) — validam o JWT interno e barram com 401/403 se xadm_admin for falso (decisão 0032).
  • Controllers finos, regra nos services da feature (ClienteAdminService, UsuariosDoAppService, IntegradorInstanciaService…): controller não injeta repository, e o ArchitectureTest trava essa fronteira — detalhe no guia do código.
  • FirebaseTokenValidator — valida Firebase ID Tokens pelo JWKS do Google; é lazy (só existe se AUTH_FIREBASE_PROJECT_ID estiver configurado).
  • PabastPasswordHasher — porta literal do algoritmo do integrador/Flutter.
  • RateLimiter — janela fixa de 60 s por IP.
  • AdminController + OauthFirebaseAdminController — CRUD de clientes e usuários pAbast, e a fila de aprovação OAuth.
  • AppSelfServiceController (/api/apps/**) — auto-governança: o ADMIN do cliente aprova acesso e atribui papel dentro do seu app.
  • CorsOrigins + AdminApiCorsFilter/AppApiCorsFilter — as duas superfícies de browser. A allowlist aceita https://*.xadm.biz (todo app cliente no domínio da casa) além da lista base e da env de ambiente; os filtros correm em HIGHEST_PRECEDENCE para o preflight OPTIONS, que não leva Authorization, não tomar 401 do filtro de auth.
  • RolesCatalogClient + RolesCatalogSyncService — ingerem o catálogo de papéis que o app declara (upsert, nunca deleta).
  • HeartbeatController + IntegradorAuthFilter (@ServerFilter("/api/integrador/**"), token CENTRAL_API_TOKEN) — recebem o heartbeat do integrador-client; SilencioJob alerta o silêncio; IntegradoresAdminController lista as instalações para o central-ui.

Heartbeat do integrador-client e alerta de silêncio

O integrador-client roda on-premise, um por cliente, e não tinha como avisar que parou — o sintoma chegava dias depois como "a integração parou". Agora cada instalação manda um heartbeat no startup e a cada 60 min ao integrador-server do próprio cliente, que carimba o cliente_id e o repassa ao central (decisão 0028). O central faz upsert de uma linha por cliente em integrador_instancias: o 1º heartbeat registra a instalação sozinho, sem cadastro além do cliente existir em clientes.

O SilencioJob roda a cada 15 min (o primeiro tick só 20 min depois do boot, fora da janela do smoke pós-deploy) e conta as horas úteis — 07:00–22:00, seg–sex, sem feriado nacional, em America/Sao_Paulo — desde o último heartbeat de cada instalação sem episódio aberto e de cliente ativo. Passou de 6h úteis, ele reivindica o episódio com um UPDATE condicional (só vira se ninguém alertou e nenhum heartbeat chegou depois da leitura — com duas réplicas, uma só alerta) e manda um evento ao GlitchTip do central, com fingerprint por cliente. O próximo heartbeat encerra o episódio. Cliente desativado no central não alerta, mas a instalação segue aceita e listada como cliente_inativo.

Um relógio só — o Clock injetado — carimba o heartbeat, reivindica o episódio e mede as horas.

Login pAbast (fluxo principal)

O app valida o formato localmente, chama POST /api/auth/pabast/login, e o serviço: confere cliente ativo em clientes → busca o usuário por (cliente_id, id_usuario) em pabast_senhas → confere os hashes com PabastPasswordHasher.matches() → emite JWT scope=full com aud = audience do cliente. Mitigação de timing: quando o usuário não existe, o serviço ainda executa o matches() com valores fictícios, para não vazar "existe vs. não existe" pela diferença de tempo.

Login Firebase de app terceiro (com aprovação)

Acesso e papel são eixos separados (decisão 0023): o status diz se entra, o role diz o que vê. Só ATIVO emite JWT.

stateDiagram-v2
    [*] --> PENDENTE: 1º client-login (cria pedido) → 403
    PENDENTE --> ATIVO: admin aprova (com um role do catálogo)
    PENDENTE --> REJEITADO: admin rejeita → 403
    ATIVO --> INATIVO: admin revoga (soft-revoke) → 403
    INATIVO --> ATIVO: admin reativa
    ATIVO --> [*]: client-login emite JWT scope=full + claim role

No client-login, contas do domínio xadm são super-admin implícito (JWT role=ADMIN direto, sem entrar na fila). O cliente precisa estar ativo e ter oauth_firebase em auth_methods. O serviço procura o acesso por (subject_type='oauth', subject_key=firebase_uid, cliente_id, app_id) em app_user_access: ATIVO → emite JWT com o role verbatim (é o que a sync rule do PowerSync consome); REJEITADO/INATIVO → 403 sem reabrir a fila; PENDENTE ou inexistente → cria/atualiza o pedido e retorna 403.

Todo 403 carrega os admins do (cliente, app) no corpo — o usuário ainda não tem JWT, logo não pode consultar o admins-contato, e a tela "Acesso Pendente" precisa dizer a quem pedir liberação.

O role só pode ser um do catálogo do app (app_roles); atribuir fora dele é 422. O catálogo é declarado pelo próprio app e ingerido pelo central (decisão 0025) — a curadoria manual driftava em silêncio.

A fila é gerenciada por dois caminhos: a equipe xadm por /api/admin/oauth/firebase/requests, e o ADMIN do próprio cliente por /api/apps/** (auto-governança). Usuário pAbast entra no mesmo modelo com subject_type='pabast' — sem linha, sem papel, fail-closed (decisão 0024).

Barrar não exige conceder. Os dois lados aceitam INATIVO/REJEITADO/PENDENTE sem papel — oauth por /usuarios/oauth/{id}/status, pAbast por /usuarios/pabast/{id}/status (que cria a linha de acesso se ainda não existir). Só ATIVO exige papel (422 role_required), porque o token pAbast copia o role apenas de linha ATIVA: ativar sem papel devolveria sessão role-less. Inativar preserva o papel já concedido, então reativar não obriga a re-escolher.

Refresh do token (renovação sem senha)

Sem refresh, o papel de uma sessão ficava congelado até o JWT vencer: a sessão pAbast guarda a senha só em memória, e depois de um F5 o app não tinha como re-logar. Troca de papel e inativação só apareciam com logout/login (incidente do bi-comercial, 2026-09-15). O POST /api/auth/refresh troca um JWT de app-user ainda válido por um novo, com a mesma decisão do login sem re-provar a credencial (decisão 0034, ADR central 0037):

  1. valida o JWT (assinatura + exp) e exige oauth_app_id e oauth_cliente_id;
  2. confere o teto de sessão: agora − auth_time (ou o iat, em token antigo) acima de AUTH_SESSION_MAX_HOURS → 403 session_expired;
  3. exige cliente ativo (apagado conta como inativo) e com o método de auth do sujeito;
  4. classifica o sub pelo claim sub_type; em token de antes dele, pelo email — o token pAbast nunca o carrega, o oauth sempre;
  5. pAbast: o sub precisa existir no pabast_senhas do cliente, comparado com TRIM porque o ERP entrega o id com espaço na borda; senão, 403 access_revoked. O papel sai do acesso ATIVO, e sem ele o token sai sem role, igual ao login. oauth: só ATIVO renova; @xadm segue ADMIN;
  6. emite com o auth_time copiado e o exp cortado no teto.

Como a senha não é re-provada, senha trocada no ERP não derruba a sessão antes do teto (7 dias, o máximo da norma). O refresh também não revoga o token anterior — o PowerSync o aceita até o exp —, então o prazo de revogação no servidor é o TTL do access token.

Token anônimo — depreciado

POST /api/auth/anon emite JWT scope=pabast_only (exp 1 h) e existe apenas para compatibilidade com o bi-transporte: pabast_senhas não é mais sincronizado via PowerSync, então o bootstrap-offline que esse token habilitava deixou de ser o caminho corrente. Não usar em integrações novas.

Rate limiting

RateLimiter mantém um contador por IP (extraído de X-Real-IP/X-Forwarded-For atrás do Traefik) e bloqueia acima de 60 req/min, resetando todos os contadores a cada 60 s (@Scheduled). É in-memory e single-instance — suficiente para o volume atual (decisão 0005). Excesso → 429 com Retry-After: 60.

6. Conceitos transversais e configuração

JWT RS256 + JWKS é o conceito central: a chave privada assina (só o central-backend a tem); a pública sai no /.well-known/jwks.json para qualquer serviço verificar. O campo alg=RS256 é obrigatório no JWKS — o PowerSync recusa chaves sem ele, e o nimbus-jose-jwt não o inclui por padrão (decisão 0002).

Algoritmo pAbast é porta literal do integrador e do app Flutter — não alterar, sob pena de quebrar a compatibilidade com os hashes em produção.

AES-256-GCM cifrava a senha do banco de cada cliente (clientes.db_pass); legado após a centralização (decisão 0003), que removeu as colunas de conexão por cliente.

Configuração (variáveis do projeto):

Tema Variáveis principais
Ambiente MICRONAUT_ENVIRONMENTS (prod), PORT (default 8080)
Banco db_auth DATASOURCES_DEFAULT_URL, DB_USER, DB_PASSWORD
Chaves / JWT AUTH_PRIVATE_KEY (RSA PKCS#8 base64) ou AUTH_PRIVATE_KEY_PATH, AUTH_ISSUER, AUTH_KEY_ID (default auth-1), AUTH_TOKEN_EXPIRATION_MINUTES (default 1440), AUTH_SESSION_MAX_HOURS (teto da sessão de app-user, default e máximo 168)
Firebase (admin/OAuth) AUTH_FIREBASE_PROJECT_ID, AUTH_XADM_EMAIL_DOMAIN (default xadm.com.br)
Central de Apps AUTH_CIPHER_KEY (AES-256, 32 bytes base64) — cifra o secret da write-key do Garage; obrigatória quando a feature arquivos/Garage é provisionada
M2M (deploy e integradores) CENTRAL_DEPLOY_TOKEN (/api/ci/**, do CI) · CENTRAL_API_TOKEN (/api/integrador/**, dos integrador-servers — heartbeat) — os dois obrigatórios em prod: vazios, o boot é recusado
CORS CENTRAL_CORS_EXTRA_ORIGINS (CSV) — só para origem fora de *.xadm.biz, que a allowlist já cobre; vazio em produção
E-mail RESEND_API_KEY (produção) · RESEND_REMETENTE · RESEND_ENDPOINT (default a API da Resend; existe como seam de teste) · CENTRAL_EMAIL_SMTP_HOST/_PORT/CENTRAL_EMAIL_FROM (mock de teste)

A geração de chaves, o deploy e a configuração do PowerSync são procedimento — ficam nos runbooks de Operação, não neste livro.

7. Contratos públicos / API

A conclusão de tudo acima: as assinaturas que outros sistemas consomem.

Método / rota Auth Resultado
GET /.well-known/jwks.json nenhuma chave pública RSA (alg=RS256) p/ o PowerSync
POST /api/auth/pabast/login nenhuma 200 JWT scope=full · 400/401/403/429
POST /api/auth/firebase/login Firebase ID Token 200 JWT scope=admin (só @xadm.com.br) · 403/503
POST /api/auth/firebase/client-login Firebase ID Token 200 JWT scope=full se aprovado · 403 pendente/negado
POST /api/auth/refresh JWT de app-user vigente 200 JWT novo com status e papel atuais · 401/403/429
POST /api/auth/anon?cliente=X nenhuma 200 JWT scope=pabast_only (depreciado)
/api/admin/** JWT xadm_admin (ou shared secret do integrador) CRUD de clientes/usuários, fila de aprovação OAuth e catálogo de papéis (incl. POST .../roles/sync)
POST /api/integrador/heartbeat Bearer CENTRAL_API_TOKEN heartbeat do integrador-client (via integrador-server) · 204 · 400/401/422
GET /api/admin/integradores · DELETE /api/admin/integradores/{clienteId} JWT xadm_admin instalações do integrador-client com situacao / parar de acompanhar (idempotente, 204)
/api/apps/** JWT de app-user (oauth_app_id) auto-governança do cliente: aprovar acesso, atribuir papel, contato do admin
GET /health nenhuma liveness plano {status, versao, flavor, commit} da xadm-comum-web (200 fixo). flavor = native|jvm; commit = o XADM_COMMIT da imagem, omitido quando ausente (ou quando vem o HEAD que o Coolify injeta). É o que o smoke pós-deploy usa como identidade da entrega (0027)

O contrato narrado (exemplos curl, corpos, códigos) está em dev/api-rest; o Javadoc interno é gerado pelo CI de docs em dev/api/.

8. Apêndice — Histórico (changelog)

Como o sistema chegou ao estado atual — as decisões e mudanças que o fundamentaram.

Decisões: