Pular para conteúdo

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