Modelagem
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.