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 comxadm_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=fullcom papelUSER/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 empabast_senhaspelo algoritmo pAbast — o serviço nunca vê senha de usuário em texto claro persistido. - Login Firebase (
/api/auth/firebase/logine/client-login): um Firebase ID Token no headerAuthorization: Bearer(rejeitado se > 8 KB), validado pelo JWKS público do Google. Oclient-loginexige aindaclienteeapp_idno corpo. - Refresh (
POST /api/auth/refresh): só o JWT de app-user vigente noAuthorization: Bearer, sem corpo — cliente e app saem do próprio token. - Token anônimo (
POST /api/auth/anon?cliente=X): só ocliente. Depreciado (ver cap. 5). - Admin (
/api/admin/**): um JWT próprio comxadm_admin=trueno header, validado peloAdminAuthFilter. - 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 BearerCENTRAL_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_senhasnão tem FK paraclientes. A tabela foi recriada no layout do ERP (V3) como tabela independente; o vínculo com o cliente é lógico (colunacliente_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 depabast_senhas(decisão 0003) tornou a conexão por cliente desnecessária. Com ela saiu o uso do AES-256-GCM (db_passcifrada).
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+PabastSenhaRepositorysempre 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óATIVOemite JWT (claimroleverbatim, 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).emailNULL para pАbast. - Migração V13: as linhas de
oauth_access_requestsviraramsubject_type='oauth',subject_key=firebase_uid;oauth_access_requestsfoi dropada (prod tinha 0 linhas). subject_keyé sempre trimado.pabast_senhas.idvem do ERP com espaço na borda (1 0999) e o login compara comid.trim(); grant gravado com o id cru davamatch_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 deupdated_atmaior). Não há CHECK impondo isso: a constraint quebraria o app da versão anterior num rollback por redeploy.- Índice em
status.
oauth_access_grantsfoi DROPADA na V11 (ledger ocioso);oauth_access_requestsfoi renomeada/evoluída paraapp_user_accessna V13 (unificação pАbast+oauth, 016). O contrato admin oauth preserva a chave JSONfirebase_uid(=subject_keydas linhas oauth) para o central-ui.
oauth_access_grantsfoi DROPADA na V11. Era um ledger ocioso (o client-login sempre leu o papel deoauth_access_requests, nunca dos grants). O parstatus+roleemoauth_access_requests— comINATIVOcomo 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 emapp_id. Recursos de build são por app (não por par); os "pares (cliente, app)" derivam dos grants. - ¹
metabaseestá reservado no CHECK (V8, congelada) mas foi descopado da featureanalytics— é BI independente, o app não o consome (decisão 0013). Providers vivos:glitchtip,garage,aptabase.
Re-ancoragem (V9):
oauth_access_grants.app_idganhou FK →apps(app_id)— os acessos de terceiros passavam a exigir o app catalogado.oauth_access_requestsnão recebe FK (o firebase client-login cria pedidos para apps ainda não catalogados). O backfill V9 cataloga umappsporapp_iddistinto dos acessos (grupo='cliente', curadoria posterior). Nota (V11): com o drop deoauth_access_grants, essa FK saiu junto; o catálogoapp_roles(V12) é que agora referenciaapps(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,''))— tratainstanceNULL como''para virar alvo doON CONFLICTdo upsert do reconcile. Uma linha lógica = um recurso deployável. - A tabela é derivada, nunca escrita à mão: o
DeployReconcileServicea recompõe a partir deGET /applicationsdo Coolify (on-boot, on-demand e on-miss com debounce). Umcoolify_uuidnovo 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 oClockinjetado — 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 menosprimeiro_heartbeat_em, zerandoalertado_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, preflightOPTIONSnos POST, rate limiting por IP.JwtService— carrega o par RSA no@PostConstruct; emite os JWT (issueToken,issueAnonToken,issueXadmAdminToken,issueOauthFirebaseUserToken) e expõe o JWKS comalg=RS256.JwtValidator+AdminAuthFilter(@ServerFilter({"/api/admin/**", "/test/glitchtip"})) — validam o JWT interno e barram com 401/403 sexadm_adminfor falso (decisão 0032).- Controllers finos, regra nos services da feature (
ClienteAdminService,UsuariosDoAppService,IntegradorInstanciaService…): controller não injeta repository, e oArchitectureTesttrava essa fronteira — detalhe no guia do código. FirebaseTokenValidator— valida Firebase ID Tokens pelo JWKS do Google; é lazy (só existe seAUTH_FIREBASE_PROJECT_IDestiver 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 aceitahttps://*.xadm.biz(todo app cliente no domínio da casa) além da lista base e da env de ambiente; os filtros correm emHIGHEST_PRECEDENCEpara o preflightOPTIONS, que não levaAuthorization, 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/**"), tokenCENTRAL_API_TOKEN) — recebem o heartbeat do integrador-client;SilencioJobalerta o silêncio;IntegradoresAdminControllerlista 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):
- valida o JWT (assinatura +
exp) e exigeoauth_app_ideoauth_cliente_id; - confere o teto de sessão:
agora − auth_time(ou oiat, em token antigo) acima deAUTH_SESSION_MAX_HOURS→403 session_expired; - exige cliente ativo (apagado conta como inativo) e com o método de auth do sujeito;
- classifica o
subpelo claimsub_type; em token de antes dele, peloemail— o token pAbast nunca o carrega, o oauth sempre; - pAbast: o
subprecisa existir nopabast_senhasdo cliente, comparado comTRIMporque o ERP entrega oidcom espaço na borda; senão,403 access_revoked. O papel sai do acessoATIVO, e sem ele o token sai semrole, igual ao login. oauth: sóATIVOrenova;@xadmsegueADMIN; - emite com o
auth_timecopiado e oexpcortado 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 |
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:
- Framework: Micronaut 4 + Netty
- JWT RS256 com nimbus-jose-jwt
- pabast_senhas centralizado em db_auth
- Login Firebase para administradores xadm e usuários de apps terceiros
- Rate limiting: janela fixa in-memory
- Bootstrap offline com token anônimo (scope=pabast_only)
- Admin CRUD protegido por JwtValidator em filtro dedicado
- pabast_senhas sem PRIMARY KEY — unicidade em software por (cliente_id, id)
- Bump para Micronaut 5 e JDK 25
- Central de Apps — modelo build-time (catálogo + broker de provisão)
- Observabilidade de erros (GlitchTip) e resposta honesta do /provision
- Garage (arquivos): provisão de bucket+key e corretor de presign broker-signed
- Analytics (Aptabase): auto-provisão forjando o token de sessão (HS256 + AUTH_SECRET)
- Analytics BI (Metabase): auto-provisão de dashboards via API REST (re-escopo)
- Filtros admin com @ServerFilter (honra @Order)
- Contrato de erro único (RFC 7807 / problem+json)
- Metabase signed embedding (supersede o "BI = só link" da 0014)
- Habilitação GraalVM native-image do auth
- Monitor de host/apps do Coolify (Fase 1, read-only)
- Rename auth → central-backend (identidade interna) mantendo auth.xadm.biz como protocolo
- Adoção do control-plane de deploy (ADR central 0026) com resolução ancorada na imagem
- Self-deploy do central pelo próprio /api/ci/deploy — reverte a guarda self_deploy_refused
- Login social: modelo status×role e auto-governança de acesso por app
- Acesso unificado do usuário ao app (pAbast ∪ oauth), fail-closed
- Catálogo de papéis declarado pelo app e ingerido pelo central
- Apps compose (build-from-git) no control-plane, ancorados no slug do git repo
- Adota o smoke de produção pós-deploy e a identidade de build (XADM_COMMIT)
- Heartbeat do integrador-client, alerta de silêncio por horas úteis e CENTRAL_API_TOKEN
- Adoção do CI/CD 100% GitHub Actions (ADR central 0027) — pipeline.yml único
- Adoção do native por padrão (ADR central 0033) — alvo único native
- Adoção das libs da casa na versão corrente (ADR central 0034)
- Rota de teste do GlitchTip sob o JWT xadm_admin
- Dois tokens de serviço para o central, um por escopo de rota
- Refresh do JWT por token e teto absoluto de sessão