Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cfeeb0464 | ||
|
|
93f280621b | ||
|
|
af559ca455 | ||
|
|
60f15a3576 | ||
|
|
b410fa4559 | ||
|
|
a4f0a2f649 | ||
|
|
2c8c5d9507 | ||
|
|
8c22597420 | ||
|
|
4779d6b2a3 | ||
|
|
bd0ecabf41 | ||
|
|
2be97ea969 | ||
|
|
e142a44c01 | ||
|
|
84ad5c4757 | ||
|
|
b8586ba0aa | ||
|
|
c5790ab301 |
No files matched your search
+18
-1
@@ -31,8 +31,25 @@ ELO_PRO_APP_URL=https://hml-ada.elopro.app
|
|||||||
AUTH_ALLOWED_RETURN_HOSTS=hml-ada.elopro.app
|
AUTH_ALLOWED_RETURN_HOSTS=hml-ada.elopro.app
|
||||||
|
|
||||||
# --- Banco ----------------------------------------------------------------
|
# --- Banco ----------------------------------------------------------------
|
||||||
# Banco de HOMOLOGAÇÃO. Nunca o de produção.
|
# Três credenciais. Nenhuma é a do Supabase self-hosted.
|
||||||
|
# Web: papel adaptador_web, sem SUPERUSER e sem BYPASSRLS.
|
||||||
|
# sslmode=verify-full é obrigatório. Não use require, prefer ou disable.
|
||||||
DATABASE_URL=
|
DATABASE_URL=
|
||||||
|
# Worker: papel adaptador_worker. Não reutilize a URL da web nem a administrativa.
|
||||||
|
ADAPTER_WORKER_DATABASE_URL=
|
||||||
|
# Migrations, seed:staging e flag. Papel postgres deste projeto, só na execução aprovada.
|
||||||
|
ADAPTER_MIGRATION_DATABASE_URL=
|
||||||
|
# Reference ID do Dashboard: 20 letras ou dígitos minúsculos. Sozinho não autoriza migration.
|
||||||
|
ADAPTER_DB_PROJECT_REF=
|
||||||
|
# Gravação explícita de adaptador_guard. Vazio por omissão. O valor 1 só no comando identity:init.
|
||||||
|
# migrate, migrate:check, seed e deploy não criam essa tabela.
|
||||||
|
ADAPTER_INIT_PROJECT_IDENTITY=
|
||||||
|
# Host do Session Pooler copiado do Dashboard. O índice aws-N não se deduz da região.
|
||||||
|
# O Dashboard deste projeto mostra host iniciado por aws-1-us-west-2.pooler.supabase.com.
|
||||||
|
# Só é aceito aws-<digitos>-us-west-2.pooler.supabase.com.
|
||||||
|
ADAPTER_DB_POOLER_HOST=
|
||||||
|
# Teto do pool por processo. Vazio usa 2 na web, 2 no worker e 1 na migration. Máximo 4.
|
||||||
|
ADAPTER_DB_POOL_MAX=
|
||||||
# Decisão explícita e obrigatória:
|
# Decisão explícita e obrigatória:
|
||||||
# 1 = criar o shim local de identidade (profiles, workspaces, workspace_members)
|
# 1 = criar o shim local de identidade (profiles, workspaces, workspace_members)
|
||||||
# 0 = o schema do Elo Pro já existe nesse banco; não recriar
|
# 0 = o schema do Elo Pro já existe nesse banco; não recriar
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Scripts executados dentro do contêiner Linux precisam de LF, independente do
|
||||||
|
# core.autocrlf da máquina que faz o checkout. CRLF quebra o `#!/bin/sh`.
|
||||||
|
*.sh text eol=lf
|
||||||
Regular → Executable
File mode changed.
Executable
+185
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Permissões do armazenamento local (volume montado em /app/.data).
|
||||||
|
#
|
||||||
|
# Cada caso cria o próprio volume descartável com prefixo único e remove só os
|
||||||
|
# volumes que criou. Nunca toca nos volumes do compose
|
||||||
|
# (adaptador-planilhas-local-postgres, adaptador-planilhas-local-storage).
|
||||||
|
#
|
||||||
|
# ADAPTER_IMAGE=adaptador-planilhas:local bash .github/scripts/storage-permissions.sh
|
||||||
|
set -euo pipefail
|
||||||
|
# Git Bash no Windows converteria /app/... em caminho do Windows.
|
||||||
|
export MSYS_NO_PATHCONV=1
|
||||||
|
|
||||||
|
IMAGE="${ADAPTER_IMAGE:-adaptador-planilhas:local}"
|
||||||
|
PREFIX="adaptador-storage-test-$$-$RANDOM"
|
||||||
|
CREATED=()
|
||||||
|
CONTAINERS=()
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
for c in "${CONTAINERS[@]:-}"; do [ -n "$c" ] && docker rm -f "$c" >/dev/null 2>&1 || true; done
|
||||||
|
for v in "${CREATED[@]:-}"; do [ -n "$v" ] && docker volume rm "$v" >/dev/null 2>&1 || true; done
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# Define VOL no shell atual: dentro de $(...) o registro em CREATED se perderia.
|
||||||
|
new_volume() {
|
||||||
|
VOL="$PREFIX-$1"
|
||||||
|
docker volume create "$VOL" >/dev/null
|
||||||
|
CREATED+=("$VOL")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mesmas restrições do serviço storage-init do docker-compose.yml.
|
||||||
|
prepare() {
|
||||||
|
docker run --rm --network none --user 0:0 \
|
||||||
|
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add DAC_OVERRIDE \
|
||||||
|
--cap-add DAC_READ_SEARCH --cap-add SETUID --cap-add SETGID \
|
||||||
|
--security-opt no-new-privileges:true \
|
||||||
|
-v "$1:/app/.data" "$IMAGE" prepare-storage "${@:2}"
|
||||||
|
}
|
||||||
|
|
||||||
|
as_app() { local v="$1"; shift; docker run --rm --network none -v "$v:/app/.data" "$IMAGE" exec "$@"; }
|
||||||
|
as_root() { local v="$1"; shift; docker run --rm --network none --user 0:0 -v "$v:/app/.data" "$IMAGE" exec "$@"; }
|
||||||
|
|
||||||
|
# Estado do volume como root: dono, grupo, modo, tamanho e caminho, mais o hash dos arquivos.
|
||||||
|
snapshot() {
|
||||||
|
as_root "$1" sh -c 'cd /app/.data && find . -printf "%U:%G %m %s %p\n" | sort && find . -type f -exec sha256sum {} + | sort -k2'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_owner_mode() {
|
||||||
|
local v="$1" path="$2" want="$3"
|
||||||
|
local got
|
||||||
|
got=$(as_root "$v" stat -c '%u:%g %a' "$path")
|
||||||
|
[ "$got" = "$want" ] || { echo "ERRO: $path = '$got', esperado '$want'"; exit 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() { echo "ERRO: $*"; exit 1; }
|
||||||
|
|
||||||
|
echo "== imagem: $IMAGE =="
|
||||||
|
docker image inspect "$IMAGE" >/dev/null
|
||||||
|
|
||||||
|
echo "== A. volume novo =="
|
||||||
|
new_volume a; VA="$VOL"
|
||||||
|
# A1: sem storage-init, o volume vazio herda dono e modo da imagem.
|
||||||
|
expect_owner_mode "$VA" /app/.data "1001:1001 750"
|
||||||
|
expect_owner_mode "$VA" /app/.data/storage "1001:1001 750"
|
||||||
|
as_app "$VA" sh -c ': > /app/.data/storage/ok && rm /app/.data/storage/ok' || fail "uid 1001 não escreve no volume novo"
|
||||||
|
# A2: com storage-init, o mesmo resultado e a prova de escrita.
|
||||||
|
prepare "$VA" | tee /tmp/storage-a.log
|
||||||
|
grep -q "STORAGE_WRITABLE" /tmp/storage-a.log || fail "storage-init não confirmou escrita"
|
||||||
|
echo "ok: volume novo 1001:1001 750, escrita pelo uid 1001"
|
||||||
|
|
||||||
|
echo "== B. volume existente, criado antes da correção (raiz root:root 755) =="
|
||||||
|
new_volume b; VB="$VOL"
|
||||||
|
as_root "$VB" sh -c '
|
||||||
|
chown 0:0 /app/.data /app/.data/storage && chmod 755 /app/.data /app/.data/storage
|
||||||
|
mkdir -p /app/.data/storage/ws-alpha/modelos
|
||||||
|
printf "conteudo do modelo alpha\n" > /app/.data/storage/ws-alpha/modelos/modelo.xlsx
|
||||||
|
chown -R 1001:1001 /app/.data/storage/ws-alpha
|
||||||
|
printf "arquivo legado de root\n" > /app/.data/storage/legado.bin
|
||||||
|
chmod 640 /app/.data/storage/legado.bin'
|
||||||
|
# Antes da preparação o uid 1001 não escreve na raiz: é o defeito original.
|
||||||
|
if as_app "$VB" sh -c ': > /app/.data/storage/x' 2>/dev/null; then fail "o cenário B deveria começar sem escrita"; fi
|
||||||
|
prepare "$VB" 2>&1 | tee /tmp/storage-b.log
|
||||||
|
grep -q "STORAGE_WRITABLE" /tmp/storage-b.log || fail "storage-init não confirmou escrita"
|
||||||
|
grep -q "AVISO: 1 item" /tmp/storage-b.log || fail "o arquivo de root deveria ser só reportado"
|
||||||
|
expect_owner_mode "$VB" /app/.data "1001:1001 750"
|
||||||
|
expect_owner_mode "$VB" /app/.data/storage "1001:1001 750"
|
||||||
|
# Conteúdo e dono dos arquivos existentes intactos; só os dois diretórios mudaram.
|
||||||
|
expect_owner_mode "$VB" /app/.data/storage/legado.bin "0:0 640"
|
||||||
|
expect_owner_mode "$VB" /app/.data/storage/ws-alpha/modelos/modelo.xlsx "1001:1001 644"
|
||||||
|
test "$(as_root "$VB" cat /app/.data/storage/ws-alpha/modelos/modelo.xlsx)" = "conteudo do modelo alpha" \
|
||||||
|
|| fail "conteúdo do modelo alterado"
|
||||||
|
test "$(as_root "$VB" cat /app/.data/storage/legado.bin)" = "arquivo legado de root" || fail "conteúdo legado alterado"
|
||||||
|
as_app "$VB" sh -c ': > /app/.data/storage/novo && rm /app/.data/storage/novo' || fail "uid 1001 não escreve após preparar"
|
||||||
|
echo "ok: volume existente preservado e liberado para o uid 1001"
|
||||||
|
|
||||||
|
echo "== C. idempotência =="
|
||||||
|
s1=$(snapshot "$VB")
|
||||||
|
prepare "$VB" > /tmp/storage-c1.log 2>&1
|
||||||
|
prepare "$VB" > /tmp/storage-c2.log 2>&1
|
||||||
|
s2=$(snapshot "$VB")
|
||||||
|
[ "$s1" = "$s2" ] || { diff <(echo "$s1") <(echo "$s2") || true; fail "a preparação repetida alterou o volume"; }
|
||||||
|
if grep -q -- "->" /tmp/storage-c2.log; then fail "a segunda execução não deveria alterar dono nem modo"; fi
|
||||||
|
echo "ok: duas execuções extras, nenhum byte, dono ou modo alterado"
|
||||||
|
|
||||||
|
echo "== C2. --fix-existing é limitado ao volume e preserva conteúdo =="
|
||||||
|
prepare "$VB" --fix-existing > /tmp/storage-c3.log 2>&1
|
||||||
|
expect_owner_mode "$VB" /app/.data/storage/legado.bin "1001:1001 640"
|
||||||
|
test "$(as_root "$VB" cat /app/.data/storage/legado.bin)" = "arquivo legado de root" || fail "conteúdo alterado"
|
||||||
|
if grep -q "AVISO" /tmp/storage-c3.log; then fail "ainda há item com outro dono"; fi
|
||||||
|
echo "ok: dono ajustado só quando pedido, conteúdo intacto"
|
||||||
|
|
||||||
|
echo "== D. web e worker compartilham o volume =="
|
||||||
|
new_volume d; VD="$VOL"
|
||||||
|
prepare "$VD" >/dev/null
|
||||||
|
# Um contêiner grava como a web grava, outro lê e apaga como o worker faz na limpeza.
|
||||||
|
as_app "$VD" sh -c 'mkdir -p /app/.data/storage/ws/jobs && printf saida > /app/.data/storage/ws/jobs/out.csv'
|
||||||
|
test "$(as_app "$VD" cat /app/.data/storage/ws/jobs/out.csv)" = "saida" || fail "outro contêiner não lê o arquivo"
|
||||||
|
as_app "$VD" rm /app/.data/storage/ws/jobs/out.csv || fail "outro contêiner não apaga o arquivo"
|
||||||
|
# O comando web real passa da verificação e responde liveness.
|
||||||
|
WEB="$PREFIX-web"
|
||||||
|
CONTAINERS+=("$WEB")
|
||||||
|
docker run -d --name "$WEB" --network none -e ADAPTER_ENV=development -v "$VD:/app/.data" "$IMAGE" web >/dev/null
|
||||||
|
ok=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if docker exec "$WEB" node -e "fetch('http://127.0.0.1:3000/api/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" 2>/dev/null; then ok=1; break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
[ -n "$ok" ] || { docker logs "$WEB"; fail "a web não subiu com o volume preparado"; }
|
||||||
|
docker logs "$WEB" 2>&1 | grep -q STORAGE_NOT_WRITABLE && fail "a web reportou storage sem escrita"
|
||||||
|
# O comando worker real passa da verificação de storage (falha depois, por falta de banco/Redis, o que não é deste teste).
|
||||||
|
WORKER="$PREFIX-worker"
|
||||||
|
CONTAINERS+=("$WORKER")
|
||||||
|
docker run -d --name "$WORKER" --network none -e ADAPTER_ENV=development -e ADAPTER_JOB_MODE=worker \
|
||||||
|
-v "$VD:/app/.data" "$IMAGE" worker >/dev/null
|
||||||
|
sleep 6
|
||||||
|
wlog=$(docker logs "$WORKER" 2>&1)
|
||||||
|
docker rm -f "$WORKER" >/dev/null
|
||||||
|
echo "$wlog" | grep -q STORAGE_NOT_WRITABLE && { echo "$wlog"; fail "o worker reportou storage sem escrita"; }
|
||||||
|
echo "$wlog" | grep -q '\[worker\]\|DATABASE\|Redis\|ECONNREFUSED\|pglite\|migration' \
|
||||||
|
|| { echo "$wlog"; fail "o worker não chegou ao código da aplicação"; }
|
||||||
|
echo "ok: escrita, leitura e remoção entre contêineres; web e worker passam da verificação"
|
||||||
|
|
||||||
|
echo "== E. usuário não privilegiado =="
|
||||||
|
test "$(docker exec "$WEB" id -u)" = "1001" || fail "a web não roda como 1001"
|
||||||
|
test "$(docker exec "$WEB" sh -c 'grep CapEff /proc/1/status | cut -f2')" = "0000000000000000" || fail "a web tem capacidades"
|
||||||
|
test "$(docker run --rm --network none "$IMAGE" exec id -u)" = "1001" || fail "o usuário padrão da imagem não é 1001"
|
||||||
|
# prepare-storage recusa rodar sem root, então não serve de atalho para escalar.
|
||||||
|
if docker run --rm --network none -v "$VD:/app/.data" "$IMAGE" prepare-storage >/dev/null 2>&1; then
|
||||||
|
fail "prepare-storage aceitou rodar como 1001"
|
||||||
|
fi
|
||||||
|
docker rm -f "$WEB" >/dev/null
|
||||||
|
test "$(docker image inspect "$IMAGE" --format "{{.Config.User}}")" = "adaptador" || fail "a imagem não declara USER adaptador"
|
||||||
|
echo "ok: web e worker como 1001 sem capacidades; preparação só como root"
|
||||||
|
|
||||||
|
echo "== F. falha segura com armazenamento sem escrita =="
|
||||||
|
new_volume f; VF="$VOL"
|
||||||
|
as_root "$VF" sh -c 'chown 0:0 /app/.data /app/.data/storage && chmod 755 /app/.data /app/.data/storage'
|
||||||
|
for proc in web worker; do
|
||||||
|
set +e
|
||||||
|
out=$(docker run --rm --network none -e ADAPTER_ENV=development -v "$VF:/app/.data" "$IMAGE" "$proc" 2>&1)
|
||||||
|
code=$?
|
||||||
|
set -e
|
||||||
|
[ "$code" -ne 0 ] || fail "$proc subiu com storage sem escrita"
|
||||||
|
echo "$out" | grep -q STORAGE_NOT_WRITABLE || { echo "$out"; fail "$proc não explicou a falha"; }
|
||||||
|
echo " $proc: saiu com código $code e STORAGE_NOT_WRITABLE"
|
||||||
|
done
|
||||||
|
# Com Supabase configurado o volume local não é usado e a verificação não bloqueia.
|
||||||
|
SUPA="$PREFIX-supabase"
|
||||||
|
CONTAINERS+=("$SUPA")
|
||||||
|
docker run -d --name "$SUPA" --network none -e ADAPTER_ENV=development \
|
||||||
|
-e NEXT_PUBLIC_SUPABASE_URL=https://exemplo.invalid -e SUPABASE_SERVICE_ROLE_KEY=x -e SUPABASE_STORAGE_BUCKET=x \
|
||||||
|
-v "$VF:/app/.data" "$IMAGE" web >/dev/null
|
||||||
|
ok=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if docker exec "$SUPA" node -e "fetch('http://127.0.0.1:3000/api/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" 2>/dev/null; then ok=1; break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
slog=$(docker logs "$SUPA" 2>&1)
|
||||||
|
docker rm -f "$SUPA" >/dev/null
|
||||||
|
[ -n "$ok" ] || { echo "$slog"; fail "a web não subiu com bucket configurado"; }
|
||||||
|
echo "$slog" | grep -q STORAGE_NOT_WRITABLE && { echo "$slog"; fail "a verificação bloqueou com bucket configurado"; }
|
||||||
|
echo "ok: falha explícita sem storage; sem bloqueio quando o bucket é o Supabase"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "PERMISSÕES DO ARMAZENAMENTO: OK (A, B, C, D, E, F)"
|
||||||
@@ -4,10 +4,25 @@ on:
|
|||||||
push:
|
push:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
|
# Mínimo necessário. Nenhum job deste workflow escreve no repositório.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# O repositório é público e o runner é uma máquina real. Um pull request de
|
||||||
|
# fork traria código de terceiro para rodar nele, com Docker à disposição.
|
||||||
|
# Enquanto não houver aprovação manual de workflow configurada na instância,
|
||||||
|
# só PR da própria origem executa. Push continua rodando normalmente.
|
||||||
|
# Evita fila de runs esperando runner: um push novo cancela o anterior do
|
||||||
|
# mesmo ref. O que já estava em execução não é interrompido no meio.
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verify:
|
verify:
|
||||||
name: lint, typecheck, testes e build
|
name: lint, typecheck, testes e build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
env:
|
env:
|
||||||
ADAPTER_ENV: development
|
ADAPTER_ENV: development
|
||||||
ADAPTER_JOB_MODE: inline
|
ADAPTER_JOB_MODE: inline
|
||||||
@@ -27,6 +42,7 @@ jobs:
|
|||||||
services:
|
services:
|
||||||
name: Postgres e Redis reais
|
name: Postgres e Redis reais
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -99,6 +115,7 @@ jobs:
|
|||||||
image:
|
image:
|
||||||
name: imagem Docker Linux
|
name: imagem Docker Linux
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
env:
|
env:
|
||||||
ADAPTER_IMAGE: adaptador-planilhas:ci
|
ADAPTER_IMAGE: adaptador-planilhas:ci
|
||||||
steps:
|
steps:
|
||||||
@@ -237,6 +254,9 @@ jobs:
|
|||||||
http://127.0.0.1:3000/api/dev/session)" = "404"
|
http://127.0.0.1:3000/api/dev/session)" = "404"
|
||||||
docker rm -f ci-web
|
docker rm -f ci-web
|
||||||
|
|
||||||
|
- name: Permissões do armazenamento local (volume novo, existente, idempotência, falha segura)
|
||||||
|
run: bash .github/scripts/storage-permissions.sh
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Topologia completa: web + worker + Postgres + Redis
|
# Topologia completa: web + worker + Postgres + Redis
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
name: runner-check
|
||||||
|
|
||||||
|
# Primeiro workflow a rodar quando um runner entrar online. Não instala
|
||||||
|
# dependência, não constrói nada, não toca em rede externa, não usa segredo e
|
||||||
|
# não faz checkout do código: só confirma que a ligação Gitea → runner funciona
|
||||||
|
# e mostra o que o runner tem disponível.
|
||||||
|
#
|
||||||
|
# Dispare pela interface (Actions → runner-check → Run workflow) ou editando
|
||||||
|
# este arquivo.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/runner-check.yml"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: runner-check-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ligacao:
|
||||||
|
name: o runner responde
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Público: PR de fork não executa enquanto não houver aprovação manual.
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
steps:
|
||||||
|
- name: Identidade do runner
|
||||||
|
run: |
|
||||||
|
echo "runner.os = ${RUNNER_OS:-?}"
|
||||||
|
echo "runner.arch = ${RUNNER_ARCH:-?}"
|
||||||
|
echo "ref = ${GITHUB_REF:-?}"
|
||||||
|
echo "sha = ${GITHUB_SHA:-?}"
|
||||||
|
echo "evento = ${GITHUB_EVENT_NAME:-?}"
|
||||||
|
echo "---"
|
||||||
|
uname -a
|
||||||
|
echo "usuario: $(id -un) (uid $(id -u))"
|
||||||
|
|
||||||
|
- name: Ferramentas que os outros jobs exigem
|
||||||
|
run: |
|
||||||
|
faltando=""
|
||||||
|
for cmd in node npm docker curl git psql python3; do
|
||||||
|
if command -v "$cmd" >/dev/null 2>&1; then
|
||||||
|
printf ' ok %-8s %s\n' "$cmd" "$($cmd --version 2>&1 | head -1)"
|
||||||
|
else
|
||||||
|
printf ' AUSENTE %-8s\n' "$cmd"
|
||||||
|
faltando="$faltando $cmd"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
if [ -n "$faltando" ]; then
|
||||||
|
echo "Faltam no runner:$faltando"
|
||||||
|
echo "node e npm são exigidos pelo job verify."
|
||||||
|
echo "docker é exigido pelo job image (precisa de Docker-in-Docker)."
|
||||||
|
echo "psql e python3 são exigidos pelo job services e pela jornada assíncrona."
|
||||||
|
echo ""
|
||||||
|
echo "Isto não falha o workflow de propósito: o objetivo é inventariar."
|
||||||
|
else
|
||||||
|
echo "todas as ferramentas presentes"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Docker funciona de verdade
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "docker ausente: o job image não vai rodar neste runner"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
docker version
|
||||||
|
docker run --rm hello-world >/dev/null 2>&1 \
|
||||||
|
&& echo "ok: o runner executa contêiner" \
|
||||||
|
|| echo "docker presente mas não executa contêiner: confira o Docker-in-Docker"
|
||||||
|
|
||||||
|
- name: Resultado
|
||||||
|
run: echo "ligacao Gitea -> runner confirmada"
|
||||||
@@ -73,6 +73,15 @@ COPY --chown=adaptador:adaptador src ./src
|
|||||||
COPY --chown=adaptador:adaptador tsconfig.json ./
|
COPY --chown=adaptador:adaptador tsconfig.json ./
|
||||||
COPY --chown=adaptador:adaptador docker ./docker
|
COPY --chown=adaptador:adaptador docker ./docker
|
||||||
|
|
||||||
|
# 4) Ponto de montagem do armazenamento local já com o dono certo. Um volume
|
||||||
|
# nomeado VAZIO herda dono e modo deste diretório na primeira montagem. Sem
|
||||||
|
# ele, o Docker cria /app/.data como root:root 755 e o uid 1001 não escreve
|
||||||
|
# (STORAGE_NOT_WRITABLE). Volumes que já existem não herdam nada: para eles
|
||||||
|
# há o serviço storage-init do compose.
|
||||||
|
RUN mkdir -p /app/.data/storage \
|
||||||
|
&& chown adaptador:adaptador /app/.data /app/.data/storage \
|
||||||
|
&& chmod 750 /app/.data /app/.data/storage
|
||||||
|
|
||||||
USER adaptador
|
USER adaptador
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ docker compose exec web node node_modules/tsx/dist/cli.mjs scripts/migrate.ts
|
|||||||
|
|
||||||
Portas só em `127.0.0.1`: web `3000`, Postgres `55432`, Redis `56379`.
|
Portas só em `127.0.0.1`: web `3000`, Postgres `55432`, Redis `56379`.
|
||||||
|
|
||||||
|
O serviço `storage-init` prepara o volume do armazenamento e termina antes de web e worker subirem; os dois continuam como uid 1001. Ver [23-storage-permissions.md](docs/spreadsheet-adapter/23-storage-permissions.md).
|
||||||
|
|
||||||
## Homologação
|
## Homologação
|
||||||
|
|
||||||
O procedimento completo — DNS, Nginx, Supabase, banco, bucket, containers, worker, observabilidade e rollback — está em **[docs/spreadsheet-adapter/20-homologacao-runbook.md](docs/spreadsheet-adapter/20-homologacao-runbook.md)**. O estado de validação está em [21-checklist-validacao.md](docs/spreadsheet-adapter/21-checklist-validacao.md).
|
O procedimento completo — DNS, Nginx, Supabase, banco, bucket, containers, worker, observabilidade e rollback — está em **[docs/spreadsheet-adapter/20-homologacao-runbook.md](docs/spreadsheet-adapter/20-homologacao-runbook.md)**. O estado de validação está em [21-checklist-validacao.md](docs/spreadsheet-adapter/21-checklist-validacao.md).
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Ensaio descartável de rollback de imagem.
|
||||||
|
#
|
||||||
|
# Projeto, rede e volumes são exclusivos deste ensaio. Não há `build:` de
|
||||||
|
# propósito: a imagem vem só de ADAPTER_IMAGE, com tag imutável do commit.
|
||||||
|
# Nunca use a tag `adaptador-planilhas:local` aqui — ela alimenta o compose
|
||||||
|
# principal. Postgres e Redis não publicam porta.
|
||||||
|
|
||||||
|
name: adaptador-rollback-ensaio
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: adaptador
|
||||||
|
POSTGRES_PASSWORD: rollback_ensaio_local
|
||||||
|
POSTGRES_DB: adaptador_rollback
|
||||||
|
volumes:
|
||||||
|
- ensaio-postgres:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U adaptador -d adaptador_rollback"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
networks: [ensaio]
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
networks: [ensaio]
|
||||||
|
|
||||||
|
storage-init:
|
||||||
|
image: ${ADAPTER_IMAGE:?defina ADAPTER_IMAGE com a tag do commit}
|
||||||
|
command: ["prepare-storage"]
|
||||||
|
user: "0:0"
|
||||||
|
network_mode: none
|
||||||
|
cap_drop: [ALL]
|
||||||
|
cap_add: [CHOWN, FOWNER, DAC_OVERRIDE, DAC_READ_SEARCH, SETUID, SETGID]
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
|
restart: "no"
|
||||||
|
volumes:
|
||||||
|
- ensaio-storage:/app/.data
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: ${ADAPTER_IMAGE:?defina ADAPTER_IMAGE com a tag do commit}
|
||||||
|
command: ["web"]
|
||||||
|
environment: &app-env
|
||||||
|
ADAPTER_ENV: development
|
||||||
|
ADAPTER_NAMESPACE: adaptador-rollback-ensaio
|
||||||
|
ADAPTER_JOB_MODE: worker
|
||||||
|
ADAPTER_APPLY_IDENTITY_SHIM: "1"
|
||||||
|
ADAPTER_DEV_AUTH: "1"
|
||||||
|
ADAPTER_DEV_AUTH_SECRET: segredo-apenas-ensaio
|
||||||
|
DATABASE_URL: postgres://adaptador:rollback_ensaio_local@postgres:5432/adaptador_rollback
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
SPREADSHEET_TEMP_TTL_SECONDS: "3600"
|
||||||
|
ELO_PRO_APP_URL: http://127.0.0.1:3310
|
||||||
|
PORT: "3000"
|
||||||
|
HOSTNAME: 0.0.0.0
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3310:3000"
|
||||||
|
volumes:
|
||||||
|
- ensaio-storage:/app/.data
|
||||||
|
depends_on:
|
||||||
|
storage-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks: [ensaio]
|
||||||
|
|
||||||
|
worker:
|
||||||
|
image: ${ADAPTER_IMAGE:?defina ADAPTER_IMAGE com a tag do commit}
|
||||||
|
command: ["worker"]
|
||||||
|
environment: *app-env
|
||||||
|
volumes:
|
||||||
|
- ensaio-storage:/app/.data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/app/docker/entrypoint.sh", "worker-health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 15s
|
||||||
|
retries: 3
|
||||||
|
stop_grace_period: 30s
|
||||||
|
depends_on:
|
||||||
|
storage-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
web:
|
||||||
|
condition: service_started
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks: [ensaio]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ensaio:
|
||||||
|
name: adaptador-rollback-ensaio
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ensaio-postgres:
|
||||||
|
name: adaptador-rollback-ensaio-postgres
|
||||||
|
ensaio-storage:
|
||||||
|
name: adaptador-rollback-ensaio-storage
|
||||||
@@ -41,6 +41,26 @@ services:
|
|||||||
retries: 20
|
retries: 20
|
||||||
networks: [adaptador]
|
networks: [adaptador]
|
||||||
|
|
||||||
|
# Prepara o volume do armazenamento antes de web e worker e termina. É o único
|
||||||
|
# passo como root: sem rede, só com as capacidades de ajustar dono e trocar de
|
||||||
|
# usuário para provar a escrita como uid 1001. Idempotente e sem recursão.
|
||||||
|
storage-init:
|
||||||
|
image: ${ADAPTER_IMAGE:-adaptador-planilhas:local}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: runner
|
||||||
|
command: ["prepare-storage"]
|
||||||
|
user: "0:0"
|
||||||
|
network_mode: none
|
||||||
|
cap_drop: [ALL]
|
||||||
|
cap_add: [CHOWN, FOWNER, DAC_OVERRIDE, DAC_READ_SEARCH, SETUID, SETGID]
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
|
restart: "no"
|
||||||
|
volumes:
|
||||||
|
- app-storage:/app/.data
|
||||||
|
|
||||||
web:
|
web:
|
||||||
# Com ADAPTER_IMAGE definido, usa a imagem já construída (o CI faz isso).
|
# Com ADAPTER_IMAGE definido, usa a imagem já construída (o CI faz isso).
|
||||||
# Sem ela, `up --build` constrói e marca com este nome.
|
# Sem ela, `up --build` constrói e marca com este nome.
|
||||||
@@ -67,6 +87,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- app-storage:/app/.data
|
- app-storage:/app/.data
|
||||||
depends_on:
|
depends_on:
|
||||||
|
storage-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
@@ -92,6 +114,8 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
stop_grace_period: 30s
|
stop_grace_period: 30s
|
||||||
depends_on:
|
depends_on:
|
||||||
|
storage-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
web:
|
web:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
postgres:
|
postgres:
|
||||||
|
|||||||
Regular → Executable
+80
-2
@@ -9,6 +9,81 @@ set -eu
|
|||||||
|
|
||||||
TSX="--import=tsx"
|
TSX="--import=tsx"
|
||||||
|
|
||||||
|
# Armazenamento local (development): o volume `app-storage` montado em /app/.data.
|
||||||
|
# Homologação e produção usam o bucket do Supabase e não montam este volume.
|
||||||
|
DATA_DIR=/app/.data
|
||||||
|
STORAGE_DIR="$DATA_DIR/storage"
|
||||||
|
|
||||||
|
# Mesma regra de src/infra/storage/index.ts: sem as três variáveis do Supabase,
|
||||||
|
# o processo grava no disco local.
|
||||||
|
local_storage_in_use() {
|
||||||
|
[ -z "${NEXT_PUBLIC_SUPABASE_URL:-}" ] || [ -z "${SUPABASE_SERVICE_ROLE_KEY:-}" ] || [ -z "${SUPABASE_STORAGE_BUCKET:-}" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Web e worker recusam subir com armazenamento local que não aceita escrita.
|
||||||
|
# Sem isto o processo fica no ar e o primeiro upload responde "Erro interno".
|
||||||
|
assert_storage_writable() {
|
||||||
|
local_storage_in_use || return 0
|
||||||
|
probe="$STORAGE_DIR/.write-probe-$$"
|
||||||
|
if mkdir -p "$STORAGE_DIR" 2>/dev/null && touch "$probe" 2>/dev/null; then
|
||||||
|
rm -f "$probe"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "STORAGE_NOT_WRITABLE: $STORAGE_DIR não aceita escrita pelo uid $(id -u)." >&2
|
||||||
|
echo "Rode o serviço storage-init do compose (docs/spreadsheet-adapter/23-storage-permissions.md)." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Roda como root, uma vez, antes de web e worker (serviço storage-init).
|
||||||
|
# Ajusta só os dois diretórios do armazenamento, sem recursão: arquivos que já
|
||||||
|
# existem não são tocados, a não ser com --fix-existing, que é passo manual.
|
||||||
|
prepare_storage() {
|
||||||
|
if [ "$(id -u)" != "0" ]; then
|
||||||
|
echo "prepare-storage precisa rodar como root (user: \"0:0\" no serviço storage-init)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
app_uid=$(id -u adaptador)
|
||||||
|
app_gid=$(id -g adaptador)
|
||||||
|
|
||||||
|
for dir in "$DATA_DIR" "$STORAGE_DIR"; do
|
||||||
|
if [ -L "$dir" ]; then
|
||||||
|
echo "[storage-init] recusado: $dir é link simbólico." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$dir"
|
||||||
|
owner=$(stat -c '%u:%g' "$dir")
|
||||||
|
if [ "$owner" != "$app_uid:$app_gid" ]; then
|
||||||
|
chown "$app_uid:$app_gid" "$dir"
|
||||||
|
echo "[storage-init] $dir: dono $owner -> $app_uid:$app_gid"
|
||||||
|
fi
|
||||||
|
mode=$(stat -c '%a' "$dir")
|
||||||
|
if [ "$mode" != "750" ]; then
|
||||||
|
chmod 750 "$dir"
|
||||||
|
echo "[storage-init] $dir: modo $mode -> 750"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--fix-existing" ]; then
|
||||||
|
# -xdev não atravessa outro sistema de arquivos; -h não segue link simbólico.
|
||||||
|
find "$DATA_DIR" -xdev ! -user "$app_uid" -exec chown -h "$app_uid:$app_gid" {} +
|
||||||
|
echo "[storage-init] dono de itens existentes ajustado para $app_uid:$app_gid"
|
||||||
|
fi
|
||||||
|
|
||||||
|
foreign=$(find "$DATA_DIR" -xdev ! -user "$app_uid" | wc -l)
|
||||||
|
if [ "$foreign" -gt 0 ]; then
|
||||||
|
echo "[storage-init] AVISO: $foreign item(ns) em $DATA_DIR com outro dono; nada foi alterado neles." >&2
|
||||||
|
echo "[storage-init] Para ajustar, rode com --fix-existing (procedimento de recuperação no doc 23)." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Prova com o usuário da aplicação, não com root.
|
||||||
|
if ! setpriv --reuid="$app_uid" --regid="$app_gid" --clear-groups \
|
||||||
|
sh -c 'p="$1/.write-probe-$$"; touch "$p" && rm -f "$p"' _ "$STORAGE_DIR"; then
|
||||||
|
echo "STORAGE_NOT_WRITABLE: o uid $app_uid não escreve em $STORAGE_DIR mesmo após a preparação." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[storage-init] STORAGE_WRITABLE: $STORAGE_DIR, dono $app_uid:$app_gid, modo 750"
|
||||||
|
}
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
uso: entrypoint.sh <comando> [args]
|
uso: entrypoint.sh <comando> [args]
|
||||||
@@ -23,6 +98,8 @@ uso: entrypoint.sh <comando> [args]
|
|||||||
check-bucket
|
check-bucket
|
||||||
smoke
|
smoke
|
||||||
worker-health confere o heartbeat do worker (healthcheck do contêiner)
|
worker-health confere o heartbeat do worker (healthcheck do contêiner)
|
||||||
|
prepare-storage [--fix-existing]
|
||||||
|
prepara /app/.data como root (serviço storage-init)
|
||||||
exec <cmd...> qualquer outro comando
|
exec <cmd...> qualquer outro comando
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -31,8 +108,9 @@ cmd="${1:-web}"
|
|||||||
[ $# -gt 0 ] && shift || true
|
[ $# -gt 0 ] && shift || true
|
||||||
|
|
||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
web) exec node server.js ;;
|
web) assert_storage_writable; exec node server.js ;;
|
||||||
worker) exec node $TSX scripts/worker.ts ;;
|
worker) assert_storage_writable; exec node $TSX scripts/worker.ts ;;
|
||||||
|
prepare-storage) prepare_storage "$@" ;;
|
||||||
migrate) exec node $TSX scripts/migrate.ts "$@" ;;
|
migrate) exec node $TSX scripts/migrate.ts "$@" ;;
|
||||||
seed-staging) exec node $TSX scripts/seed-staging.ts "$@" ;;
|
seed-staging) exec node $TSX scripts/seed-staging.ts "$@" ;;
|
||||||
flag) exec node $TSX scripts/set-flag.ts "$@" ;;
|
flag) exec node $TSX scripts/set-flag.ts "$@" ;;
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ Regra de preenchimento: **PASS** só com evidência executada. Sem infraestrutur
|
|||||||
| D9d | Worker relê o contexto do dono | PASS | flag do módulo desligada entre o enfileiramento e o tick: o job não é processado |
|
| D9d | Worker relê o contexto do dono | PASS | flag do módulo desligada entre o enfileiramento e o tick: o job não é processado |
|
||||||
| D10 | Worker recusa iniciar com migration pendente | PASS por construção | `pendingMigrations` no start; a execução real é PENDENTE |
|
| D10 | Worker recusa iniciar com migration pendente | PASS por construção | `pendingMigrations` no start; a execução real é PENDENTE |
|
||||||
| D11 | Desligamento gracioso do worker | PASS no loop / PENDENTE no sinal | `shutdown()` para de aceitar trabalho, espera o tick corrente sair, e o job que não começou fica `queued` com o lock reservado para ele; concluído, o lock é liberado. A **entrega do SIGTERM** ao processo é PENDENTE: no Windows não há sinal real, e o job `image` do CI confere `SIGTERM recebido`, `[worker] encerrado` e exit 0 em menos de 25 s |
|
| D11 | Desligamento gracioso do worker | PASS no loop / PENDENTE no sinal | `shutdown()` para de aceitar trabalho, espera o tick corrente sair, e o job que não começou fica `queued` com o lock reservado para ele; concluído, o lock é liberado. A **entrega do SIGTERM** ao processo é PENDENTE: no Windows não há sinal real, e o job `image` do CI confere `SIGTERM recebido`, `[worker] encerrado` e exit 0 em menos de 25 s |
|
||||||
|
| D19 | Storage local gravável pelo uid 1001 em volume novo e existente (local) | PASS | 16 out 2026, Docker Desktop/WSL2: `.github/scripts/storage-permissions.sh` casos A–F em volumes descartáveis. Corrige `STORAGE_NOT_WRITABLE` no upload. Ver [23-storage-permissions.md](23-storage-permissions.md). Não se aplica a homologação (bucket Supabase, D7) |
|
||||||
|
| D20 | Web e worker recusam subir com storage local sem escrita | PASS | mesmo script, caso F: saída com código 1 e `STORAGE_NOT_WRITABLE` |
|
||||||
| D17 | Heartbeat do worker e healthcheck | PASS | worker no layout de runtime gravou o heartbeat; `docker/worker-health.mjs` aprovou com 1 s e reprova quando o arquivo falta |
|
| D17 | Heartbeat do worker e healthcheck | PASS | worker no layout de runtime gravou o heartbeat; `docker/worker-health.mjs` aprovou com 1 s e reprova quando o arquivo falta |
|
||||||
| D12 | Download XLSX, CSV e PDF ponta a ponta | PASS no local / PENDENTE no ambiente | `tests/integration/journey.test.ts` cobre o fluxo; falta a jornada em homologação |
|
| D12 | Download XLSX, CSV e PDF ponta a ponta | PASS no local / PENDENTE no ambiente | `tests/integration/journey.test.ts` cobre o fluxo; falta a jornada em homologação |
|
||||||
| D13 | Modelo original não é alterado | PASS | `tests/unit/xlsx-fixtures.test.ts` compara bytes |
|
| D13 | Modelo original não é alterado | PASS | `tests/unit/xlsx-fixtures.test.ts` compara bytes |
|
||||||
@@ -121,7 +123,7 @@ Regra de preenchimento: **PASS** só com evidência executada. Sem infraestrutur
|
|||||||
|
|
||||||
| | Quantidade |
|
| | Quantidade |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| PASS com evidência executada | 54 |
|
| PASS com evidência executada | 56 |
|
||||||
| PENDENTE por falta de infraestrutura ou credencial | 20 |
|
| PENDENTE por falta de infraestrutura ou credencial | 20 |
|
||||||
| FAIL | 0 |
|
| FAIL | 0 |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Gitea Actions — diagnóstico e ativação do runner
|
||||||
|
|
||||||
|
Repositório `cferraz/adaptador-planilhas` em `gitea.fatorxtech.com`, Gitea **1.26.2**.
|
||||||
|
|
||||||
|
## Causa confirmada da ausência de execuções
|
||||||
|
|
||||||
|
**Os runs existem. O que não existe é runner para executá-los.** A falha está na **atribuição**, não no enfileiramento.
|
||||||
|
|
||||||
|
| Evidência | Resultado |
|
||||||
|
| --- | --- |
|
||||||
|
| `ci.yml` detectado por Gitea | sim, listado em Workflows |
|
||||||
|
| Gatilho de `ci.yml` | `on: push` sem filtro de branch — contempla `homologacao` |
|
||||||
|
| Actions no repositório | habilitado (`has_actions: true`) |
|
||||||
|
| Actions na instância | habilitado (a página `/actions` responde e lista workflows) |
|
||||||
|
| Runs criados pelos pushes | **sim**, três |
|
||||||
|
| Estado de todos | **Waiting**, duração `0s` |
|
||||||
|
| Mensagem do Gitea em cada um | **`No matching online runner with label: ubuntu-latest`** |
|
||||||
|
|
||||||
|
| Run | Workflow | Commit | Branch | Estado |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| [668](https://gitea.fatorxtech.com/cferraz/adaptador-planilhas/actions/runs/668) | `runner-check.yml #4` | `c5790ab301` | `homologacao` | **Waiting** |
|
||||||
|
| [667](https://gitea.fatorxtech.com/cferraz/adaptador-planilhas/actions/runs/667) | `ci.yml #3` | `c5790ab301` | `homologacao` | **Waiting** |
|
||||||
|
| [666](https://gitea.fatorxtech.com/cferraz/adaptador-planilhas/actions/runs/666) | `ci.yml #2` | `5b11496cd9` | `homologacao` | **Waiting** |
|
||||||
|
| [584](https://gitea.fatorxtech.com/cferraz/adaptador-planilhas/actions/runs/584) | `ci.yml #1` | `419325db12` | `main` | Canceled |
|
||||||
|
|
||||||
|
Listagem: https://gitea.fatorxtech.com/cferraz/adaptador-planilhas/actions
|
||||||
|
|
||||||
|
Dois detalhes observados na prática:
|
||||||
|
|
||||||
|
- a barra lateral de Workflows mostra só `ci.yml`, porque lista os workflows da branch padrão (`main`), onde `runner-check.yml` ainda não existe. Isso não impediu o run `668` de ser criado a partir de `homologacao`;
|
||||||
|
- **`concurrency` não tem efeito no Gitea 1.26.2.** O run `667` deveria ter cancelado o `666`, que está no mesmo grupo, e não cancelou. A fila de runs em `Waiting` vai crescer a cada push enquanto não houver runner. Não é problema: quando o runner subir, cancele os antigos pela interface ou deixe-os rodar em sequência.
|
||||||
|
|
||||||
|
### Por que a API parecia dizer "zero execuções"
|
||||||
|
|
||||||
|
`GET /api/v1/repos/cferraz/adaptador-planilhas/actions/tasks` devolve `{"workflow_runs":[],"total_count":0}`. Esse endpoint lista **tasks**, isto é, jobs que um runner aceitou. Nenhum runner aceitou nada, então a lista é vazia — mas os runs estão criados e esperando. `GET /actions/runs` responde `token is required`, e foi por isso que a primeira leitura passou a impressão errada.
|
||||||
|
|
||||||
|
**Consequência prática: nada precisa ser republicado.** O run `666` está em `Waiting` e começa sozinho no momento em que um runner com a label compatível ficar online.
|
||||||
|
|
||||||
|
## Runners encontrados e labels
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Runners com label `ubuntu-latest` **online** | **nenhum** (é o que o Gitea reporta) |
|
||||||
|
| Outros runners na instância | **não determinado** |
|
||||||
|
|
||||||
|
Listar runners exige token de owner ou admin: `GET /api/v1/repos/{owner}/{repo}/actions/runners`, ou a interface em **Settings → Actions → Runners** (nível repositório, organização ou admin). Os números de run (`584`, `666`) sugerem que a instância já teve atividade de Actions em outros repositórios, então **é possível que existam runners com outra label**. Confira antes de instalar um novo:
|
||||||
|
|
||||||
|
- se já houver runner Linux com Docker e outra label (`linux_amd64`, `ubuntu-22.04`, `docker`), **não instale nada**: basta ajustar o `runs-on` dos workflows. Diga qual é a label e a troca é de uma linha em três arquivos;
|
||||||
|
- se não houver, siga a instalação abaixo.
|
||||||
|
|
||||||
|
## Configuração recomendada do runner
|
||||||
|
|
||||||
|
Máquina Linux **dedicada a homologação**, sem nenhuma relação com o Docker de produção do Elo Pro.
|
||||||
|
|
||||||
|
### Por que Docker-in-Docker, e não o socket do host
|
||||||
|
|
||||||
|
O repositório é **público**. Montar `/var/run/docker.sock` do host dá ao workflow controle do Docker da máquina — e, num repositório público, código de terceiro poderia chegar lá por pull request de fork. A imagem `-dind-rootless` sobe um Docker próprio dentro do contêiner do runner: o job `image` constrói e executa contêineres sem alcançar o Docker do host.
|
||||||
|
|
||||||
|
### Instalação
|
||||||
|
|
||||||
|
Versão fixada, compatível com o Gitea 1.26.2 (`0.6.1` é a mais recente publicada; as alternativas semver são `0.1.4` a `0.6.1`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Token de registro: Settings → Actions → Runners → Create new runner,
|
||||||
|
# no NÍVEL DO REPOSITÓRIO (escopo restrito, não use token de admin).
|
||||||
|
# Grave o token num arquivo com permissão 600 — nunca em commit, nunca em log.
|
||||||
|
umask 077
|
||||||
|
printf '%s' 'COLE_O_TOKEN_AQUI' > /etc/gitea-runner.token
|
||||||
|
|
||||||
|
# 2. Suba o runner com Docker-in-Docker rootless.
|
||||||
|
docker run -d \
|
||||||
|
--name gitea-runner-hml \
|
||||||
|
--restart always \
|
||||||
|
--privileged \
|
||||||
|
-e GITEA_INSTANCE_URL=https://gitea.fatorxtech.com \
|
||||||
|
-e GITEA_RUNNER_REGISTRATION_TOKEN_FILE=/run/secrets/token \
|
||||||
|
-e GITEA_RUNNER_NAME=hml-adaptador \
|
||||||
|
-e GITEA_RUNNER_LABELS='ubuntu-latest:docker://node:24-bookworm' \
|
||||||
|
-v /etc/gitea-runner.token:/run/secrets/token:ro \
|
||||||
|
-v gitea-runner-hml-data:/data \
|
||||||
|
docker.io/gitea/act_runner:0.6.1-dind-rootless
|
||||||
|
```
|
||||||
|
|
||||||
|
Notas que importam:
|
||||||
|
|
||||||
|
- `GITEA_RUNNER_REGISTRATION_TOKEN_FILE` em vez da variável com o valor: o token não aparece em `docker inspect` nem no histórico do shell.
|
||||||
|
- `--privileged` é exigência do Docker-in-Docker. É justamente por isso que a máquina precisa ser dedicada.
|
||||||
|
- A label **tem de ser `ubuntu-latest`**, que é o `runs-on` dos três jobs. O mapeamento `docker://node:24-bookworm` já entrega `node` e `npm` na versão certa.
|
||||||
|
- `psql` e `python3` não vêm nessa imagem. O job `services` usa `psql` e a jornada assíncrona usa `python3`. Duas saídas: trocar o mapeamento para uma imagem que os tenha, ou deixar que o `runner-check` reporte a ausência e então instalá-los num passo do workflow. Rode o `runner-check` primeiro: ele faz esse inventário.
|
||||||
|
- Rede de saída necessária: `gitea.fatorxtech.com` (fila e logs), `github.com` (o Gitea resolve `actions/checkout@v4`, `actions/setup-node@v4` e `docker/*` via proxy), `registry.npmjs.org` e `docker.io`.
|
||||||
|
|
||||||
|
### Conferir que subiu
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs -f gitea-runner-hml # espere "runner registered successfully"
|
||||||
|
```
|
||||||
|
|
||||||
|
Depois, em **Settings → Actions → Runners**, o runner aparece online com a label `ubuntu-latest`. O run `666`, que está em `Waiting`, começa sozinho.
|
||||||
|
|
||||||
|
## Ordem de validação
|
||||||
|
|
||||||
|
Nenhum destes foi executado ainda. Não marque nada como aprovado sem o log.
|
||||||
|
|
||||||
|
1. **`runner-check`** — dispare manualmente. Não faz checkout, não instala nada, não usa segredo. Confirma a ligação e inventaria `node`, `npm`, `docker`, `psql`, `python3`.
|
||||||
|
2. **`verify`** — lint, typecheck, 102 testes, build.
|
||||||
|
3. **`services`** — Postgres e Redis efêmeros: as quatro migrations, idempotência, `--dry-run` sem efeito, shim recusado com `ADAPTER_APPLY_IDENTITY_SHIM=0`, `app_user` sem `BYPASSRLS`, `FORCE RLS`, isolamento entre workspaces, auditoria não alterável.
|
||||||
|
4. **`image`** — build Linux, conteúdo da imagem, usuário não-root, ausência de `.env`/docs/testes, os oito scripts operacionais transformando e executando, fail-fast com código diferente de zero na web e no worker, liveness com cabeçalhos, compose até `ready`, heartbeat do worker, jornada assíncrona entre processos e desligamento gracioso exigindo `SIGTERM recebido` em menos de 25 s.
|
||||||
|
|
||||||
|
## Segurança revisada
|
||||||
|
|
||||||
|
| Risco | Estado |
|
||||||
|
| --- | --- |
|
||||||
|
| Repositório **público** com Actions | `ci.yml`, `runner-check` e os três jobs têm guarda: PR de fork **não executa** (`head.repo.full_name == github.repository`). Push continua rodando |
|
||||||
|
| Código não confiável com acesso privilegiado | O runner usa Docker-in-Docker rootless, sem o socket do host. A máquina é dedicada e não alcança o Docker de produção |
|
||||||
|
| Deploy automático | `deploy-staging.yml` tem gatilho único `workflow_dispatch`. Nenhum push, tag ou merge dispara deploy |
|
||||||
|
| Aprovação manual de deploy | Os ambientes `hml-migracao`, `hml-deploy` e `hml-flag` são conceito do GitHub. **O Gitea não os implementa da mesma forma**: se o deploy for rodar aqui, as aprovações deixam de existir e isso precisa ser resolvido antes de apontar o workflow ao servidor |
|
||||||
|
| Permissão do token de Actions | `permissions: contents: read` nos dois workflows |
|
||||||
|
| Fila de runs esperando runner | `concurrency` por ref declarado, **sem efeito no Gitea 1.26.2** (verificado: o run 667 não cancelou o 666). Cancele os antigos pela interface quando o runner subir |
|
||||||
|
| SSH de produção | **não configurado**. Nenhum segredo de produção foi adicionado |
|
||||||
|
| Branch `main` | **preservada** em `419325d` |
|
||||||
|
| Token de registro do runner | por arquivo com permissão 600, fora de commit e de log |
|
||||||
|
|
||||||
|
Vale decidir também se este repositório deve seguir público. Auditei os commits e não há segredo algum — só modelos com campos vazios — mas um repositório privado remove de uma vez o risco de PR de fork.
|
||||||
|
|
||||||
|
## Pendências
|
||||||
|
|
||||||
|
**Para o CI rodar:** um runner Linux online com a label `ubuntu-latest`, Docker-in-Docker e saída de rede. Nada além disso.
|
||||||
|
|
||||||
|
**Para homologação, depois do CI verde:** projeto Supabase de homologação com contas de teste e bucket privado; `DATABASE_URL` e a decisão do `ADAPTER_APPLY_IDENTITY_SHIM`; Redis com senha; DSN do Sentry; DNS, TLS e Nginx de `hml-ada.elopro.app`; smoke real e ensaio de rollback. O procedimento está em [`20-homologacao-runbook.md`](20-homologacao-runbook.md) e o estado item a item em [`21-checklist-validacao.md`](21-checklist-validacao.md).
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Permissões do armazenamento local em Docker
|
||||||
|
|
||||||
|
Escopo: `docker-compose.yml` (desenvolvimento local). Homologação e produção usam o bucket privado do Supabase e não montam volume em `/app/.data` — `docker-compose.staging.yml` não foi alterado.
|
||||||
|
|
||||||
|
## Problema
|
||||||
|
|
||||||
|
Cadastro de `modelo_imoveis_teste.xlsx` no compose local respondeu `Não foi possível continuar — Erro interno.`
|
||||||
|
|
||||||
|
```
|
||||||
|
uid=1001(adaptador) gid=1001(adaptador)
|
||||||
|
drwxr-xr-x 2 root root /app/.data
|
||||||
|
STORAGE_NOT_WRITABLE
|
||||||
|
```
|
||||||
|
|
||||||
|
Contornado à mão no volume local com `chown 1001:1001` e `chmod 750` em `/app/.data`, depois `STORAGE_WRITABLE`. Esta correção elimina o passo manual.
|
||||||
|
|
||||||
|
## Causa raiz
|
||||||
|
|
||||||
|
1. A imagem não continha `/app/.data`. `LocalPrivateStorage` grava em `/app/.data/storage` e só cria o diretório em runtime.
|
||||||
|
2. Quando um volume nomeado é montado num caminho que não existe na imagem, o Docker cria o ponto de montagem como `root:root 755`. O volume vazio não tem de onde herdar dono.
|
||||||
|
3. Web e worker rodam como `adaptador` (1001) sem capacidades. Não escrevem num diretório de root.
|
||||||
|
4. O health check do storage local só fazia `mkdir -p`, que passa quando o diretório já existe. O processo subia saudável e o erro só aparecia no primeiro upload, como 500 genérico.
|
||||||
|
|
||||||
|
Reproduzido num volume descartável com a imagem anterior: `drwxr-xr-x 0 0 /app/.data` e `touch: Permission denied`.
|
||||||
|
|
||||||
|
## Correção
|
||||||
|
|
||||||
|
Duas camadas, porque `RUN chown` no Dockerfile sozinho não alcança volume que já existe.
|
||||||
|
|
||||||
|
| Camada | Arquivo | O que faz | Alcança |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Imagem | `Dockerfile` | Cria `/app/.data/storage` com dono `adaptador` e modo `750`. Um volume nomeado **vazio** herda dono e modo na primeira montagem | Volumes novos |
|
||||||
|
| Inicialização | `docker-compose.yml` (`storage-init`) + `docker/entrypoint.sh prepare-storage` | Serviço de uma execução, antes de web e worker, que ajusta **só** `/app/.data` e `/app/.data/storage` e prova a escrita como uid 1001 | Volumes existentes, inclusive os criados antes desta correção |
|
||||||
|
| Falha segura | `docker/entrypoint.sh` (`web`, `worker`) | Antes de iniciar, grava e apaga um arquivo de prova em `/app/.data/storage`. Sem escrita, sai com código 1 e `STORAGE_NOT_WRITABLE` | Qualquer ambiente que use o storage local |
|
||||||
|
|
||||||
|
Alternativa A (preparar dentro do entrypoint de web e worker) foi descartada: exigiria iniciar os processos como root e rebaixar o usuário depois. A alternativa B mantém web e worker exatamente como antes — `USER adaptador`, sem capacidades — e concentra o único passo privilegiado num contêiner que termina.
|
||||||
|
|
||||||
|
## Fluxo de inicialização
|
||||||
|
|
||||||
|
```
|
||||||
|
postgres (healthy) ─┐
|
||||||
|
redis (healthy) ────┤
|
||||||
|
storage-init ───────┼─ completed_successfully ─► web ─► worker
|
||||||
|
(root, sem rede, │
|
||||||
|
termina) │
|
||||||
|
```
|
||||||
|
|
||||||
|
`storage-init`:
|
||||||
|
|
||||||
|
- `user: "0:0"`, `network_mode: none`, `cap_drop: [ALL]` e só `CHOWN`, `FOWNER`, `DAC_OVERRIDE`, `DAC_READ_SEARCH`, `SETUID`, `SETGID`; `no-new-privileges`.
|
||||||
|
- Recusa link simbólico em `/app/.data` ou `/app/.data/storage`.
|
||||||
|
- Muda dono/modo somente quando diferem de `1001:1001 750`, e registra cada mudança.
|
||||||
|
- **Não é recursivo.** Itens com outro dono dentro do volume são contados e reportados, não alterados.
|
||||||
|
- Prova a escrita com `setpriv --reuid 1001` e falha com `STORAGE_NOT_WRITABLE` se não conseguir. Falha do `storage-init` impede web e worker de subir.
|
||||||
|
- UID/GID vêm de `id -u adaptador` na própria imagem, a mesma fonte do `USER` do Dockerfile.
|
||||||
|
|
||||||
|
## Permissões esperadas
|
||||||
|
|
||||||
|
| Caminho | Dono | Modo |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/app/.data` | `1001:1001` | `750` |
|
||||||
|
| `/app/.data/storage` | `1001:1001` | `750` |
|
||||||
|
| Subpastas e arquivos criados pela aplicação | `1001:1001` | padrão do processo (`755`/`644`), protegidos pelo `750` acima |
|
||||||
|
|
||||||
|
| Processo | Usuário | Capacidades efetivas |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| web | `adaptador` (1001) | nenhuma |
|
||||||
|
| worker | `adaptador` (1001) | nenhuma |
|
||||||
|
| storage-init | root, só durante a preparação | as seis listadas acima |
|
||||||
|
|
||||||
|
Web e worker montam o mesmo volume `adaptador-planilhas-local-storage` em `/app/.data`. O volume não é servido por HTTP: o download continua passando pela rota autenticada.
|
||||||
|
|
||||||
|
## Volume novo × existente
|
||||||
|
|
||||||
|
- **Novo:** herda `1001:1001 750` da imagem; `storage-init` só confirma e não altera nada.
|
||||||
|
- **Existente com raiz de root:** `storage-init` ajusta os dois diretórios. Conteúdo, dono e modo dos arquivos existentes ficam como estavam.
|
||||||
|
- **Existente já corrigido à mão:** nenhuma mudança de dono; o modo de `/app/.data/storage` passa de `755` para `750` na primeira execução.
|
||||||
|
|
||||||
|
## Validação
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --parallel 1 build web
|
||||||
|
ADAPTER_IMAGE=adaptador-planilhas:local bash .github/scripts/storage-permissions.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
O script usa apenas volumes e contêineres descartáveis com prefixo `adaptador-storage-test-` e remove só o que criou. Casos:
|
||||||
|
|
||||||
|
| Caso | Verifica |
|
||||||
|
| --- | --- |
|
||||||
|
| A | Volume novo: `1001:1001 750` sem `storage-init`, escrita pelo uid 1001, `STORAGE_WRITABLE` |
|
||||||
|
| B | Volume com raiz `root:root 755` e arquivos: diretórios corrigidos, conteúdo e dono dos arquivos intactos, item de root apenas reportado |
|
||||||
|
| C | Duas execuções extras: snapshot de dono, modo, tamanho e SHA-256 idêntico; nenhuma mudança registrada |
|
||||||
|
| C2 | `--fix-existing` ajusta o dono só quando pedido e preserva o conteúdo |
|
||||||
|
| D | Um contêiner grava, outro lê e apaga; `web` real sobe e responde liveness; `worker` real passa da verificação |
|
||||||
|
| E | web como 1001 com `CapEff` zero; imagem com `USER adaptador`; `prepare-storage` recusa rodar sem root |
|
||||||
|
| F | Volume sem escrita: `web` e `worker` saem com código 1 e `STORAGE_NOT_WRITABLE`; com Supabase configurado a verificação não bloqueia |
|
||||||
|
|
||||||
|
O job `image` do CI executa o mesmo script.
|
||||||
|
|
||||||
|
## Recuperação
|
||||||
|
|
||||||
|
Se `storage-init` reportar `AVISO: N item(ns) ... com outro dono`, os arquivos continuam íntegros, mas a aplicação pode não conseguir sobrescrevê-los ou apagá-los. Ajuste controlado, limitado ao volume, sem seguir link simbólico e sem atravessar outro sistema de arquivos — **execute só com autorização**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm storage-init prepare-storage --fix-existing
|
||||||
|
```
|
||||||
|
|
||||||
|
Se `storage-init` falhar com `STORAGE_NOT_WRITABLE`: confira `docker compose logs storage-init`. Causas prováveis são volume montado somente leitura ou driver de volume que não aceita `chown`. Não use `chmod 777` nem rode web/worker como root.
|
||||||
|
|
||||||
|
## Limitações
|
||||||
|
|
||||||
|
- Bind mount (`./pasta:/app/.data`) não herda dono da imagem. No Docker Desktop/WSL2 sobre o disco do Windows o `chown` pode não ter efeito; a falha segura impede o processo de subir em silêncio. O compose usa volume nomeado.
|
||||||
|
- `--fix-existing` é recursivo dentro do volume e por isso é manual.
|
||||||
|
- O ajuste do volume local real `adaptador-planilhas-local-storage` acontece quando os contêineres forem recriados com o compose novo. Esta validação não recriou os contêineres em execução.
|
||||||
|
- Não valida homologação: o storage de homologação é o bucket privado do Supabase (item D7 do checklist continua PENDENTE).
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Smoke local do compose de desenvolvimento
|
||||||
|
|
||||||
|
Smoke do ambiente Docker local. Confere o Adaptador depois de atualizar código, reinstalar dependências ou recriar os contêineres, sem Supabase de homologação.
|
||||||
|
|
||||||
|
Não altera `scripts/smoke-staging.ts` nem o comando `npm run smoke:staging`.
|
||||||
|
|
||||||
|
## Separação
|
||||||
|
|
||||||
|
| | Smoke local | Smoke de homologação |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Comando | `npm run smoke:local` | `npm run smoke:staging` |
|
||||||
|
| Script | `scripts/smoke-local.ts` | `scripts/smoke-staging.ts` |
|
||||||
|
| Alvo | `http://127.0.0.1:3000` (`ADAPTER_SMOKE_LOCAL_URL` só se for HTTP em loopback) | `ADAPTER_SMOKE_URL` público |
|
||||||
|
| Identidade | `POST /api/dev/session` com as fixtures de desenvolvimento | Supabase Auth de homologação |
|
||||||
|
| Banco | Postgres 16 do `docker-compose.yml`, publicado em loopback | Banco de homologação |
|
||||||
|
| Jornada | modelo XLSX, upload, mapeamento, prévia, worker, XLSX, CSV e PDF | fase 1 anônima; fase 2 só leitura autenticada. Upload e download continuam no runbook |
|
||||||
|
| Resultado | evidência do compose local | evidência de `hml-ada.elopro.app` |
|
||||||
|
|
||||||
|
Um PASS local não preenche nem aprova os gates de homologação externa do [checklist](21-checklist-validacao.md): deploy real (F7), smoke de homologação (F10) e ensaio de rollback (F11) continuam pendentes até rodarem no ambiente externo.
|
||||||
|
|
||||||
|
## Quando rodar
|
||||||
|
|
||||||
|
Com o compose local no ar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
docker compose exec web /app/docker/entrypoint.sh migrate
|
||||||
|
npm run smoke:local
|
||||||
|
```
|
||||||
|
|
||||||
|
A migration é um passo anterior e manual. O smoke não executa `migrate`, `seed`, `seed:staging` nem `flag`.
|
||||||
|
|
||||||
|
## O que verifica
|
||||||
|
|
||||||
|
1. `GET /api/health/ready` com HTTP 200 e `status=ready`.
|
||||||
|
2. `checks.database=ok`.
|
||||||
|
3. `checks.migrations=ok`, sem ids pendentes. Se houver migration pendente, a jornada não grava.
|
||||||
|
4. `checks.redis=ok`.
|
||||||
|
5. Leitura e escrita no volume privado: o contêiner `web` grava, relê e apaga um arquivo em `/app/.data/storage/_smoke-local`. O health de storage só cria diretório; esta prova é separada.
|
||||||
|
6. `ADAPTER_JOB_MODE=worker` nos contêineres `web` e `worker`.
|
||||||
|
7. Sessão local emitida e `POST /api/auth/staging/session` respondendo 404.
|
||||||
|
8. Ana enxerga só o workspace Alpha.
|
||||||
|
9. `GET /api/spreadsheet-adapter/models` autenticado.
|
||||||
|
10. Cadastro de um XLSX fictício, upload, mapeamento, prévia, fila do worker e download XLSX, CSV e PDF pelos contratos reais em `src/app/api/spreadsheet-adapter/`.
|
||||||
|
11. Conteúdo da sentinela fictícia nos três formatos e hash do modelo original inalterado. No PDF a sentinela é lida nos operadores de texto do arquivo gerado: a fonte padrão grava os caracteres em hexadecimal, não como texto solto.
|
||||||
|
12. Anônimo recebe 401; Diego, no workspace Beta com o módulo desligado, recebe 403 `FEATURE_DISABLED`; Ana não seleciona Beta; Diego não lê, baixa nem exclui o recurso de Alpha; corpo com `workspace_id` de Beta recebe 403 `WORKSPACE_FORBIDDEN`.
|
||||||
|
13. Logs de `web` e `worker` durante a execução, mais respostas HTTP 5xx.
|
||||||
|
|
||||||
|
Os dados são colunas `Nome` e `Codigo` e um valor `SMOKELOCAL` mais um sufixo da execução. Não há planilha real.
|
||||||
|
|
||||||
|
## Segurança da gravação
|
||||||
|
|
||||||
|
Antes de cadastrar o modelo, o script:
|
||||||
|
|
||||||
|
- recusa `ADAPTER_ENV=staging` ou `production` no processo;
|
||||||
|
- recusa URL que não seja HTTP em loopback;
|
||||||
|
- interrompe se `/api/health/ready` reportar outro ambiente;
|
||||||
|
- lê `DATABASE_URL` só dentro do contêiner `web` e só aceita host `postgres` e database `adaptador`;
|
||||||
|
- conecta no host pela porta publicada por `docker compose port`, e só se o bind for loopback;
|
||||||
|
- exige role com `rolsuper` ou `rolbypassrls`, porque a RLS força política e não há `DELETE` de modelo ou de job para `app_user`;
|
||||||
|
- fotografa modelos, jobs, feature flags e configuração de upload.
|
||||||
|
|
||||||
|
A limpeza, na mesma transação, apaga somente o modelo e as operações criados nesta execução (nome, ids e autor conferidos, ids anteriores excluídos da cláusula). Auditoria removida é só a da Ana, posterior ao início da gravação, ligada a esses ids ou à tentativa de workspace forjado. Se a contagem de auditoria alheia mudar, a transação é desfeita.
|
||||||
|
|
||||||
|
Os arquivos apagados no volume são só as chaves `original`, `source`, `out.csv` e `out.xlsx` desse modelo e dessa operação. O script não roda `docker compose down` nem remove volume.
|
||||||
|
|
||||||
|
A feature flag não é ligada, desligada nem restaurada. Se ela mudar durante a execução, o item correspondente falha.
|
||||||
|
|
||||||
|
Se a limpeza não puder ser garantida, os itens de gravação ficam **PENDENTE** e não entram na conta de aprovados. Código de saída diferente de zero ocorre quando uma verificação executada falha. Pendência sozinha não reprova o processo.
|
||||||
|
|
||||||
|
## Relatório
|
||||||
|
|
||||||
|
Ao terminar, o script imprime executados, aprovados, reprovados, pendentes, tempo, o estado de XLSX, CSV e PDF, as falhas e as pendências.
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Ensaio local de rollback de imagem
|
||||||
|
|
||||||
|
Ensaio descartável feito em 17 de outubro de 2026, nesta máquina, no projeto Docker `adaptador-rollback-ensaio`. Não é o rollback da homologação nem o da produção da Elo Pro.
|
||||||
|
|
||||||
|
O gate F11 do [checklist](21-checklist-validacao.md) continua **pendente**. Ele exige duas imagens publicadas e o ambiente `hml-ada.elopro.app`. O procedimento de lá está na seção 10 de [20-homologacao-runbook.md](20-homologacao-runbook.md). Este documento não o aprova.
|
||||||
|
|
||||||
|
## O que foi comparado
|
||||||
|
|
||||||
|
| | Atual | Anterior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Commit | `bd0ecabf41330d8e48dbe070cb090b25fd6eb64c` | `2be97ea9694eb3fe44e125fbd2ab3e13d8cc488a` |
|
||||||
|
| Tag da imagem | `adaptador-planilhas:bd0ecab` | `adaptador-planilhas:2be97ea` |
|
||||||
|
| Id da imagem | `sha256:bc06e875c96b2a1f28b406fd0512e40b37399550355f204d32d77ae766db9eed` | `sha256:edc36b8532b172ea940ed6dd252e2f965a91694366adb5828e87f7c72dc520d9` |
|
||||||
|
|
||||||
|
`git diff 2be97ea bd0ecab -- src migrations Dockerfile docker docker-compose.yml` não tem diferença. O commit atual só acrescenta o smoke local, o `package.json`, o `tsconfig.json` e a documentação. As quatro migrations (`0001` a `0004`) são as mesmas. Por isso a imagem anterior convive com o schema aplicado pela imagem atual. Não houve downgrade de migration.
|
||||||
|
|
||||||
|
A tag `adaptador-planilhas:local` do compose principal não foi reescrita. Antes e depois do ensaio ela continuou `sha256:1efe0dfab0a80518563bd8ac2905466eca5692c05a26818a397551fffc05f1fa`, com web, worker, Postgres e Redis principais saudáveis. O banco `adaptador` seguiu com 1 modelo e as flags `spreadsheet_adapter` Alpha ligada e Beta desligada. O comando `flag` não foi executado.
|
||||||
|
|
||||||
|
## Isolamento
|
||||||
|
|
||||||
|
Arquivo: `docker-compose.rollback-local.yml`.
|
||||||
|
|
||||||
|
| Recurso | Nome | Publicado no host |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Projeto | `adaptador-rollback-ensaio` | não |
|
||||||
|
| Rede | `adaptador-rollback-ensaio` | não |
|
||||||
|
| Volume Postgres | `adaptador-rollback-ensaio-postgres` | não |
|
||||||
|
| Volume storage | `adaptador-rollback-ensaio-storage` | não |
|
||||||
|
| Banco | `adaptador_rollback` | não |
|
||||||
|
| Redis | interno | não |
|
||||||
|
| Web | `127.0.0.1:3310` | só loopback |
|
||||||
|
|
||||||
|
Não há `build:` nesse compose. Sem `ADAPTER_IMAGE` ele recusa subir, para não cair na tag `:local`. Postgres e Redis não têm porta no host. A sonda `scripts/rollback-local-probe.ts` só aceita `http://127.0.0.1:3310`.
|
||||||
|
|
||||||
|
Os volumes `adaptador-planilhas-local-postgres` e `adaptador-planilhas-local-storage` não entraram no ensaio. Não houve `docker compose down -v` no projeto principal, nem `git reset`.
|
||||||
|
|
||||||
|
## Comandos executados
|
||||||
|
|
||||||
|
Conferência de compatibilidade, sem trocar de branch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --stat 2be97ea bd0ecab -- src migrations Dockerfile docker docker-compose.yml
|
||||||
|
git worktree add --detach "$TEMP/adaptador-2be97ea-src" 2be97ea
|
||||||
|
```
|
||||||
|
|
||||||
|
Imagens, sem mover a tag `:local`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t adaptador-planilhas:bd0ecab --build-arg SENTRY_RELEASE=bd0ecabf41330d8e48dbe070cb090b25fd6eb64c --target runner .
|
||||||
|
docker build -t adaptador-planilhas:2be97ea --build-arg SENTRY_RELEASE=2be97eaf --target runner "$TEMP/adaptador-2be97ea-src"
|
||||||
|
```
|
||||||
|
|
||||||
|
Subida do ensaio e migration única, com a imagem atual:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ADAPTER_IMAGE=adaptador-planilhas:bd0ecab
|
||||||
|
docker compose -p adaptador-rollback-ensaio -f docker-compose.rollback-local.yml up -d postgres redis storage-init
|
||||||
|
docker compose -p adaptador-rollback-ensaio -f docker-compose.rollback-local.yml run --rm --no-deps web migrate
|
||||||
|
docker compose -p adaptador-rollback-ensaio -f docker-compose.rollback-local.yml up -d web worker
|
||||||
|
```
|
||||||
|
|
||||||
|
A migration só rodou depois do Postgres saudável. Aplicou `0001_platform_shim.sql`, `0002_spreadsheet_adapter.sql`, `0003_rls.sql` e `0004_audit_append_only.sql`. Não existe passo de down. `0003` e `0004` continuam sem caminho de volta, como no runbook.
|
||||||
|
|
||||||
|
Jornada fictícia:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --import=tsx scripts/rollback-local-probe.ts seed
|
||||||
|
```
|
||||||
|
|
||||||
|
Troca só de web e worker, volumes intactos:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -p adaptador-rollback-ensaio -f docker-compose.rollback-local.yml stop -t 30 web worker
|
||||||
|
export ADAPTER_IMAGE=adaptador-planilhas:2be97ea
|
||||||
|
docker compose -p adaptador-rollback-ensaio -f docker-compose.rollback-local.yml up -d --no-deps --force-recreate web worker
|
||||||
|
```
|
||||||
|
|
||||||
|
Volta para a imagem atual, do mesmo jeito, com `ADAPTER_IMAGE=adaptador-planilhas:bd0ecab`.
|
||||||
|
|
||||||
|
Depois das evidências, só o projeto do ensaio foi removido:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -p adaptador-rollback-ensaio -f docker-compose.rollback-local.yml down -v
|
||||||
|
git worktree remove --force "$TEMP/adaptador-2be97ea-src"
|
||||||
|
```
|
||||||
|
|
||||||
|
Antes do `down -v`, os nomes listados eram apenas `adaptador-rollback-ensaio`, `adaptador-rollback-ensaio-postgres` e `adaptador-rollback-ensaio-storage`. As imagens `:bd0ecab` e `:2be97ea` foram mantidas. A tag `:local` não.
|
||||||
|
|
||||||
|
## Resultado
|
||||||
|
|
||||||
|
Prontidão da imagem atual, antes da troca: HTTP 200, `status=ready`, `environment=development`, e `config`, `database`, `migrations`, `redis`, `storage` e `job_mode` em `ok`.
|
||||||
|
|
||||||
|
Dados fictícios gravados:
|
||||||
|
|
||||||
|
| Campo | Valor |
|
||||||
|
| --- | --- |
|
||||||
|
| Modelo | `52add4ba-88b5-4611-b8d7-4ffe33273d3e` |
|
||||||
|
| Operação | `ee5fb92f-57ba-4414-a7ab-b9635e243aea` |
|
||||||
|
| Fonte | `eabec970-e86d-4b39-8c76-c2b5d10b4674` |
|
||||||
|
| SHA-256 do XLSX original | `74a4130f7712f94727c6bbf8708b9e77fd34e61c7504da8b84be309cbd6d9559` |
|
||||||
|
| SHA-256 do XLSX exportado | `89abbca277ea2569bf63ac5cffcd581181b5b6e97d22a285cf21786e175608dc` |
|
||||||
|
| SHA-256 do CSV exportado | `199e83c30d6fbae7174a4ecd563d32e9ece19a677209e79e88bb56116a6b8977` |
|
||||||
|
|
||||||
|
O mesmo SHA-256 do original foi lido no volume pela imagem atual e, depois da troca, pela imagem `2be97ea`. O `content_sha256` no banco isolado coincidiu. `schema_migrations` permaneceu nas quatro migrations. As flags do banco isolado nasceram do login local de desenvolvimento (Alpha ligada, Beta desligada) e não mudaram na troca de imagem.
|
||||||
|
|
||||||
|
Na imagem anterior e de novo na atual:
|
||||||
|
|
||||||
|
- `GET /api/health/ready` voltou `ready`.
|
||||||
|
- O modelo foi encontrado pelo id e pelo nome.
|
||||||
|
- A operação permaneceu `completed`.
|
||||||
|
- Os bytes armazenados de XLSX e CSV repetiram o SHA-256.
|
||||||
|
- O PDF voltou HTTP 200. O SHA-256 do PDF não se repete: ele é gerado no download, não fica gravado. Isso ocorreu nas duas imagens, não é incompatibilidade de rollback.
|
||||||
|
- O worker da imagem anterior registrou `[worker] pronto`. Depois da restauração, o healthcheck do worker ficou `healthy`.
|
||||||
|
|
||||||
|
Tempo observado da troca, do `stop` até o `ready`, no ensaio local: cerca de 3 s na ida para `2be97ea` e 2,8 s na volta para `bd0ecab`. Esse número não descreve pull de registry, Nginx nem a flag do piloto.
|
||||||
|
|
||||||
|
Nenhuma incompatibilidade de schema apareceu. O rollback não foi interrompido.
|
||||||
|
|
||||||
|
## Como repetir a recuperação
|
||||||
|
|
||||||
|
1. Anote a imagem que está no ar. No ensaio, o retorno é `adaptador-planilhas:bd0ecab`.
|
||||||
|
2. Pare só `web` e `worker` do projeto isolado, com `stop -t 30`.
|
||||||
|
3. Aponte `ADAPTER_IMAGE` para a tag do commit desejado.
|
||||||
|
4. Suba de novo com `up -d --no-deps --force-recreate web worker`.
|
||||||
|
5. Não rode migration de down. Se `migrations` não ficar `ok`, ou se o processo não subir, interrompa e volte para a tag atual pelo mesmo caminho. Schema incompatível é operação planejada, com backup, não rollback às pressas.
|
||||||
|
6. Confira `/api/health/ready`, o modelo, o SHA-256 do original no volume e o download do XLSX e do CSV.
|
||||||
|
|
||||||
|
Na homologação real o equivalente está no runbook: tag `staging-<sha>`, `up -d --no-deps web worker`, e a flag do módulo é outro passo, feito com `flag`, antes da troca e só religada depois da saúde. Este ensaio não fez isso e não deve ser copiado para o servidor compartilhado.
|
||||||
|
|
||||||
|
## Limitações
|
||||||
|
|
||||||
|
- As duas imagens executam o mesmo código de aplicação. O ensaio prova a troca de imagem com volume e schema preservados. Não prova um rollback atravessando uma migration nova.
|
||||||
|
- Não usa Supabase, SSO, Sentry nem bucket da Elo Pro.
|
||||||
|
- A flag do piloto não foi ligada nem desligada.
|
||||||
|
- O PDF não serve como hash de integridade entre downloads.
|
||||||
|
- O tempo de 3 s é só o recreate local das duas imagens já presentes no Docker.
|
||||||
+47
-2
@@ -93,12 +93,15 @@ returns boolean
|
|||||||
language sql
|
language sql
|
||||||
stable
|
stable
|
||||||
security definer
|
security definer
|
||||||
set search_path = public
|
set search_path = pg_catalog
|
||||||
as $$
|
as $$
|
||||||
select exists (
|
select exists (
|
||||||
select 1 from public.spreadsheet_jobs j
|
select 1
|
||||||
|
from public.spreadsheet_jobs j
|
||||||
|
join public.spreadsheet_models m on m.id = j.model_id
|
||||||
where j.model_id = model
|
where j.model_id = model
|
||||||
and j.status in ('validating', 'ready', 'queued', 'processing')
|
and j.status in ('validating', 'ready', 'queued', 'processing')
|
||||||
|
and public.is_member(m.workspace_id)
|
||||||
)
|
)
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
@@ -208,14 +211,17 @@ create policy models_update on public.spreadsheet_models
|
|||||||
with check (public.can_manage_adapter(workspace_id));
|
with check (public.can_manage_adapter(workspace_id));
|
||||||
|
|
||||||
drop policy if exists jobs_all on public.spreadsheet_jobs;
|
drop policy if exists jobs_all on public.spreadsheet_jobs;
|
||||||
|
drop policy if exists jobs_select on public.spreadsheet_jobs;
|
||||||
create policy jobs_select on public.spreadsheet_jobs
|
create policy jobs_select on public.spreadsheet_jobs
|
||||||
for select to app_user
|
for select to app_user
|
||||||
using (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id));
|
using (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id));
|
||||||
|
|
||||||
|
drop policy if exists jobs_insert on public.spreadsheet_jobs;
|
||||||
create policy jobs_insert on public.spreadsheet_jobs
|
create policy jobs_insert on public.spreadsheet_jobs
|
||||||
for insert to app_user
|
for insert to app_user
|
||||||
with check (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id));
|
with check (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id));
|
||||||
|
|
||||||
|
drop policy if exists jobs_update on public.spreadsheet_jobs;
|
||||||
create policy jobs_update on public.spreadsheet_jobs
|
create policy jobs_update on public.spreadsheet_jobs
|
||||||
for update to app_user
|
for update to app_user
|
||||||
using (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id))
|
using (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id))
|
||||||
@@ -229,6 +235,7 @@ create policy sources_select on public.spreadsheet_job_sources
|
|||||||
where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id)
|
where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id)
|
||||||
));
|
));
|
||||||
|
|
||||||
|
drop policy if exists sources_insert on public.spreadsheet_job_sources;
|
||||||
create policy sources_insert on public.spreadsheet_job_sources
|
create policy sources_insert on public.spreadsheet_job_sources
|
||||||
for insert to app_user
|
for insert to app_user
|
||||||
with check (exists (
|
with check (exists (
|
||||||
@@ -236,6 +243,7 @@ create policy sources_insert on public.spreadsheet_job_sources
|
|||||||
where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id)
|
where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id)
|
||||||
));
|
));
|
||||||
|
|
||||||
|
drop policy if exists sources_update on public.spreadsheet_job_sources;
|
||||||
create policy sources_update on public.spreadsheet_job_sources
|
create policy sources_update on public.spreadsheet_job_sources
|
||||||
for update to app_user
|
for update to app_user
|
||||||
using (exists (
|
using (exists (
|
||||||
@@ -247,6 +255,7 @@ create policy sources_update on public.spreadsheet_job_sources
|
|||||||
where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id)
|
where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id)
|
||||||
));
|
));
|
||||||
|
|
||||||
|
drop policy if exists sources_delete on public.spreadsheet_job_sources;
|
||||||
create policy sources_delete on public.spreadsheet_job_sources
|
create policy sources_delete on public.spreadsheet_job_sources
|
||||||
for delete to app_user
|
for delete to app_user
|
||||||
using (exists (
|
using (exists (
|
||||||
@@ -263,6 +272,7 @@ create policy mappings_select on public.spreadsheet_job_mappings
|
|||||||
where s.id = job_source_id and j.user_id = public.auth_uid()
|
where s.id = job_source_id and j.user_id = public.auth_uid()
|
||||||
));
|
));
|
||||||
|
|
||||||
|
drop policy if exists mappings_insert on public.spreadsheet_job_mappings;
|
||||||
create policy mappings_insert on public.spreadsheet_job_mappings
|
create policy mappings_insert on public.spreadsheet_job_mappings
|
||||||
for insert to app_user
|
for insert to app_user
|
||||||
with check (exists (
|
with check (exists (
|
||||||
@@ -271,6 +281,7 @@ create policy mappings_insert on public.spreadsheet_job_mappings
|
|||||||
where s.id = job_source_id and j.user_id = public.auth_uid()
|
where s.id = job_source_id and j.user_id = public.auth_uid()
|
||||||
));
|
));
|
||||||
|
|
||||||
|
drop policy if exists mappings_delete on public.spreadsheet_job_mappings;
|
||||||
create policy mappings_delete on public.spreadsheet_job_mappings
|
create policy mappings_delete on public.spreadsheet_job_mappings
|
||||||
for delete to app_user
|
for delete to app_user
|
||||||
using (exists (
|
using (exists (
|
||||||
@@ -282,3 +293,37 @@ create policy mappings_delete on public.spreadsheet_job_mappings
|
|||||||
grant usage on schema public to app_user;
|
grant usage on schema public to app_user;
|
||||||
grant select, insert, update, delete on all tables in schema public to app_user;
|
grant select, insert, update, delete on all tables in schema public to app_user;
|
||||||
grant usage, select on all sequences in schema public to app_user;
|
grant usage, select on all sequences in schema public to app_user;
|
||||||
|
|
||||||
|
-- schema_migrations não tem RLS. A escrita fica só com o dono que aplica as migrations.
|
||||||
|
revoke insert, update, delete, truncate, references, trigger on table public.schema_migrations from public, app_user;
|
||||||
|
|
||||||
|
do $priv$
|
||||||
|
declare
|
||||||
|
versao int := current_setting('server_version_num')::int;
|
||||||
|
escrita text := 'insert, update, delete, truncate, references, trigger';
|
||||||
|
papel text;
|
||||||
|
begin
|
||||||
|
if versao >= 170000 then
|
||||||
|
escrita := escrita || ', maintain';
|
||||||
|
execute 'revoke maintain on table public.schema_migrations from public, app_user';
|
||||||
|
end if;
|
||||||
|
foreach papel in array array['anon', 'authenticated', 'service_role', 'adaptador_web', 'adaptador_worker']
|
||||||
|
loop
|
||||||
|
if exists (select 1 from pg_catalog.pg_roles where rolname = papel) then
|
||||||
|
execute format(
|
||||||
|
'revoke %s on table public.profiles, public.workspaces, public.workspace_members, public.workspace_module_flags, public.audit_log, public.spreadsheet_adapter_settings, public.spreadsheet_models, public.spreadsheet_jobs, public.spreadsheet_job_sources, public.spreadsheet_job_mappings, public.schema_migrations from %I',
|
||||||
|
escrita,
|
||||||
|
papel
|
||||||
|
);
|
||||||
|
if papel in ('anon', 'authenticated', 'service_role') then
|
||||||
|
execute format('revoke select on table public.schema_migrations from %I', papel);
|
||||||
|
execute format(
|
||||||
|
'alter default privileges in schema public revoke %s on tables from %I',
|
||||||
|
escrita,
|
||||||
|
papel
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
end
|
||||||
|
$priv$;
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
-- Papéis e funções de manutenção da homologação.
|
||||||
|
-- Não altera os schemas auth e storage do Supabase.
|
||||||
|
-- Não concede BYPASSRLS nem SUPERUSER.
|
||||||
|
-- Os papéis nascem NOLOGIN. A senha e o LOGIN ficam para um passo autorizado no projeto de homologação.
|
||||||
|
-- As funções são SECURITY DEFINER, com search_path travado, e EXECUTE só para adaptador_worker.
|
||||||
|
-- O dono é quem aplica a migration. No Supabase gerenciado esse dono é o postgres,
|
||||||
|
-- com rolsuper = false e rolbypassrls = true. O BYPASSRLS do dono faz a função
|
||||||
|
-- atravessar FORCE RLS; SUPERUSER não é necessário para isso. BYPASSRLS não identifica o projeto.
|
||||||
|
-- adaptador_web e adaptador_worker são NOINHERIT: sem SET ROLE app_user não herdam os grants do papel da aplicação.
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create role adaptador_web nologin nosuperuser nobypassrls noinherit;
|
||||||
|
exception when duplicate_object then null;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create role adaptador_worker nologin nosuperuser nobypassrls noinherit;
|
||||||
|
exception when duplicate_object then null;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
grant app_user to adaptador_web;
|
||||||
|
grant app_user to adaptador_worker;
|
||||||
|
grant usage on schema public to adaptador_web, adaptador_worker;
|
||||||
|
grant select on table public.schema_migrations to adaptador_web, adaptador_worker;
|
||||||
|
|
||||||
|
create or replace function public.adapter_list_queued_job_ids()
|
||||||
|
returns table (id uuid)
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = pg_catalog
|
||||||
|
as $$
|
||||||
|
select j.id
|
||||||
|
from public.spreadsheet_jobs j
|
||||||
|
where j.status = 'queued'
|
||||||
|
order by j.created_at asc
|
||||||
|
limit 5
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.adapter_read_queued_job(p_job uuid)
|
||||||
|
returns table (user_id uuid, workspace_id uuid, progress jsonb)
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = pg_catalog
|
||||||
|
as $$
|
||||||
|
select j.user_id, j.workspace_id, j.progress
|
||||||
|
from public.spreadsheet_jobs j
|
||||||
|
where j.id = p_job and j.status = 'queued'
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.adapter_worker_context(p_user uuid, p_workspace uuid)
|
||||||
|
returns table (
|
||||||
|
display_name text,
|
||||||
|
is_superadmin boolean,
|
||||||
|
role text,
|
||||||
|
workspace_name text,
|
||||||
|
module_enabled boolean,
|
||||||
|
allow_upload boolean
|
||||||
|
)
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = pg_catalog
|
||||||
|
as $$
|
||||||
|
select p.display_name,
|
||||||
|
p.is_superadmin,
|
||||||
|
m.role,
|
||||||
|
w.name,
|
||||||
|
coalesce(f.enabled, false),
|
||||||
|
coalesce(s.allow_non_admin_model_upload, false)
|
||||||
|
from public.profiles p
|
||||||
|
join public.workspace_members m on m.user_id = p.id and m.workspace_id = p_workspace
|
||||||
|
join public.workspaces w on w.id = p_workspace
|
||||||
|
left join public.workspace_module_flags f
|
||||||
|
on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter'
|
||||||
|
left join public.spreadsheet_adapter_settings s on s.workspace_id = w.id
|
||||||
|
where p.id = p_user
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.adapter_list_expired_sources(p_now timestamptz)
|
||||||
|
returns table (
|
||||||
|
id uuid,
|
||||||
|
workspace_id uuid,
|
||||||
|
temp_storage_key text,
|
||||||
|
output_csv_key text,
|
||||||
|
output_xlsx_key text
|
||||||
|
)
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = pg_catalog
|
||||||
|
as $$
|
||||||
|
select s.id, j.workspace_id, s.temp_storage_key, s.output_csv_key, s.output_xlsx_key
|
||||||
|
from public.spreadsheet_job_sources s
|
||||||
|
join public.spreadsheet_jobs j on j.id = s.job_id
|
||||||
|
where s.expires_at < p_now and s.status <> 'expired'
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.adapter_mark_source_expired(p_source uuid)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = pg_catalog
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
update public.spreadsheet_job_sources
|
||||||
|
set status = 'expired', output_csv_key = null, output_xlsx_key = null
|
||||||
|
where id = p_source and status <> 'expired';
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.adapter_list_processing_jobs()
|
||||||
|
returns table (id uuid, user_id uuid, workspace_id uuid)
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = pg_catalog
|
||||||
|
as $$
|
||||||
|
select j.id, j.user_id, j.workspace_id
|
||||||
|
from public.spreadsheet_jobs j
|
||||||
|
where j.status = 'processing'
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.adapter_mark_job_interrupted(p_job uuid)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = pg_catalog
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
update public.spreadsheet_jobs
|
||||||
|
set status = 'failed', error_code = 'INTERRUPTED', updated_at = now()
|
||||||
|
where id = p_job and status = 'processing';
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.adapter_list_queued_job_ids() from public, app_user, adaptador_web;
|
||||||
|
revoke all on function public.adapter_read_queued_job(uuid) from public, app_user, adaptador_web;
|
||||||
|
revoke all on function public.adapter_worker_context(uuid, uuid) from public, app_user, adaptador_web;
|
||||||
|
revoke all on function public.adapter_list_expired_sources(timestamptz) from public, app_user, adaptador_web;
|
||||||
|
revoke all on function public.adapter_mark_source_expired(uuid) from public, app_user, adaptador_web;
|
||||||
|
revoke all on function public.adapter_list_processing_jobs() from public, app_user, adaptador_web;
|
||||||
|
revoke all on function public.adapter_mark_job_interrupted(uuid) from public, app_user, adaptador_web;
|
||||||
|
|
||||||
|
grant execute on function public.adapter_list_queued_job_ids() to adaptador_worker;
|
||||||
|
grant execute on function public.adapter_read_queued_job(uuid) to adaptador_worker;
|
||||||
|
grant execute on function public.adapter_worker_context(uuid, uuid) to adaptador_worker;
|
||||||
|
grant execute on function public.adapter_list_expired_sources(timestamptz) to adaptador_worker;
|
||||||
|
grant execute on function public.adapter_mark_source_expired(uuid) to adaptador_worker;
|
||||||
|
grant execute on function public.adapter_list_processing_jobs() to adaptador_worker;
|
||||||
|
grant execute on function public.adapter_mark_job_interrupted(uuid) to adaptador_worker;
|
||||||
@@ -2,6 +2,14 @@
|
|||||||
-- Não remove profiles, workspaces, members, flags ou audit_log do Elo Pro.
|
-- Não remove profiles, workspaces, members, flags ou audit_log do Elo Pro.
|
||||||
-- Funções de autorização usadas também pelo audit_log permanecem.
|
-- Funções de autorização usadas também pelo audit_log permanecem.
|
||||||
|
|
||||||
|
drop function if exists public.adapter_mark_job_interrupted(uuid);
|
||||||
|
drop function if exists public.adapter_list_processing_jobs();
|
||||||
|
drop function if exists public.adapter_mark_source_expired(uuid);
|
||||||
|
drop function if exists public.adapter_list_expired_sources(timestamptz);
|
||||||
|
drop function if exists public.adapter_worker_context(uuid, uuid);
|
||||||
|
drop function if exists public.adapter_read_queued_job(uuid);
|
||||||
|
drop function if exists public.adapter_list_queued_job_ids();
|
||||||
|
|
||||||
drop table if exists public.spreadsheet_job_mappings;
|
drop table if exists public.spreadsheet_job_mappings;
|
||||||
drop table if exists public.spreadsheet_job_sources;
|
drop table if exists public.spreadsheet_job_sources;
|
||||||
drop table if exists public.spreadsheet_jobs;
|
drop table if exists public.spreadsheet_jobs;
|
||||||
|
|||||||
+3
-1
@@ -18,6 +18,7 @@
|
|||||||
"test:services": "vitest run --config vitest.services.config.ts",
|
"test:services": "vitest run --config vitest.services.config.ts",
|
||||||
"migrate": "node --import=tsx scripts/migrate.ts",
|
"migrate": "node --import=tsx scripts/migrate.ts",
|
||||||
"migrate:check": "node --import=tsx scripts/migrate.ts --dry-run",
|
"migrate:check": "node --import=tsx scripts/migrate.ts --dry-run",
|
||||||
|
"identity:init": "node --import=tsx scripts/init-project-identity.ts",
|
||||||
"seed": "node --import=tsx scripts/seed-dev.ts",
|
"seed": "node --import=tsx scripts/seed-dev.ts",
|
||||||
"seed:staging": "node --import=tsx scripts/seed-staging.ts",
|
"seed:staging": "node --import=tsx scripts/seed-staging.ts",
|
||||||
"worker": "node --import=tsx scripts/worker.ts",
|
"worker": "node --import=tsx scripts/worker.ts",
|
||||||
@@ -25,7 +26,8 @@
|
|||||||
"flag": "node --import=tsx scripts/set-flag.ts",
|
"flag": "node --import=tsx scripts/set-flag.ts",
|
||||||
"verify:config": "node --import=tsx scripts/verify-config.ts",
|
"verify:config": "node --import=tsx scripts/verify-config.ts",
|
||||||
"check:bucket": "node --import=tsx scripts/check-bucket.ts",
|
"check:bucket": "node --import=tsx scripts/check-bucket.ts",
|
||||||
"smoke:staging": "node --import=tsx scripts/smoke-staging.ts"
|
"smoke:staging": "node --import=tsx scripts/smoke-staging.ts",
|
||||||
|
"smoke:local": "node --import=tsx scripts/smoke-local.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
+19
-9
@@ -1,8 +1,10 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { adapterEnv, identityShimEnabled, isDeployedEnv, requireDatabaseUrl } from "../src/config/env";
|
import { adapterEnv, identityShimEnabled, isDeployedEnv } from "../src/config/env";
|
||||||
import { exitOnInvalidConfig } from "../src/config/fail-fast";
|
import { exitOnInvalidConfig } from "../src/config/fail-fast";
|
||||||
|
import { buildPostgresOptions, databaseUrlFor, scrubDbError, type DbPurpose } from "../src/infra/db/connection-policy";
|
||||||
import { createFileDb } from "../src/infra/db/pglite";
|
import { createFileDb } from "../src/infra/db/pglite";
|
||||||
import { createPostgresDb } from "../src/infra/db/postgres";
|
import { createPostgresDb } from "../src/infra/db/postgres";
|
||||||
|
import { assertDatabaseGuard } from "../src/infra/db/target-guard";
|
||||||
import type { Db } from "../src/infra/db/types";
|
import type { Db } from "../src/infra/db/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,11 +12,20 @@ import type { Db } from "../src/infra/db/types";
|
|||||||
* obrigatória: cair em PGlite com um segredo ausente faria uma migração
|
* obrigatória: cair em PGlite com um segredo ausente faria uma migração
|
||||||
* silenciosa num arquivo local parecer sucesso.
|
* silenciosa num arquivo local parecer sucesso.
|
||||||
*/
|
*/
|
||||||
export async function openOperationalDb(): Promise<Db> {
|
export async function openOperationalDb(purpose: DbPurpose): Promise<Db> {
|
||||||
const databaseUrl = requireDatabaseUrl();
|
const databaseUrl = databaseUrlFor(purpose);
|
||||||
if (databaseUrl) return createPostgresDb(databaseUrl);
|
if (databaseUrl) {
|
||||||
|
const db = createPostgresDb(databaseUrl, buildPostgresOptions(databaseUrl, purpose));
|
||||||
|
try {
|
||||||
|
await assertDatabaseGuard(db, purpose, databaseUrl);
|
||||||
|
return db;
|
||||||
|
} catch (error) {
|
||||||
|
await db.close().catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (isDeployedEnv()) {
|
if (isDeployedEnv()) {
|
||||||
throw new Error("DATABASE_URL é obrigatória fora de development.");
|
throw new Error("DATABASE_URL é obrigatória fora do PGlite de development.");
|
||||||
}
|
}
|
||||||
const dataDir = path.join(process.cwd(), ".data", "pglite");
|
const dataDir = path.join(process.cwd(), ".data", "pglite");
|
||||||
const { mkdir } = await import("node:fs/promises");
|
const { mkdir } = await import("node:fs/promises");
|
||||||
@@ -47,17 +58,16 @@ export function runScript(name: string, body: () => Promise<void>): void {
|
|||||||
process.exitCode ??= 0;
|
process.exitCode ??= 0;
|
||||||
},
|
},
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
const label = error instanceof Error ? `${error.name}: ${error.message}` : "erro";
|
console.error(`[${name}] ${scrubDbError(error)}`);
|
||||||
console.error(`[${name}] ${label}`);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Abre o banco, executa e fecha, mesmo em caso de falha. */
|
/** Abre o banco, executa e fecha, mesmo em caso de falha. */
|
||||||
export function runWithDb(name: string, body: (db: Db) => Promise<void>): void {
|
export function runWithDb(name: string, purpose: DbPurpose, body: (db: Db) => Promise<void>): void {
|
||||||
runScript(name, async () => {
|
runScript(name, async () => {
|
||||||
const db = await openOperationalDb();
|
const db = await openOperationalDb(purpose);
|
||||||
try {
|
try {
|
||||||
await body(db);
|
await body(db);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { bootstrapScript, runWithDb } from "./_bootstrap";
|
|||||||
|
|
||||||
bootstrapScript("cleanup-temp");
|
bootstrapScript("cleanup-temp");
|
||||||
|
|
||||||
runWithDb("cleanup-temp", async (db) => {
|
runWithDb("cleanup-temp", "worker", async (db) => {
|
||||||
const removed = await cleanupExpired({ db, storage: createStorage(), coordinator: createCoordinator() });
|
const removed = await cleanupExpired({ db, storage: createStorage(), coordinator: createCoordinator() });
|
||||||
console.log(`temporários expirados: ${removed}`);
|
console.log(`temporários expirados: ${removed}`);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { adapterEnv } from "../src/config/env";
|
||||||
|
import { buildPostgresOptions, databaseUrlFor, DatabaseGuardError } from "../src/infra/db/connection-policy";
|
||||||
|
import { createPostgresDb } from "../src/infra/db/postgres";
|
||||||
|
import { assertInitAuthorized, installProjectIdentity } from "../src/infra/db/project-identity";
|
||||||
|
import { assertAdminTarget } from "../src/infra/db/target-guard";
|
||||||
|
import { runScript } from "./_bootstrap";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grava `adaptador_guard.project_identity` uma única vez.
|
||||||
|
* Exige ADAPTER_INIT_PROJECT_IDENTITY=1 e o destino administrativo já conferido.
|
||||||
|
* Não chama o fail-fast da aplicação: nesta etapa os papéis web e worker ainda
|
||||||
|
* não existem, e Redis, seed e a feature flag ficam fora desta operação.
|
||||||
|
* Migrate, seed e deploy não chamam este script.
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
assertInitAuthorized(process.env);
|
||||||
|
} catch (error) {
|
||||||
|
const code = error instanceof DatabaseGuardError ? error.code : "init_recusada";
|
||||||
|
console.error(`[init-project-identity] ${code}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[init-project-identity] ambiente=${adapterEnv()}`);
|
||||||
|
|
||||||
|
runScript("init-project-identity", async () => {
|
||||||
|
const databaseUrl = databaseUrlFor("admin");
|
||||||
|
if (!databaseUrl) throw new DatabaseGuardError("url_ausente");
|
||||||
|
const db = createPostgresDb(databaseUrl, buildPostgresOptions(databaseUrl, "admin"));
|
||||||
|
try {
|
||||||
|
await assertAdminTarget(db);
|
||||||
|
const ref = process.env.ADAPTER_DB_PROJECT_REF?.trim() ?? "";
|
||||||
|
const result = await installProjectIdentity(db, ref);
|
||||||
|
console.log(`identidade: ${result}`);
|
||||||
|
} finally {
|
||||||
|
await db.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
+47
-22
@@ -1,28 +1,53 @@
|
|||||||
import { identityShimEnabled } from "../src/config/env";
|
import { adapterEnv, identityShimEnabled } from "../src/config/env";
|
||||||
import { migrate, pendingMigrations } from "../src/infra/db/migrate";
|
import { exitOnInvalidMigrationConfig } from "../src/config/fail-fast";
|
||||||
import { bootstrapScript, describeTarget, runWithDb } from "./_bootstrap";
|
import {
|
||||||
|
assertManifestMatches,
|
||||||
|
listMigrationFiles,
|
||||||
|
migrate,
|
||||||
|
pendingMigrations,
|
||||||
|
readMigrationLedger,
|
||||||
|
} from "../src/infra/db/migrate";
|
||||||
|
import { bootstrapScript, describeTarget, runScript, runWithDb } from "./_bootstrap";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aplica as migrations pendentes. `--dry-run` apenas lista: usado no pipeline
|
* Três modos, separados de propósito:
|
||||||
* antes do passo manual de migração, para que ninguém descubra o que vai rodar
|
* - `--plan` lista o manifesto no disco e não abre banco.
|
||||||
* só depois de ter rodado.
|
* - `--dry-run` lê `schema_migrations` e não aplica SQL.
|
||||||
|
* - sem flag, aplica o lote pendente numa transação.
|
||||||
*/
|
*/
|
||||||
|
const plan = process.argv.includes("--plan");
|
||||||
const dryRun = process.argv.includes("--dry-run");
|
const dryRun = process.argv.includes("--dry-run");
|
||||||
|
|
||||||
bootstrapScript("migrate");
|
if (plan) {
|
||||||
|
runScript("migrate", async () => {
|
||||||
|
const files = await listMigrationFiles();
|
||||||
|
assertManifestMatches(files);
|
||||||
|
console.log(`plano estatico offline: ${files.join(", ")}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (adapterEnv() === "staging") exitOnInvalidMigrationConfig(process.env);
|
||||||
|
else if (adapterEnv() === "production") {
|
||||||
|
console.error(
|
||||||
|
"[adaptador-planilhas] Configuração administrativa recusada. ADAPTER_ENV: migrate administrativo de homologação exige staging",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
} else bootstrapScript("migrate");
|
||||||
|
|
||||||
runWithDb("migrate", async (db) => {
|
runWithDb("migrate", "admin", async (db) => {
|
||||||
const pending = await pendingMigrations(db);
|
const ledger = await readMigrationLedger(db);
|
||||||
console.log(`alvo: ${describeTarget()}`);
|
const pending = await pendingMigrations(db);
|
||||||
console.log(`shim de identidade: ${identityShimEnabled() ? "SERÁ aplicado" : "NÃO será aplicado"}`);
|
console.log(`alvo: ${adapterEnv() === "staging" ? "postgres administrativo" : describeTarget()}`);
|
||||||
if (pending.length === 0) {
|
console.log(`shim de identidade: ${identityShimEnabled() ? "SERÁ aplicado" : "NÃO será aplicado"}`);
|
||||||
console.log("migrations: nada pendente");
|
if (ledger.state === "ausente") console.log("historico: ausente; schema_migrations nao foi criada");
|
||||||
return;
|
if (pending.length === 0) {
|
||||||
}
|
console.log("migrations: nada pendente");
|
||||||
if (dryRun) {
|
return;
|
||||||
console.log(`migrations pendentes (dry-run, nada aplicado): ${pending.join(", ")}`);
|
}
|
||||||
return;
|
if (dryRun) {
|
||||||
}
|
console.log(`migrations pendentes (dry-run, nada aplicado): ${pending.join(", ")}`);
|
||||||
const ran = await migrate(db);
|
return;
|
||||||
console.log(ran.length ? `migrations aplicadas: ${ran.join(", ")}` : "migrations: nada pendente");
|
}
|
||||||
});
|
const ran = await migrate(db);
|
||||||
|
console.log(ran.length ? `migrations aplicadas: ${ran.join(", ")}` : "migrations: nada pendente");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import ExcelJS from "exceljs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jornada fictícia contra o ensaio de rollback, só em 127.0.0.1:3310.
|
||||||
|
* Recusa a porta 3000 do compose principal. Não abre banco nem Docker.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ALPHA = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const ANA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||||
|
const base = process.env.ROLLBACK_PROBE_URL ?? "http://127.0.0.1:3310";
|
||||||
|
const mode = process.argv[2];
|
||||||
|
const expectedRaw = process.argv[3];
|
||||||
|
|
||||||
|
function assertTarget(): void {
|
||||||
|
const url = new URL(base);
|
||||||
|
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || url.port !== "3310") {
|
||||||
|
throw new Error("alvo recusado: o ensaio só fala com http://127.0.0.1:3310");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(body: Buffer): string {
|
||||||
|
return createHash("sha256").update(body).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function xlsx(rows: string[][]): Promise<Buffer> {
|
||||||
|
const book = new ExcelJS.Workbook();
|
||||||
|
const sheet = book.addWorksheet("Dados");
|
||||||
|
for (const row of rows) sheet.addRow(row);
|
||||||
|
return Buffer.from(await book.xlsx.writeBuffer()) as Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(): Promise<string> {
|
||||||
|
const response = await fetch(`${base}/api/dev/session`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", accept: "application/json" },
|
||||||
|
body: JSON.stringify({ userId: ANA, workspaceId: ALPHA }),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
if (response.status !== 200) throw new Error(`sessão local HTTP ${response.status}`);
|
||||||
|
const lines = typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [];
|
||||||
|
const pair = lines.map((line) => line.split(";")[0]).find((line) => line.startsWith("adapter_dev_session="));
|
||||||
|
if (!pair) throw new Error("cookie de sessão ausente");
|
||||||
|
return pair;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path: string, cookie: string, init?: RequestInit): Promise<{ status: number; body: unknown; bytes: Buffer; headers: Headers }> {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
headers.set("cookie", cookie);
|
||||||
|
headers.set("accept", headers.get("accept") ?? "application/json");
|
||||||
|
const response = await fetch(`${base}${path}`, { ...init, headers, signal: AbortSignal.timeout(60_000) });
|
||||||
|
const bytes = Buffer.from(await response.arrayBuffer());
|
||||||
|
let body: unknown = bytes.toString("utf8");
|
||||||
|
try {
|
||||||
|
body = JSON.parse(bytes.toString("utf8"));
|
||||||
|
} catch {
|
||||||
|
/* arquivo binário */
|
||||||
|
}
|
||||||
|
return { status: response.status, body, bytes, headers: response.headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitJob(cookie: string, jobId: string): Promise<string> {
|
||||||
|
let status = "";
|
||||||
|
for (let attempt = 0; attempt < 45; attempt += 1) {
|
||||||
|
const polled = await request(`/api/spreadsheet-adapter/jobs/${jobId}`, cookie);
|
||||||
|
status = (polled.body as { job?: { status?: string } }).job?.status ?? "";
|
||||||
|
if (status === "completed" || status === "failed") return status;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
return status || "timeout";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloads(cookie: string, jobId: string, sourceId: string) {
|
||||||
|
const files: Record<string, { status: number; sha256: string; size: number }> = {};
|
||||||
|
for (const format of ["xlsx", "csv", "pdf"]) {
|
||||||
|
const file = await request(`/api/spreadsheet-adapter/jobs/${jobId}/download?format=${format}&sourceId=${sourceId}`, cookie, {
|
||||||
|
headers: { accept: "*/*" },
|
||||||
|
});
|
||||||
|
files[format] = { status: file.status, sha256: sha256(file.bytes), size: file.bytes.length };
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seed(): Promise<void> {
|
||||||
|
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||||
|
const suffix = Math.random().toString(36).slice(2, 8).padEnd(6, "0");
|
||||||
|
const modelName = `Rollback local ${stamp}-${suffix}`;
|
||||||
|
const sentinel = `ROLLBACKLOCAL${suffix.toUpperCase()}`;
|
||||||
|
const cookie = await login();
|
||||||
|
const modelBytes = await xlsx([["Nome", "Codigo"]]);
|
||||||
|
const sourceBytes = await xlsx([
|
||||||
|
["Nome", "Codigo"],
|
||||||
|
["Registro ficticio", sentinel],
|
||||||
|
]);
|
||||||
|
const modelForm = new FormData();
|
||||||
|
modelForm.append("name", modelName);
|
||||||
|
modelForm.append("requiredColumns", JSON.stringify(["Nome", "Codigo"]));
|
||||||
|
modelForm.append("file", new File([new Uint8Array(modelBytes)], "modelo-ficticio.xlsx", { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }));
|
||||||
|
const created = await request("/api/spreadsheet-adapter/models", cookie, { method: "POST", body: modelForm, headers: { accept: "application/json" } });
|
||||||
|
const modelId = (created.body as { model?: { id?: string } }).model?.id ?? "";
|
||||||
|
if (created.status !== 201 || !modelId) throw new Error(`modelo HTTP ${created.status}`);
|
||||||
|
const job = await request("/api/spreadsheet-adapter/jobs", cookie, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ modelId, idempotencyKey: `rollback-job-${suffix}` }),
|
||||||
|
});
|
||||||
|
const jobId = (job.body as { job?: { id?: string } }).job?.id ?? "";
|
||||||
|
if (job.status !== 201 || !jobId) throw new Error(`operação HTTP ${job.status}`);
|
||||||
|
const sourceForm = new FormData();
|
||||||
|
sourceForm.append("jobId", jobId);
|
||||||
|
sourceForm.append("file", new File([new Uint8Array(sourceBytes)], "fonte-ficticia.xlsx", { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }));
|
||||||
|
const uploaded = await request("/api/spreadsheet-adapter/sources/validate", cookie, { method: "POST", body: sourceForm, headers: { accept: "application/json" } });
|
||||||
|
const sourceId = (uploaded.body as { job?: { sources?: { id?: string }[] } }).job?.sources?.[0]?.id ?? "";
|
||||||
|
if (uploaded.status !== 200 || !sourceId) throw new Error(`fonte HTTP ${uploaded.status}`);
|
||||||
|
const mapped = await request(`/api/spreadsheet-adapter/jobs/${jobId}/mappings`, cookie, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourceId,
|
||||||
|
mappings: [
|
||||||
|
{ sourceColumn: "Nome", modelColumn: "Nome" },
|
||||||
|
{ sourceColumn: "Codigo", modelColumn: "Codigo" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (mapped.status !== 200) throw new Error(`mapeamento HTTP ${mapped.status}`);
|
||||||
|
const preview = await request(`/api/spreadsheet-adapter/jobs/${jobId}/preview`, cookie, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ sourceId }),
|
||||||
|
});
|
||||||
|
const previewRows = (preview.body as { rows?: string[][] }).rows ?? [];
|
||||||
|
if (preview.status !== 200 || !previewRows.some((row) => row.includes(sentinel))) throw new Error(`prévia HTTP ${preview.status}`);
|
||||||
|
const queued = await request(`/api/spreadsheet-adapter/jobs/${jobId}/process`, cookie, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ sourceId, idempotencyKey: `rollback-proc-${suffix}` }),
|
||||||
|
});
|
||||||
|
const immediate = (queued.body as { job?: { status?: string } }).job?.status ?? "";
|
||||||
|
if (queued.status !== 200 || (immediate !== "queued" && immediate !== "processing")) throw new Error(`processo HTTP ${queued.status} status=${immediate}`);
|
||||||
|
const finalStatus = await waitJob(cookie, jobId);
|
||||||
|
if (finalStatus !== "completed") throw new Error(`worker status=${finalStatus}`);
|
||||||
|
const files = await downloads(cookie, jobId, sourceId);
|
||||||
|
if (Object.values(files).some((file) => file.status !== 200 || file.size < 1)) throw new Error("download incompleto");
|
||||||
|
console.log(JSON.stringify({ modelName, modelId, jobId, sourceId, modelSha256: sha256(modelBytes), sentinel, files }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verify(): Promise<void> {
|
||||||
|
if (!expectedRaw) throw new Error("informe o JSON gravado na subida");
|
||||||
|
const expected = JSON.parse(expectedRaw.startsWith("{") ? expectedRaw : readFileSync(expectedRaw, "utf8")) as {
|
||||||
|
modelName: string;
|
||||||
|
modelId: string;
|
||||||
|
jobId: string;
|
||||||
|
sourceId: string;
|
||||||
|
modelSha256: string;
|
||||||
|
files: Record<string, { sha256: string }>;
|
||||||
|
};
|
||||||
|
const cookie = await login();
|
||||||
|
const ready = await request("/api/health/ready", cookie);
|
||||||
|
const models = await request("/api/spreadsheet-adapter/models", cookie);
|
||||||
|
const list = (models.body as { models?: { id?: string; name?: string }[] }).models ?? [];
|
||||||
|
const found = list.some((model) => model.id === expected.modelId && model.name === expected.modelName);
|
||||||
|
const job = await request(`/api/spreadsheet-adapter/jobs/${expected.jobId}`, cookie);
|
||||||
|
const status = (job.body as { job?: { status?: string } }).job?.status ?? "";
|
||||||
|
const files = await downloads(cookie, expected.jobId, expected.sourceId);
|
||||||
|
const stored = (["xlsx", "csv"] as const).every((format) => files[format].sha256 === expected.files[format].sha256 && files[format].status === 200);
|
||||||
|
const pdf = files.pdf.status === 200 && files.pdf.size > 4;
|
||||||
|
const report = {
|
||||||
|
ready: (ready.body as { status?: string }).status ?? ready.status,
|
||||||
|
modelFound: found,
|
||||||
|
jobStatus: status,
|
||||||
|
storedFilesMatch: stored,
|
||||||
|
pdfRegenerated: pdf,
|
||||||
|
sha256: {
|
||||||
|
xlsx: files.xlsx.sha256 === expected.files.xlsx.sha256,
|
||||||
|
csv: files.csv.sha256 === expected.files.csv.sha256,
|
||||||
|
pdf: files.pdf.sha256 === expected.files.pdf.sha256,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
console.log(JSON.stringify(report));
|
||||||
|
if (report.ready !== "ready" || !found || status !== "completed" || !stored || !pdf) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTarget();
|
||||||
|
if (mode === "seed") {
|
||||||
|
seed().catch((error: unknown) => {
|
||||||
|
console.error(error instanceof Error ? error.message : "erro");
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
} else if (mode === "verify") {
|
||||||
|
verify().catch((error: unknown) => {
|
||||||
|
console.error(error instanceof Error ? error.message : "erro");
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error("uso: rollback-local-probe.ts seed|verify [json]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
+1
-1
@@ -9,7 +9,7 @@ if (adapterEnv() !== "development") {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
runWithDb("seed-dev", async (db) => {
|
runWithDb("seed-dev", "local", async (db) => {
|
||||||
await migrate(db);
|
await migrate(db);
|
||||||
await ensureDevFixtures(db);
|
await ensureDevFixtures(db);
|
||||||
console.log("fixtures de desenvolvimento conferidas");
|
console.log("fixtures de desenvolvimento conferidas");
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ runScript("seed-staging", async () => {
|
|||||||
seed.members ??= [];
|
seed.members ??= [];
|
||||||
assertSeed(seed);
|
assertSeed(seed);
|
||||||
|
|
||||||
const db = await openOperationalDb();
|
const db = await openOperationalDb("admin");
|
||||||
try {
|
try {
|
||||||
const ran = await migrate(db);
|
const ran = await migrate(db);
|
||||||
if (ran.length > 0) console.log(`migrations aplicadas antes do seed: ${ran.join(", ")}`);
|
if (ran.length > 0) console.log(`migrations aplicadas antes do seed: ${ran.join(", ")}`);
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ const enabled = action === "on";
|
|||||||
|
|
||||||
bootstrapScript("set-flag");
|
bootstrapScript("set-flag");
|
||||||
|
|
||||||
runWithDb("set-flag", async (db) => {
|
runWithDb("set-flag", "admin", async (db) => {
|
||||||
const existing = await db.withService((query) =>
|
const existing = await db.withService((query) =>
|
||||||
query.query<{ name: string }>(`select name from public.workspaces where id = $1`, [workspaceId]),
|
query.query<{ name: string }>(`select name from public.workspaces where id = $1`, [workspaceId]),
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,105 @@
|
|||||||
|
-- Inicialização explícita da identidade de adaptador-planilha-hml.
|
||||||
|
-- Não faz parte de migrations/. Migrate, seed e deploy não executam este arquivo.
|
||||||
|
-- Substitua <<PROJECT_REF>> pelo Reference ID do Dashboard: 20 letras ou dígitos minúsculos.
|
||||||
|
-- Rode o bloco inteiro de uma vez. Qualquer falha desfaz o schema inteiro.
|
||||||
|
-- Não altera app.settings.jwt_exp nem os schemas auth e storage.
|
||||||
|
-- Se o schema já existir, não use este arquivo. O comando identity:init recusa linha divergente e objeto desconhecido.
|
||||||
|
|
||||||
|
do $identity$
|
||||||
|
begin
|
||||||
|
create schema adaptador_guard;
|
||||||
|
revoke all on schema adaptador_guard from public;
|
||||||
|
|
||||||
|
create table adaptador_guard.project_identity (
|
||||||
|
singleton boolean primary key default true check (singleton),
|
||||||
|
project_name text not null,
|
||||||
|
project_ref text not null,
|
||||||
|
constraint project_identity_name check (project_name = 'adaptador-planilha-hml'),
|
||||||
|
constraint project_identity_ref check (project_ref ~ '^[a-z0-9]{20}$')
|
||||||
|
);
|
||||||
|
revoke all on table adaptador_guard.project_identity from public;
|
||||||
|
alter table adaptador_guard.project_identity enable row level security;
|
||||||
|
alter table adaptador_guard.project_identity force row level security;
|
||||||
|
comment on table adaptador_guard.project_identity is
|
||||||
|
'Identidade persistente do projeto de homologacao. Nao e prova criptografica.';
|
||||||
|
|
||||||
|
create function adaptador_guard.reject_identity_change()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security invoker
|
||||||
|
set search_path = pg_catalog
|
||||||
|
as $body$
|
||||||
|
begin
|
||||||
|
if tg_op <> 'INSERT' then
|
||||||
|
raise exception 'identidade imutavel' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
if session_user <> 'postgres' and session_user !~ '^postgres[.][a-z0-9]{20}$' then
|
||||||
|
raise exception 'identidade protegida' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$body$;
|
||||||
|
|
||||||
|
create trigger project_identity_guard
|
||||||
|
before insert or update or delete on adaptador_guard.project_identity
|
||||||
|
for each row execute function adaptador_guard.reject_identity_change();
|
||||||
|
|
||||||
|
create function adaptador_guard.adapter_project_identity()
|
||||||
|
returns table (project_name text, project_ref text)
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = pg_catalog
|
||||||
|
as $body$
|
||||||
|
select i.project_name, i.project_ref
|
||||||
|
from adaptador_guard.project_identity i
|
||||||
|
$body$;
|
||||||
|
|
||||||
|
revoke all on function adaptador_guard.adapter_project_identity() from public;
|
||||||
|
revoke all on function adaptador_guard.reject_identity_change() from public;
|
||||||
|
|
||||||
|
insert into adaptador_guard.project_identity (project_name, project_ref)
|
||||||
|
values ('adaptador-planilha-hml', '<<PROJECT_REF>>');
|
||||||
|
|
||||||
|
revoke all on schema adaptador_guard from public;
|
||||||
|
revoke all on table adaptador_guard.project_identity from public;
|
||||||
|
revoke all on all functions in schema adaptador_guard from public;
|
||||||
|
alter default privileges in schema adaptador_guard revoke all on tables from public;
|
||||||
|
alter default privileges in schema adaptador_guard revoke execute on functions from public;
|
||||||
|
|
||||||
|
if exists (select 1 from pg_catalog.pg_roles where rolname = 'adaptador_web') then
|
||||||
|
revoke all on schema adaptador_guard from adaptador_web;
|
||||||
|
revoke all on table adaptador_guard.project_identity from adaptador_web;
|
||||||
|
revoke all on all functions in schema adaptador_guard from adaptador_web;
|
||||||
|
grant usage on schema adaptador_guard to adaptador_web;
|
||||||
|
grant execute on function adaptador_guard.adapter_project_identity() to adaptador_web;
|
||||||
|
end if;
|
||||||
|
if exists (select 1 from pg_catalog.pg_roles where rolname = 'adaptador_worker') then
|
||||||
|
revoke all on schema adaptador_guard from adaptador_worker;
|
||||||
|
revoke all on table adaptador_guard.project_identity from adaptador_worker;
|
||||||
|
revoke all on all functions in schema adaptador_guard from adaptador_worker;
|
||||||
|
grant usage on schema adaptador_guard to adaptador_worker;
|
||||||
|
grant execute on function adaptador_guard.adapter_project_identity() to adaptador_worker;
|
||||||
|
end if;
|
||||||
|
if exists (select 1 from pg_catalog.pg_roles where rolname = 'anon') then
|
||||||
|
revoke all on schema adaptador_guard from anon;
|
||||||
|
revoke all on table adaptador_guard.project_identity from anon;
|
||||||
|
revoke all on all functions in schema adaptador_guard from anon;
|
||||||
|
end if;
|
||||||
|
if exists (select 1 from pg_catalog.pg_roles where rolname = 'authenticated') then
|
||||||
|
revoke all on schema adaptador_guard from authenticated;
|
||||||
|
revoke all on table adaptador_guard.project_identity from authenticated;
|
||||||
|
revoke all on all functions in schema adaptador_guard from authenticated;
|
||||||
|
end if;
|
||||||
|
if exists (select 1 from pg_catalog.pg_roles where rolname = 'service_role') then
|
||||||
|
revoke all on schema adaptador_guard from service_role;
|
||||||
|
revoke all on table adaptador_guard.project_identity from service_role;
|
||||||
|
revoke all on all functions in schema adaptador_guard from service_role;
|
||||||
|
end if;
|
||||||
|
if exists (select 1 from pg_catalog.pg_roles where rolname = 'app_user') then
|
||||||
|
revoke all on schema adaptador_guard from app_user;
|
||||||
|
revoke all on table adaptador_guard.project_identity from app_user;
|
||||||
|
revoke all on all functions in schema adaptador_guard from app_user;
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$identity$;
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
-- Remove somente os objetos conhecidos de adaptador_guard.
|
||||||
|
-- Recusa se faltar peça, se houver objeto estranho ou se outro schema depender da identidade.
|
||||||
|
-- Não apaga auth, storage, public nem app.settings.jwt_exp.
|
||||||
|
-- O bloco e os DROP seguem juntos: se a conferência falhar, nada é removido.
|
||||||
|
|
||||||
|
do $revert$
|
||||||
|
begin
|
||||||
|
if not exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') then
|
||||||
|
raise exception 'marcador_ausente' using errcode = 'P0002';
|
||||||
|
end if;
|
||||||
|
if exists (
|
||||||
|
select kind, name
|
||||||
|
from (
|
||||||
|
select 'missing'::text as kind, 'project_identity'::text as name
|
||||||
|
where exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard')
|
||||||
|
and not exists (
|
||||||
|
select 1
|
||||||
|
from pg_catalog.pg_class c
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
||||||
|
where n.nspname = 'adaptador_guard' and c.relname = 'project_identity' and c.relkind = 'r'
|
||||||
|
)
|
||||||
|
union all
|
||||||
|
select 'missing', 'adapter_project_identity'
|
||||||
|
where exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard')
|
||||||
|
and not exists (
|
||||||
|
select 1
|
||||||
|
from pg_catalog.pg_proc p
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
||||||
|
where n.nspname = 'adaptador_guard' and p.proname = 'adapter_project_identity'
|
||||||
|
)
|
||||||
|
union all
|
||||||
|
select 'missing', 'reject_identity_change'
|
||||||
|
where exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard')
|
||||||
|
and not exists (
|
||||||
|
select 1
|
||||||
|
from pg_catalog.pg_proc p
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
||||||
|
where n.nspname = 'adaptador_guard' and p.proname = 'reject_identity_change'
|
||||||
|
)
|
||||||
|
union all
|
||||||
|
select 'unknown_relation', c.relname
|
||||||
|
from pg_catalog.pg_class c
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
||||||
|
where n.nspname = 'adaptador_guard'
|
||||||
|
and not (
|
||||||
|
(c.relname = 'project_identity' and c.relkind = 'r')
|
||||||
|
or (
|
||||||
|
c.relkind = 'i'
|
||||||
|
and exists (
|
||||||
|
select 1
|
||||||
|
from pg_catalog.pg_index i
|
||||||
|
join pg_catalog.pg_class t on t.oid = i.indrelid
|
||||||
|
where i.indexrelid = c.oid
|
||||||
|
and t.relname = 'project_identity'
|
||||||
|
and t.relnamespace = n.oid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
union all
|
||||||
|
select 'unknown_function', p.proname
|
||||||
|
from pg_catalog.pg_proc p
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
||||||
|
where n.nspname = 'adaptador_guard'
|
||||||
|
and p.proname not in ('adapter_project_identity', 'reject_identity_change')
|
||||||
|
union all
|
||||||
|
select 'unknown_type', t.typname
|
||||||
|
from pg_catalog.pg_type t
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = t.typnamespace
|
||||||
|
where n.nspname = 'adaptador_guard'
|
||||||
|
and not exists (
|
||||||
|
select 1
|
||||||
|
from pg_catalog.pg_class c
|
||||||
|
where c.oid = t.typrelid
|
||||||
|
and c.relname = 'project_identity'
|
||||||
|
and c.relnamespace = n.oid
|
||||||
|
)
|
||||||
|
and not exists (
|
||||||
|
select 1
|
||||||
|
from pg_catalog.pg_type elem
|
||||||
|
join pg_catalog.pg_class c on c.oid = elem.typrelid
|
||||||
|
where elem.oid = t.typelem
|
||||||
|
and c.relname = 'project_identity'
|
||||||
|
and c.relnamespace = n.oid
|
||||||
|
)
|
||||||
|
union all
|
||||||
|
select 'external', vn.nspname || '.' || v.relname
|
||||||
|
from pg_catalog.pg_depend d
|
||||||
|
join pg_catalog.pg_rewrite rw on rw.oid = d.objid
|
||||||
|
join pg_catalog.pg_class v on v.oid = rw.ev_class
|
||||||
|
join pg_catalog.pg_namespace vn on vn.oid = v.relnamespace
|
||||||
|
join pg_catalog.pg_class t on t.oid = d.refobjid
|
||||||
|
join pg_catalog.pg_namespace tn on tn.oid = t.relnamespace
|
||||||
|
where tn.nspname = 'adaptador_guard'
|
||||||
|
and vn.nspname <> 'adaptador_guard'
|
||||||
|
union all
|
||||||
|
select 'external', n.nspname || '.' || p.proname
|
||||||
|
from pg_catalog.pg_depend d
|
||||||
|
join pg_catalog.pg_proc p on p.oid = d.objid
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
||||||
|
join pg_catalog.pg_class t on t.oid = d.refobjid
|
||||||
|
join pg_catalog.pg_namespace tn on tn.oid = t.relnamespace
|
||||||
|
where tn.nspname = 'adaptador_guard'
|
||||||
|
and n.nspname <> 'adaptador_guard'
|
||||||
|
) findings
|
||||||
|
) then
|
||||||
|
raise exception 'reversao_recusada' using errcode = '55000';
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$revert$;
|
||||||
|
drop function adaptador_guard.adapter_project_identity();
|
||||||
|
drop table adaptador_guard.project_identity;
|
||||||
|
drop function adaptador_guard.reject_identity_change();
|
||||||
|
drop schema adaptador_guard restrict;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- Leitura da identidade e dos privilégios. Não grava.
|
||||||
|
|
||||||
|
select project_name, project_ref
|
||||||
|
from adaptador_guard.adapter_project_identity();
|
||||||
|
|
||||||
|
select p.proname, p.prosecdef, p.proconfig
|
||||||
|
from pg_catalog.pg_proc p
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
||||||
|
where n.nspname = 'adaptador_guard'
|
||||||
|
order by p.proname;
|
||||||
|
|
||||||
|
select c.relname, c.relrowsecurity, c.relforcerowsecurity
|
||||||
|
from pg_catalog.pg_class c
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
||||||
|
where n.nspname = 'adaptador_guard'
|
||||||
|
and c.relkind = 'r'
|
||||||
|
order by c.relname;
|
||||||
|
|
||||||
|
select 'public' as papel,
|
||||||
|
has_schema_privilege('public', 'adaptador_guard', 'usage') as uso,
|
||||||
|
has_table_privilege('public', 'adaptador_guard.project_identity', 'select') as leitura,
|
||||||
|
has_table_privilege('public', 'adaptador_guard.project_identity', 'update') as alteracao,
|
||||||
|
has_function_privilege('public', 'adaptador_guard.adapter_project_identity()', 'execute') as executar
|
||||||
|
union all
|
||||||
|
select r.rolname,
|
||||||
|
has_schema_privilege(r.oid, 'adaptador_guard', 'usage'),
|
||||||
|
has_table_privilege(r.oid, 'adaptador_guard.project_identity', 'select'),
|
||||||
|
has_table_privilege(r.oid, 'adaptador_guard.project_identity', 'update'),
|
||||||
|
has_function_privilege(r.oid, 'adaptador_guard.adapter_project_identity()', 'execute')
|
||||||
|
from pg_catalog.pg_roles r
|
||||||
|
where r.rolname in ('anon', 'authenticated', 'service_role', 'app_user', 'adaptador_web', 'adaptador_worker')
|
||||||
|
order by papel;
|
||||||
|
|
||||||
|
select item
|
||||||
|
from pg_catalog.pg_db_role_setting s
|
||||||
|
cross join lateral unnest(s.setconfig) as item
|
||||||
|
where s.setdatabase = (select oid from pg_catalog.pg_database where datname = current_database())
|
||||||
|
and s.setrole = 0
|
||||||
|
order by item;
|
||||||
+1
-1
@@ -27,7 +27,7 @@ if (jobRunsInline()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
runScript("worker", async () => {
|
runScript("worker", async () => {
|
||||||
const db = await openOperationalDb();
|
const db = await openOperationalDb("worker");
|
||||||
|
|
||||||
// O worker não aplica migrations: isso é passo explícito e aprovado do deploy.
|
// O worker não aplica migrations: isso é passo explícito e aprovado do deploy.
|
||||||
const pending = await pendingMigrations(db);
|
const pending = await pendingMigrations(db);
|
||||||
|
|||||||
@@ -138,6 +138,47 @@ export function formatConfigIssues(issues: EnvIssue[]): string {
|
|||||||
return issues.map((issue) => `${issue.variable}: ${issue.message}`).join("; ");
|
return issues.map((issue) => `${issue.variable}: ${issue.message}`).join("; ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MIGRATION_REF_PATTERN = /^[a-z0-9]{20}$/;
|
||||||
|
const MIGRATION_POOLER_PATTERN = /^aws-[0-9]+-us-west-2\.pooler\.supabase\.com$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuração do `npm run migrate` em homologação. Não inclui web, Redis,
|
||||||
|
* Storage, Auth nem worker: esses papéis ainda não existem quando a migration
|
||||||
|
* administrativa cria o schema.
|
||||||
|
*/
|
||||||
|
export function collectMigrationAdminIssues(env: EnvMap = process.env): EnvIssue[] {
|
||||||
|
const issues: EnvIssue[] = [];
|
||||||
|
if (adapterEnv(env) !== "staging") {
|
||||||
|
issues.push({ variable: "ADAPTER_ENV", message: "migrate administrativo de homologação exige staging" });
|
||||||
|
}
|
||||||
|
const ref = env.ADAPTER_DB_PROJECT_REF?.trim() ?? "";
|
||||||
|
if (!MIGRATION_REF_PATTERN.test(ref)) {
|
||||||
|
issues.push({ variable: "ADAPTER_DB_PROJECT_REF", message: "Reference ID ausente ou fora do formato" });
|
||||||
|
}
|
||||||
|
const pooler = env.ADAPTER_DB_POOLER_HOST?.trim().toLowerCase() ?? "";
|
||||||
|
if (!MIGRATION_POOLER_PATTERN.test(pooler)) {
|
||||||
|
issues.push({ variable: "ADAPTER_DB_POOLER_HOST", message: "host do Session Pooler ausente ou recusado" });
|
||||||
|
}
|
||||||
|
if (!env.ADAPTER_MIGRATION_DATABASE_URL?.trim()) {
|
||||||
|
issues.push({ variable: "ADAPTER_MIGRATION_DATABASE_URL", message: "obrigatória para o migrate administrativo" });
|
||||||
|
}
|
||||||
|
const shim = env.ADAPTER_APPLY_IDENTITY_SHIM?.trim();
|
||||||
|
if (shim !== "0" && shim !== "1") {
|
||||||
|
issues.push({
|
||||||
|
variable: "ADAPTER_APPLY_IDENTITY_SHIM",
|
||||||
|
message: "decisão explícita obrigatória. 1 aplica o shim, 0 usa o schema existente",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recusa a configuração administrativa incompleta. Não abre conexão. */
|
||||||
|
export function assertMigrationAdminEnv(env: EnvMap = process.env): void {
|
||||||
|
const issues = collectMigrationAdminIssues(env);
|
||||||
|
if (issues.length === 0) return;
|
||||||
|
throw new Error(`Configuração administrativa recusada. ${formatConfigIssues(issues)}`);
|
||||||
|
}
|
||||||
|
|
||||||
function skipAssertion(env: EnvMap): boolean {
|
function skipAssertion(env: EnvMap): boolean {
|
||||||
if (env.NEXT_PHASE === "phase-production-build") return true;
|
if (env.NEXT_PHASE === "phase-production-build") return true;
|
||||||
if (env.npm_lifecycle_event === "build") return true;
|
if (env.npm_lifecycle_event === "build") return true;
|
||||||
|
|||||||
+21
-1
@@ -1,4 +1,5 @@
|
|||||||
import { assertDeployedEnv, type EnvMap } from "./env";
|
import { buildPostgresOptions } from "@/infra/db/connection-policy";
|
||||||
|
import { assertDeployedEnv, assertMigrationAdminEnv, isDeployedEnv, type EnvMap } from "./env";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encerra o processo com código diferente de zero quando a configuração é
|
* Encerra o processo com código diferente de zero quando a configuração é
|
||||||
@@ -11,6 +12,25 @@ import { assertDeployedEnv, type EnvMap } from "./env";
|
|||||||
export function exitOnInvalidConfig(env: EnvMap = process.env): void {
|
export function exitOnInvalidConfig(env: EnvMap = process.env): void {
|
||||||
try {
|
try {
|
||||||
assertDeployedEnv(env);
|
assertDeployedEnv(env);
|
||||||
|
if (isDeployedEnv(env) && env.DATABASE_URL?.trim()) {
|
||||||
|
buildPostgresOptions(env.DATABASE_URL, "web", env);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Configuração recusada.";
|
||||||
|
console.error(`[adaptador-planilhas] ${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Barreira do `npm run migrate` em staging. Exige a URL administrativa e o
|
||||||
|
* shim explícito. A URL web, o Redis e o Storage continuam fora desta função.
|
||||||
|
*/
|
||||||
|
export function exitOnInvalidMigrationConfig(env: EnvMap = process.env): void {
|
||||||
|
try {
|
||||||
|
assertMigrationAdminEnv(env);
|
||||||
|
const adminUrl = env.ADAPTER_MIGRATION_DATABASE_URL?.trim();
|
||||||
|
if (adminUrl) buildPostgresOptions(adminUrl, "admin", env);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Configuração recusada.";
|
const message = error instanceof Error ? error.message : "Configuração recusada.";
|
||||||
console.error(`[adaptador-planilhas] ${message}`);
|
console.error(`[adaptador-planilhas] ${message}`);
|
||||||
|
|||||||
+12
-1
@@ -2,8 +2,10 @@ import "server-only";
|
|||||||
import { mkdir } from "node:fs/promises";
|
import { mkdir } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { isDeployedEnv, requireDatabaseUrl } from "@/config/env";
|
import { isDeployedEnv, requireDatabaseUrl } from "@/config/env";
|
||||||
|
import { buildPostgresOptions } from "./connection-policy";
|
||||||
import { createFileDb } from "./pglite";
|
import { createFileDb } from "./pglite";
|
||||||
import { createPostgresDb } from "./postgres";
|
import { createPostgresDb } from "./postgres";
|
||||||
|
import { assertDatabaseGuard } from "./target-guard";
|
||||||
import type { Db } from "./types";
|
import type { Db } from "./types";
|
||||||
|
|
||||||
let singleton: Promise<Db> | null = null;
|
let singleton: Promise<Db> | null = null;
|
||||||
@@ -13,7 +15,16 @@ export async function getDb(): Promise<Db> {
|
|||||||
singleton = (async () => {
|
singleton = (async () => {
|
||||||
// Lança em staging e produção quando DATABASE_URL falta. Nunca cai em PGlite.
|
// Lança em staging e produção quando DATABASE_URL falta. Nunca cai em PGlite.
|
||||||
const databaseUrl = requireDatabaseUrl();
|
const databaseUrl = requireDatabaseUrl();
|
||||||
if (databaseUrl) return createPostgresDb(databaseUrl);
|
if (databaseUrl) {
|
||||||
|
const db = createPostgresDb(databaseUrl, buildPostgresOptions(databaseUrl, "web"));
|
||||||
|
try {
|
||||||
|
await assertDatabaseGuard(db, "web", databaseUrl);
|
||||||
|
return db;
|
||||||
|
} catch (error) {
|
||||||
|
await db.close().catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (isDeployedEnv()) {
|
if (isDeployedEnv()) {
|
||||||
throw new Error("DATABASE_URL é obrigatória fora de development.");
|
throw new Error("DATABASE_URL é obrigatória fora de development.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import type { EnvMap } from "@/config/env";
|
||||||
|
import { isDeployedEnv } from "@/config/env";
|
||||||
|
import { SUPABASE_ROOT_CA_2021 } from "./supabase-root-ca";
|
||||||
|
|
||||||
|
/** Projeto autorizado a receber migrations. Não é segredo. */
|
||||||
|
export const AUTHORIZED_HML_PROJECT = "adaptador-planilha-hml";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forma do Session Pooler IPv4 em us-west-2.
|
||||||
|
*
|
||||||
|
* O índice `aws-0` ou `aws-1` não se deduz da região. O valor `aws-0` chegou a
|
||||||
|
* ser usado como constante, mas era uma suposição, não uma leitura do Dashboard
|
||||||
|
* de `adaptador-planilha-hml`. O Dashboard desse projeto mostra um host que
|
||||||
|
* começa por `aws-1-us-west-2.pooler.supabase.com`. O host esperado entra em
|
||||||
|
* `ADAPTER_DB_POOLER_HOST` e precisa caber neste formato. Qualquer outro
|
||||||
|
* destino, inclusive `*.elopro.app` e o host direto `db.*.supabase.co`, é recusado.
|
||||||
|
* O hostname sozinho não identifica o projeto.
|
||||||
|
*/
|
||||||
|
export const POOLER_HOST_PATTERN = /^aws-[0-9]+-us-west-2\.pooler\.supabase\.com$/;
|
||||||
|
|
||||||
|
export const LOCAL_DATABASE_HOSTS = new Set([
|
||||||
|
"localhost",
|
||||||
|
"127.0.0.1",
|
||||||
|
"::1",
|
||||||
|
"postgres",
|
||||||
|
"db",
|
||||||
|
"host.docker.internal",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type DbPurpose = "web" | "worker" | "admin" | "local";
|
||||||
|
|
||||||
|
export type ParsedDatabaseUrl = {
|
||||||
|
username: string;
|
||||||
|
hostname: string;
|
||||||
|
port: number;
|
||||||
|
database: string;
|
||||||
|
sslmode: string | null;
|
||||||
|
hasPassword: boolean;
|
||||||
|
sessionOption: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class DatabaseGuardError extends Error {
|
||||||
|
readonly code: string;
|
||||||
|
|
||||||
|
constructor(code: string) {
|
||||||
|
super(
|
||||||
|
`Banco recusado (${code}). A identidade do destino não foi comprovada. A operação foi interrompida e exige aprovação manual. Nenhum schema foi alterado.`,
|
||||||
|
);
|
||||||
|
this.name = "DatabaseGuardError";
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference ID do Supabase: 20 caracteres minúsculos, letras ou dígitos.
|
||||||
|
* O valor real sai do Dashboard. Sozinho, não prova qual banco atendeu a sessão.
|
||||||
|
*/
|
||||||
|
export const PROJECT_REF_PATTERN = /^[a-z0-9]{20}$/;
|
||||||
|
|
||||||
|
export function scrubDbError(error: unknown): string {
|
||||||
|
const raw = error instanceof Error ? `${error.name}: ${error.message}` : "erro";
|
||||||
|
return raw
|
||||||
|
.replace(/postgres(?:ql)?:\/\/\S+/gi, "postgres://***")
|
||||||
|
.replace(/\b[\w.+-]+:[^@\s/]+@/g, "***@")
|
||||||
|
.replace(/password=\S+/gi, "password=***");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lê a URL sem devolver senha, usuário completo ou connection string. */
|
||||||
|
export function parseDatabaseUrl(connectionString: string): ParsedDatabaseUrl {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(connectionString);
|
||||||
|
} catch {
|
||||||
|
throw new DatabaseGuardError("url_invalida");
|
||||||
|
}
|
||||||
|
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") {
|
||||||
|
throw new DatabaseGuardError("url_invalida");
|
||||||
|
}
|
||||||
|
const database = decodeURIComponent(url.pathname.replace(/^\//, ""));
|
||||||
|
const port = url.port ? Number(url.port) : 5432;
|
||||||
|
if (!url.hostname || !database || !Number.isInteger(port)) {
|
||||||
|
throw new DatabaseGuardError("url_invalida");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
username: decodeURIComponent(url.username),
|
||||||
|
hostname: url.hostname.toLowerCase(),
|
||||||
|
port,
|
||||||
|
database,
|
||||||
|
sslmode: url.searchParams.get("sslmode")?.toLowerCase() ?? null,
|
||||||
|
hasPassword: url.password.length > 0,
|
||||||
|
sessionOption: url.searchParams.get("options"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocalDatabaseHost(hostname: string): boolean {
|
||||||
|
return LOCAL_DATABASE_HOSTS.has(hostname.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertLocalDatabaseUrl(connectionString: string): void {
|
||||||
|
const parsed = parseDatabaseUrl(connectionString);
|
||||||
|
if (parsed.hostname === "supabase.elopro.app" || parsed.hostname.endsWith(".elopro.app")) {
|
||||||
|
throw new DatabaseGuardError("host_elopro_preservado");
|
||||||
|
}
|
||||||
|
if (parsed.hostname.endsWith(".supabase.com") || parsed.hostname.endsWith(".supabase.co")) {
|
||||||
|
throw new DatabaseGuardError("development_nao_acessa_supabase");
|
||||||
|
}
|
||||||
|
if (!isLocalDatabaseHost(parsed.hostname)) {
|
||||||
|
throw new DatabaseGuardError("development_somente_local");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Host declarado para a homologação. Precisa ser o pooler de us-west-2, nada além disso. */
|
||||||
|
export function expectedPoolerHost(env: EnvMap = process.env): string {
|
||||||
|
const host = env.ADAPTER_DB_POOLER_HOST?.trim().toLowerCase() ?? "";
|
||||||
|
if (!host) throw new DatabaseGuardError("pooler_host_ausente");
|
||||||
|
if (!POOLER_HOST_PATTERN.test(host)) throw new DatabaseGuardError("pooler_host_recusado");
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confere a URL antes de conectar. Não autentica o projeto: isso só o servidor
|
||||||
|
* faz, pela leitura do catálogo. Rejeita TLS fraco e parâmetro de sessão.
|
||||||
|
*/
|
||||||
|
export function assertDeployedUrl(connectionString: string, purpose: DbPurpose, env: EnvMap = process.env): ParsedDatabaseUrl {
|
||||||
|
const parsed = parseDatabaseUrl(connectionString);
|
||||||
|
if (parsed.hostname === "supabase.elopro.app" || parsed.hostname.endsWith(".elopro.app")) {
|
||||||
|
throw new DatabaseGuardError("host_elopro_preservado");
|
||||||
|
}
|
||||||
|
if (parsed.sessionOption?.includes("hml_project")) {
|
||||||
|
throw new DatabaseGuardError("parametro_de_sessao_recusado");
|
||||||
|
}
|
||||||
|
if (parsed.sslmode !== "verify-full") {
|
||||||
|
throw new DatabaseGuardError("tls_inseguro");
|
||||||
|
}
|
||||||
|
if (!parsed.hasPassword) {
|
||||||
|
throw new DatabaseGuardError("credencial_ausente");
|
||||||
|
}
|
||||||
|
const expectedHost = expectedPoolerHost(env);
|
||||||
|
if (parsed.hostname !== expectedHost || parsed.port !== 5432 || parsed.database !== "postgres") {
|
||||||
|
throw new DatabaseGuardError("destino_incompativel");
|
||||||
|
}
|
||||||
|
const ref = env.ADAPTER_DB_PROJECT_REF?.trim() ?? "";
|
||||||
|
if (!PROJECT_REF_PATTERN.test(ref)) {
|
||||||
|
throw new DatabaseGuardError("ref_ausente");
|
||||||
|
}
|
||||||
|
const expectedUser = purpose === "admin" ? "postgres" : purpose === "worker" ? "adaptador_worker" : "adaptador_web";
|
||||||
|
if (parsed.username !== `${expectedUser}.${ref}`) {
|
||||||
|
throw new DatabaseGuardError("usuario_url_diverge");
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePoolMax(purpose: DbPurpose, env: EnvMap = process.env): number {
|
||||||
|
const deployed = isDeployedEnv(env);
|
||||||
|
const defaults: Record<DbPurpose, number> = deployed
|
||||||
|
? { web: 2, worker: 2, admin: 1, local: 1 }
|
||||||
|
: { web: 8, worker: 8, admin: 2, local: 2 };
|
||||||
|
const cap = deployed ? 4 : 8;
|
||||||
|
const raw = env.ADAPTER_DB_POOL_MAX?.trim();
|
||||||
|
const value = raw ? Number(raw) : defaults[purpose];
|
||||||
|
if (!Number.isInteger(value) || value < 1 || value > cap) {
|
||||||
|
throw new DatabaseGuardError("pool_recusado");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PostgresClientOptions = {
|
||||||
|
max: number;
|
||||||
|
prepare: false;
|
||||||
|
connect_timeout: number;
|
||||||
|
idle_timeout: number;
|
||||||
|
max_lifetime: number;
|
||||||
|
ssl?: { rejectUnauthorized: true; ca: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opções do driver. Em ambiente implantado o certificado é verificado sempre.
|
||||||
|
*
|
||||||
|
* `connection` no postgres.js é um parâmetro de startup, não uma opção do
|
||||||
|
* socket. Enviar `family=4` faz o PostgreSQL recusar a sessão com 42704
|
||||||
|
* antes da primeira consulta. O IPv4 continua no host do Session Pooler.
|
||||||
|
*
|
||||||
|
* O pooler apresenta uma cadeia emitida pela raiz pública Supabase Root 2021
|
||||||
|
* CA. Essa raiz não está na confiança padrão do Node, e por isso a verificação
|
||||||
|
* falha com SELF_SIGNED_CERT_IN_CHAIN. `ca` fixa somente essa raiz neste
|
||||||
|
* socket. `rejectUnauthorized` permanece verdadeiro, e o driver continua
|
||||||
|
* conferindo o hostname. A confiança padrão dos outros processos não muda.
|
||||||
|
*/
|
||||||
|
export function buildPostgresOptions(connectionString: string, purpose: DbPurpose, env: EnvMap = process.env): PostgresClientOptions {
|
||||||
|
const deployed = isDeployedEnv(env);
|
||||||
|
if (deployed) assertDeployedUrl(connectionString, purpose, env);
|
||||||
|
else if (connectionString) assertLocalDatabaseUrl(connectionString);
|
||||||
|
const options: PostgresClientOptions = {
|
||||||
|
max: resolvePoolMax(purpose, env),
|
||||||
|
prepare: false,
|
||||||
|
connect_timeout: 10,
|
||||||
|
idle_timeout: 20,
|
||||||
|
max_lifetime: 60 * 30,
|
||||||
|
};
|
||||||
|
if (deployed) options.ssl = { rejectUnauthorized: true, ca: SUPABASE_ROOT_CA_2021 };
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function databaseUrlFor(purpose: DbPurpose, env: EnvMap = process.env): string | null {
|
||||||
|
if (purpose === "local") {
|
||||||
|
if (isDeployedEnv(env)) throw new DatabaseGuardError("seed_dev_fora_de_development");
|
||||||
|
return env.DATABASE_URL?.trim() || null;
|
||||||
|
}
|
||||||
|
if (!isDeployedEnv(env)) return env.DATABASE_URL?.trim() || null;
|
||||||
|
if (purpose === "admin") {
|
||||||
|
const url = env.ADAPTER_MIGRATION_DATABASE_URL?.trim();
|
||||||
|
if (!url) throw new DatabaseGuardError("migration_url_ausente");
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
if (purpose === "worker") {
|
||||||
|
const url = env.ADAPTER_WORKER_DATABASE_URL?.trim();
|
||||||
|
if (!url) throw new DatabaseGuardError("worker_url_ausente");
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
const url = env.DATABASE_URL?.trim();
|
||||||
|
if (!url) throw new DatabaseGuardError("web_url_ausente");
|
||||||
|
return url;
|
||||||
|
}
|
||||||
+36
-18
@@ -16,6 +16,8 @@ import type { Db } from "./types";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const MIGRATIONS_FOLDER = "migrations";
|
const MIGRATIONS_FOLDER = "migrations";
|
||||||
|
/** Trava só durante a transação do migrate. A tabela de controle permanece id e applied_at. */
|
||||||
|
const MIGRATION_LOCK = 872341001;
|
||||||
|
|
||||||
export type MigrateOptions = {
|
export type MigrateOptions = {
|
||||||
/** Aplica o shim de identidade. Padrão: `ADAPTER_APPLY_IDENTITY_SHIM`. */
|
/** Aplica o shim de identidade. Padrão: `ADAPTER_APPLY_IDENTITY_SHIM`. */
|
||||||
@@ -51,27 +53,43 @@ export function assertManifestMatches(files: string[]): void {
|
|||||||
|
|
||||||
export async function migrate(db: Db, options: MigrateOptions = {}): Promise<string[]> {
|
export async function migrate(db: Db, options: MigrateOptions = {}): Promise<string[]> {
|
||||||
const applyShim = options.applyIdentityShim ?? identityShimEnabled();
|
const applyShim = options.applyIdentityShim ?? identityShimEnabled();
|
||||||
|
|
||||||
await db.exec(`
|
|
||||||
create table if not exists public.schema_migrations (
|
|
||||||
id text primary key,
|
|
||||||
applied_at timestamptz not null default now()
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
const files = await listMigrationFiles();
|
const files = await listMigrationFiles();
|
||||||
assertManifestMatches(files);
|
assertManifestMatches(files);
|
||||||
const appliedRows = await db.withService((query) => query.query<{ id: string }>("select id from public.schema_migrations"));
|
const sqlByFile = new Map<string, string>();
|
||||||
const applied = new Set(appliedRows.map((row) => row.id));
|
|
||||||
const ran: string[] = [];
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (applied.has(file)) continue;
|
sqlByFile.set(file, await readFile(path.join(process.cwd(), MIGRATIONS_FOLDER, file), "utf8"));
|
||||||
if (isShimMigration(file) && !applyShim) continue;
|
|
||||||
const sql = await readFile(path.join(process.cwd(), MIGRATIONS_FOLDER, file), "utf8");
|
|
||||||
await db.exec(sql);
|
|
||||||
await db.withService((query) => query.query("insert into public.schema_migrations (id) values ($1)", [file]));
|
|
||||||
ran.push(file);
|
|
||||||
}
|
}
|
||||||
return ran;
|
|
||||||
|
return db.withTransaction(async (tx) => {
|
||||||
|
await tx.exec(`select pg_catalog.pg_advisory_xact_lock(${MIGRATION_LOCK})`);
|
||||||
|
await tx.exec(`
|
||||||
|
create table if not exists public.schema_migrations (
|
||||||
|
id text primary key,
|
||||||
|
applied_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const appliedRows = await tx.query<{ id: string }>("select id from public.schema_migrations");
|
||||||
|
const applied = new Set(appliedRows.map((row) => row.id));
|
||||||
|
const ran: string[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
if (applied.has(file)) continue;
|
||||||
|
if (isShimMigration(file) && !applyShim) continue;
|
||||||
|
const sql = sqlByFile.get(file);
|
||||||
|
if (!sql) throw new Error(`migration ausente: ${file}`);
|
||||||
|
await tx.exec(sql);
|
||||||
|
await tx.query("insert into public.schema_migrations (id) values ($1)", [file]);
|
||||||
|
ran.push(file);
|
||||||
|
}
|
||||||
|
return ran;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { EXPECTED_MIGRATIONS, SHIM_MIGRATIONS, isShimMigration, pendingMigrations } from "./migration-manifest";
|
export {
|
||||||
|
EXPECTED_MIGRATIONS,
|
||||||
|
SHIM_MIGRATIONS,
|
||||||
|
isShimMigration,
|
||||||
|
pendingMigrations,
|
||||||
|
readMigrationLedger,
|
||||||
|
MigrationLedgerError,
|
||||||
|
} from "./migration-manifest";
|
||||||
|
export type { MigrationLedger } from "./migration-manifest";
|
||||||
@@ -17,6 +17,7 @@ export const EXPECTED_MIGRATIONS = [
|
|||||||
"0002_spreadsheet_adapter.sql",
|
"0002_spreadsheet_adapter.sql",
|
||||||
"0003_rls.sql",
|
"0003_rls.sql",
|
||||||
"0004_audit_append_only.sql",
|
"0004_audit_append_only.sql",
|
||||||
|
"0005_maintenance_roles.sql",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** Migrations que criam o shim local de identidade do Elo Pro. */
|
/** Migrations que criam o shim local de identidade do Elo Pro. */
|
||||||
@@ -28,12 +29,46 @@ export function isShimMigration(file: string): boolean {
|
|||||||
|
|
||||||
export type PendingOptions = { applyIdentityShim?: boolean };
|
export type PendingOptions = { applyIdentityShim?: boolean };
|
||||||
|
|
||||||
|
export type MigrationLedger = { state: "ausente" } | { state: "presente"; ids: string[] };
|
||||||
|
|
||||||
|
/** A leitura do histórico falhou. Não é o mesmo que tabela ausente. */
|
||||||
|
export class MigrationLedgerError extends Error {
|
||||||
|
readonly code = "historico_indisponivel";
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("historico_indisponivel");
|
||||||
|
this.name = "MigrationLedgerError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lê o histórico sem criá-lo. Tabela ausente é um estado explícito. Falha de
|
||||||
|
* permissão, conexão ou consulta interrompe: não vira lista vazia.
|
||||||
|
*/
|
||||||
|
export async function readMigrationLedger(db: Db): Promise<MigrationLedger> {
|
||||||
|
let presence: { name: string | null }[];
|
||||||
|
try {
|
||||||
|
presence = await db.withService((query) =>
|
||||||
|
query.query<{ name: string | null }>("select to_regclass('public.schema_migrations')::text as name"),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
throw new MigrationLedgerError();
|
||||||
|
}
|
||||||
|
if (!presence[0]?.name) return { state: "ausente" };
|
||||||
|
try {
|
||||||
|
const rows = await db.withService((query) =>
|
||||||
|
query.query<{ id: string }>("select id from public.schema_migrations order by id"),
|
||||||
|
);
|
||||||
|
return { state: "presente", ids: rows.map((row) => row.id) };
|
||||||
|
} catch {
|
||||||
|
throw new MigrationLedgerError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Migrations esperadas que ainda não constam em `schema_migrations`. */
|
/** Migrations esperadas que ainda não constam em `schema_migrations`. */
|
||||||
export async function pendingMigrations(db: Db, options: PendingOptions = {}): Promise<string[]> {
|
export async function pendingMigrations(db: Db, options: PendingOptions = {}): Promise<string[]> {
|
||||||
const applyShim = options.applyIdentityShim ?? identityShimEnabled();
|
const applyShim = options.applyIdentityShim ?? identityShimEnabled();
|
||||||
const appliedRows = await db
|
const ledger = await readMigrationLedger(db);
|
||||||
.withService((query) => query.query<{ id: string }>("select id from public.schema_migrations"))
|
const applied = new Set(ledger.state === "presente" ? ledger.ids : []);
|
||||||
.catch(() => [] as { id: string }[]);
|
|
||||||
const applied = new Set(appliedRows.map((row) => row.id));
|
|
||||||
return EXPECTED_MIGRATIONS.filter((file) => !applied.has(file) && (applyShim || !isShimMigration(file)));
|
return EXPECTED_MIGRATIONS.filter((file) => !applied.has(file) && (applyShim || !isShimMigration(file)));
|
||||||
}
|
}
|
||||||
+23
-1
@@ -1,5 +1,5 @@
|
|||||||
import { PGlite } from "@electric-sql/pglite";
|
import { PGlite } from "@electric-sql/pglite";
|
||||||
import { createMutex, type Db, type Query, type Row, assertUuid } from "./types";
|
import { createMutex, type Db, type Query, type Row, type Tx, assertUuid } from "./types";
|
||||||
|
|
||||||
type PgClient = {
|
type PgClient = {
|
||||||
exec(sql: string): Promise<unknown>;
|
exec(sql: string): Promise<unknown>;
|
||||||
@@ -44,6 +44,28 @@ export function wrapPglite(client: PgClient): Db {
|
|||||||
close() {
|
close() {
|
||||||
return client.close();
|
return client.close();
|
||||||
},
|
},
|
||||||
|
withTransaction(fn) {
|
||||||
|
return exclusive(async () => {
|
||||||
|
await client.exec("begin");
|
||||||
|
const tx: Tx = {
|
||||||
|
exec(sql) {
|
||||||
|
return client.exec(sql).then(() => undefined);
|
||||||
|
},
|
||||||
|
async query<T = Row>(sql: string, params: unknown[] = []) {
|
||||||
|
const result = await client.query(sql, params);
|
||||||
|
return result.rows as T[];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const value = await fn(tx);
|
||||||
|
await client.exec("commit");
|
||||||
|
return value;
|
||||||
|
} catch (error) {
|
||||||
|
await client.exec("rollback");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import { assertUuid, type Db, type Query, type Row } from "./types";
|
import type { PostgresClientOptions } from "./connection-policy";
|
||||||
|
import { assertUuid, type Db, type Query, type Row, type Tx } from "./types";
|
||||||
|
|
||||||
export function createPostgresDb(connectionString: string): Db {
|
export function createPostgresDb(connectionString: string, options?: PostgresClientOptions): Db {
|
||||||
const sql = postgres(connectionString, { max: 8, prepare: false });
|
const sql = postgres(connectionString, options ?? { max: 8, prepare: false });
|
||||||
const serviceQuery: Query = {
|
const serviceQuery: Query = {
|
||||||
async query<T = Row>(text: string, params: unknown[] = []) {
|
async query<T = Row>(text: string, params: unknown[] = []) {
|
||||||
return sql.unsafe(text, params as never[]) as unknown as T[];
|
return sql.unsafe(text, params as never[]) as unknown as T[];
|
||||||
@@ -32,5 +33,19 @@ export function createPostgresDb(connectionString: string): Db {
|
|||||||
async close() {
|
async close() {
|
||||||
await sql.end();
|
await sql.end();
|
||||||
},
|
},
|
||||||
|
async withTransaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T> {
|
||||||
|
const result = await sql.begin(async (tx) => {
|
||||||
|
const adapter: Tx = {
|
||||||
|
async exec(text: string) {
|
||||||
|
await tx.unsafe(text);
|
||||||
|
},
|
||||||
|
async query<R = Row>(text: string, params: unknown[] = []) {
|
||||||
|
return tx.unsafe(text, params as never[]) as unknown as R[];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return fn(adapter);
|
||||||
|
});
|
||||||
|
return result as T;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { isDeployedEnv, type EnvMap } from "@/config/env";
|
||||||
|
import { AUTHORIZED_HML_PROJECT, PROJECT_REF_PATTERN, DatabaseGuardError } from "./connection-policy";
|
||||||
|
import type { Db } from "./types";
|
||||||
|
|
||||||
|
export type IdentityInstallResult = "criada" | "inalterada";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autorização explícita da gravação. Migrate, migrate:check, seed e deploy
|
||||||
|
* não definem esta variável e não importam este módulo.
|
||||||
|
*/
|
||||||
|
export function assertInitAuthorized(env: EnvMap = process.env): void {
|
||||||
|
if (env.ADAPTER_INIT_PROJECT_IDENTITY !== "1") throw new DatabaseGuardError("init_nao_autorizada");
|
||||||
|
if (!isDeployedEnv(env)) throw new DatabaseGuardError("init_fora_de_homologacao");
|
||||||
|
}
|
||||||
|
|
||||||
|
type StoredIdentity =
|
||||||
|
| { kind: "ausente" }
|
||||||
|
| { kind: "presente"; projectName: string; projectRef: string }
|
||||||
|
| { kind: "inconsistente" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fecha privilégios operacionais. Web e worker ficam só com USAGE e EXECUTE
|
||||||
|
* da função de leitura. Os demais papéis ficam sem acesso.
|
||||||
|
*/
|
||||||
|
const LOCK_BODY = `
|
||||||
|
revoke all on schema adaptador_guard from public;
|
||||||
|
revoke all on table adaptador_guard.project_identity from public;
|
||||||
|
revoke all on all functions in schema adaptador_guard from public;
|
||||||
|
alter default privileges in schema adaptador_guard revoke all on tables from public;
|
||||||
|
alter default privileges in schema adaptador_guard revoke execute on functions from public;
|
||||||
|
|
||||||
|
if exists (select 1 from pg_catalog.pg_roles where rolname = 'adaptador_web') then
|
||||||
|
revoke all on schema adaptador_guard from adaptador_web;
|
||||||
|
revoke all on table adaptador_guard.project_identity from adaptador_web;
|
||||||
|
revoke all on all functions in schema adaptador_guard from adaptador_web;
|
||||||
|
grant usage on schema adaptador_guard to adaptador_web;
|
||||||
|
grant execute on function adaptador_guard.adapter_project_identity() to adaptador_web;
|
||||||
|
end if;
|
||||||
|
if exists (select 1 from pg_catalog.pg_roles where rolname = 'adaptador_worker') then
|
||||||
|
revoke all on schema adaptador_guard from adaptador_worker;
|
||||||
|
revoke all on table adaptador_guard.project_identity from adaptador_worker;
|
||||||
|
revoke all on all functions in schema adaptador_guard from adaptador_worker;
|
||||||
|
grant usage on schema adaptador_guard to adaptador_worker;
|
||||||
|
grant execute on function adaptador_guard.adapter_project_identity() to adaptador_worker;
|
||||||
|
end if;
|
||||||
|
if exists (select 1 from pg_catalog.pg_roles where rolname = 'anon') then
|
||||||
|
revoke all on schema adaptador_guard from anon;
|
||||||
|
revoke all on table adaptador_guard.project_identity from anon;
|
||||||
|
revoke all on all functions in schema adaptador_guard from anon;
|
||||||
|
end if;
|
||||||
|
if exists (select 1 from pg_catalog.pg_roles where rolname = 'authenticated') then
|
||||||
|
revoke all on schema adaptador_guard from authenticated;
|
||||||
|
revoke all on table adaptador_guard.project_identity from authenticated;
|
||||||
|
revoke all on all functions in schema adaptador_guard from authenticated;
|
||||||
|
end if;
|
||||||
|
if exists (select 1 from pg_catalog.pg_roles where rolname = 'service_role') then
|
||||||
|
revoke all on schema adaptador_guard from service_role;
|
||||||
|
revoke all on table adaptador_guard.project_identity from service_role;
|
||||||
|
revoke all on all functions in schema adaptador_guard from service_role;
|
||||||
|
end if;
|
||||||
|
if exists (select 1 from pg_catalog.pg_roles where rolname = 'app_user') then
|
||||||
|
revoke all on schema adaptador_guard from app_user;
|
||||||
|
revoke all on table adaptador_guard.project_identity from app_user;
|
||||||
|
revoke all on all functions in schema adaptador_guard from app_user;
|
||||||
|
end if;
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** SQL da primeira gravação. Um único bloco: se falhar, o schema inteiro é desfeito. */
|
||||||
|
export function projectIdentityDefinitionSql(projectRef: string): string {
|
||||||
|
if (!PROJECT_REF_PATTERN.test(projectRef)) throw new DatabaseGuardError("ref_ausente");
|
||||||
|
return `
|
||||||
|
do $identity$
|
||||||
|
begin
|
||||||
|
create schema adaptador_guard;
|
||||||
|
revoke all on schema adaptador_guard from public;
|
||||||
|
|
||||||
|
create table adaptador_guard.project_identity (
|
||||||
|
singleton boolean primary key default true check (singleton),
|
||||||
|
project_name text not null,
|
||||||
|
project_ref text not null,
|
||||||
|
constraint project_identity_name check (project_name = '${AUTHORIZED_HML_PROJECT}'),
|
||||||
|
constraint project_identity_ref check (project_ref ~ '^[a-z0-9]{20}$')
|
||||||
|
);
|
||||||
|
revoke all on table adaptador_guard.project_identity from public;
|
||||||
|
alter table adaptador_guard.project_identity enable row level security;
|
||||||
|
alter table adaptador_guard.project_identity force row level security;
|
||||||
|
comment on table adaptador_guard.project_identity is
|
||||||
|
'Identidade persistente do projeto de homologacao. Nao e prova criptografica.';
|
||||||
|
|
||||||
|
create function adaptador_guard.reject_identity_change()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security invoker
|
||||||
|
set search_path = pg_catalog
|
||||||
|
as $body$
|
||||||
|
begin
|
||||||
|
if tg_op <> 'INSERT' then
|
||||||
|
raise exception 'identidade imutavel' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
if session_user <> 'postgres' and session_user !~ '^postgres[.][a-z0-9]{20}$' then
|
||||||
|
raise exception 'identidade protegida' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$body$;
|
||||||
|
|
||||||
|
create trigger project_identity_guard
|
||||||
|
before insert or update or delete on adaptador_guard.project_identity
|
||||||
|
for each row execute function adaptador_guard.reject_identity_change();
|
||||||
|
|
||||||
|
create function adaptador_guard.adapter_project_identity()
|
||||||
|
returns table (project_name text, project_ref text)
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = pg_catalog
|
||||||
|
as $body$
|
||||||
|
select i.project_name, i.project_ref
|
||||||
|
from adaptador_guard.project_identity i
|
||||||
|
$body$;
|
||||||
|
|
||||||
|
revoke all on function adaptador_guard.adapter_project_identity() from public;
|
||||||
|
revoke all on function adaptador_guard.reject_identity_change() from public;
|
||||||
|
|
||||||
|
insert into adaptador_guard.project_identity (project_name, project_ref)
|
||||||
|
values ('${AUTHORIZED_HML_PROJECT}', '${projectRef}');
|
||||||
|
${LOCK_BODY}
|
||||||
|
end
|
||||||
|
$identity$;
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GRANT_SQL = `
|
||||||
|
do $grant$
|
||||||
|
begin
|
||||||
|
${LOCK_BODY}
|
||||||
|
end
|
||||||
|
$grant$;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SHAPE_SQL = `
|
||||||
|
select kind, name
|
||||||
|
from (
|
||||||
|
select 'missing'::text as kind, 'project_identity'::text as name
|
||||||
|
where exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard')
|
||||||
|
and not exists (
|
||||||
|
select 1
|
||||||
|
from pg_catalog.pg_class c
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
||||||
|
where n.nspname = 'adaptador_guard' and c.relname = 'project_identity' and c.relkind = 'r'
|
||||||
|
)
|
||||||
|
union all
|
||||||
|
select 'missing', 'adapter_project_identity'
|
||||||
|
where exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard')
|
||||||
|
and not exists (
|
||||||
|
select 1
|
||||||
|
from pg_catalog.pg_proc p
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
||||||
|
where n.nspname = 'adaptador_guard' and p.proname = 'adapter_project_identity'
|
||||||
|
)
|
||||||
|
union all
|
||||||
|
select 'missing', 'reject_identity_change'
|
||||||
|
where exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard')
|
||||||
|
and not exists (
|
||||||
|
select 1
|
||||||
|
from pg_catalog.pg_proc p
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
||||||
|
where n.nspname = 'adaptador_guard' and p.proname = 'reject_identity_change'
|
||||||
|
)
|
||||||
|
union all
|
||||||
|
select 'unknown_relation', c.relname
|
||||||
|
from pg_catalog.pg_class c
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
||||||
|
where n.nspname = 'adaptador_guard'
|
||||||
|
and not (
|
||||||
|
(c.relname = 'project_identity' and c.relkind = 'r')
|
||||||
|
or (
|
||||||
|
c.relkind = 'i'
|
||||||
|
and exists (
|
||||||
|
select 1
|
||||||
|
from pg_catalog.pg_index i
|
||||||
|
join pg_catalog.pg_class t on t.oid = i.indrelid
|
||||||
|
where i.indexrelid = c.oid
|
||||||
|
and t.relname = 'project_identity'
|
||||||
|
and t.relnamespace = n.oid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
union all
|
||||||
|
select 'unknown_function', p.proname
|
||||||
|
from pg_catalog.pg_proc p
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
||||||
|
where n.nspname = 'adaptador_guard'
|
||||||
|
and p.proname not in ('adapter_project_identity', 'reject_identity_change')
|
||||||
|
union all
|
||||||
|
select 'unknown_type', t.typname
|
||||||
|
from pg_catalog.pg_type t
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = t.typnamespace
|
||||||
|
where n.nspname = 'adaptador_guard'
|
||||||
|
and not exists (
|
||||||
|
select 1
|
||||||
|
from pg_catalog.pg_class c
|
||||||
|
where c.oid = t.typrelid
|
||||||
|
and c.relname = 'project_identity'
|
||||||
|
and c.relnamespace = n.oid
|
||||||
|
)
|
||||||
|
and not exists (
|
||||||
|
select 1
|
||||||
|
from pg_catalog.pg_type elem
|
||||||
|
join pg_catalog.pg_class c on c.oid = elem.typrelid
|
||||||
|
where elem.oid = t.typelem
|
||||||
|
and c.relname = 'project_identity'
|
||||||
|
and c.relnamespace = n.oid
|
||||||
|
)
|
||||||
|
union all
|
||||||
|
select 'external', vn.nspname || '.' || v.relname
|
||||||
|
from pg_catalog.pg_depend d
|
||||||
|
join pg_catalog.pg_rewrite rw on rw.oid = d.objid
|
||||||
|
join pg_catalog.pg_class v on v.oid = rw.ev_class
|
||||||
|
join pg_catalog.pg_namespace vn on vn.oid = v.relnamespace
|
||||||
|
join pg_catalog.pg_class t on t.oid = d.refobjid
|
||||||
|
join pg_catalog.pg_namespace tn on tn.oid = t.relnamespace
|
||||||
|
where tn.nspname = 'adaptador_guard'
|
||||||
|
and vn.nspname <> 'adaptador_guard'
|
||||||
|
union all
|
||||||
|
select 'external', n.nspname || '.' || p.proname
|
||||||
|
from pg_catalog.pg_depend d
|
||||||
|
join pg_catalog.pg_proc p on p.oid = d.objid
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
||||||
|
join pg_catalog.pg_class t on t.oid = d.refobjid
|
||||||
|
join pg_catalog.pg_namespace tn on tn.oid = t.relnamespace
|
||||||
|
where tn.nspname = 'adaptador_guard'
|
||||||
|
and n.nspname <> 'adaptador_guard'
|
||||||
|
) findings
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Remove só os objetos conhecidos. Recusa se houver objeto ou dependência fora da lista. */
|
||||||
|
export function projectIdentityRevertSql(): string {
|
||||||
|
return `
|
||||||
|
do $revert$
|
||||||
|
begin
|
||||||
|
if not exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') then
|
||||||
|
raise exception 'marcador_ausente' using errcode = 'P0002';
|
||||||
|
end if;
|
||||||
|
if exists (${SHAPE_SQL}) then
|
||||||
|
raise exception 'reversao_recusada' using errcode = '55000';
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$revert$;
|
||||||
|
drop function adaptador_guard.adapter_project_identity();
|
||||||
|
drop table adaptador_guard.project_identity;
|
||||||
|
drop function adaptador_guard.reject_identity_change();
|
||||||
|
drop schema adaptador_guard restrict;
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readStoredIdentity(db: Db): Promise<StoredIdentity> {
|
||||||
|
let schemaExists = false;
|
||||||
|
let findings: { kind: string; name: string }[] = [];
|
||||||
|
try {
|
||||||
|
const presence = await db.withService((query) =>
|
||||||
|
query.query<{ exists: boolean }>(
|
||||||
|
`select exists(select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') as exists`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
schemaExists = Boolean(presence[0]?.exists);
|
||||||
|
if (schemaExists) {
|
||||||
|
findings = await db.withService((query) => query.query<{ kind: string; name: string }>(SHAPE_SQL));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new DatabaseGuardError("marcador_indisponivel");
|
||||||
|
}
|
||||||
|
if (!schemaExists) return { kind: "ausente" };
|
||||||
|
if (findings.length > 0) return { kind: "inconsistente" };
|
||||||
|
try {
|
||||||
|
const rows = await db.withService((query) =>
|
||||||
|
query.query<{ project_name: string; project_ref: string }>(
|
||||||
|
`select project_name, project_ref from adaptador_guard.project_identity`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (rows.length !== 1) return { kind: "inconsistente" };
|
||||||
|
return { kind: "presente", projectName: rows[0]?.project_name ?? "", projectRef: rows[0]?.project_ref ?? "" };
|
||||||
|
} catch {
|
||||||
|
throw new DatabaseGuardError("marcador_indisponivel");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grava a identidade uma vez, ou só reaplica os privilégios se a linha já concorda.
|
||||||
|
* Recusa schema incompleto, objeto desconhecido e Reference ID diferente.
|
||||||
|
* Não é chamada por migrate, seed nem deploy.
|
||||||
|
*/
|
||||||
|
export async function installProjectIdentity(db: Db, projectRef: string): Promise<IdentityInstallResult> {
|
||||||
|
if (!PROJECT_REF_PATTERN.test(projectRef)) throw new DatabaseGuardError("ref_ausente");
|
||||||
|
const current = await readStoredIdentity(db);
|
||||||
|
if (current.kind === "inconsistente") throw new DatabaseGuardError("marcador_indisponivel");
|
||||||
|
if (current.kind === "presente") {
|
||||||
|
if (current.projectName !== AUTHORIZED_HML_PROJECT || current.projectRef !== projectRef) {
|
||||||
|
throw new DatabaseGuardError("marcador_diverge");
|
||||||
|
}
|
||||||
|
await db.exec(GRANT_SQL);
|
||||||
|
return "inalterada";
|
||||||
|
}
|
||||||
|
await db.exec(projectIdentityDefinitionSql(projectRef));
|
||||||
|
return "criada";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Desfaz a identidade sem CASCADE. Não toca em outro schema. */
|
||||||
|
export async function revertProjectIdentity(db: Db): Promise<void> {
|
||||||
|
try {
|
||||||
|
await db.exec(projectIdentityRevertSql());
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "";
|
||||||
|
if (message.includes("marcador_ausente")) throw new DatabaseGuardError("marcador_ausente");
|
||||||
|
if (message.includes("reversao_recusada")) throw new DatabaseGuardError("reversao_recusada");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* CA raiz pública do Supabase, publicada como prod-ca-2021.
|
||||||
|
* Não contém chave privada. A impressão SHA-256 é conferida nos testes.
|
||||||
|
*/
|
||||||
|
export const SUPABASE_ROOT_CA_2021 = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l
|
||||||
|
dyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh
|
||||||
|
c2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow
|
||||||
|
azELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD
|
||||||
|
YXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug
|
||||||
|
Um9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW
|
||||||
|
QyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q
|
||||||
|
DmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2
|
||||||
|
GtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi
|
||||||
|
cvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4
|
||||||
|
O4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt
|
||||||
|
NaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX
|
||||||
|
uXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt
|
||||||
|
aUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU
|
||||||
|
tVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b
|
||||||
|
VW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6
|
||||||
|
jB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx
|
||||||
|
Cea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2
|
||||||
|
CMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P
|
||||||
|
o/bKiIz+Fq8=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
`;
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import type { EnvMap } from "@/config/env";
|
||||||
|
import { isDeployedEnv } from "@/config/env";
|
||||||
|
import {
|
||||||
|
AUTHORIZED_HML_PROJECT,
|
||||||
|
PROJECT_REF_PATTERN,
|
||||||
|
type DbPurpose,
|
||||||
|
DatabaseGuardError,
|
||||||
|
assertLocalDatabaseUrl,
|
||||||
|
} from "./connection-policy";
|
||||||
|
import type { Db } from "./types";
|
||||||
|
|
||||||
|
export type RoleFacts = {
|
||||||
|
sessionUser: string;
|
||||||
|
rolsuper: boolean | null;
|
||||||
|
rolbypassrls: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectIdentity = {
|
||||||
|
projectName: string | null;
|
||||||
|
projectRef: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminAttestation = RoleFacts & {
|
||||||
|
databaseName: string;
|
||||||
|
hasAuth: boolean;
|
||||||
|
hasStorage: boolean;
|
||||||
|
projectName: string | null;
|
||||||
|
projectRef: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prova administrativa do Supabase gerenciado. O hostname, o nome `postgres`,
|
||||||
|
* um GUC de sessão, a variável de ambiente e o atributo BYPASSRLS, cada um
|
||||||
|
* sozinho, não passam.
|
||||||
|
*
|
||||||
|
* O PostgreSQL 17.11 de `adaptador-planilha-hml` recusou
|
||||||
|
* `ALTER DATABASE ... SET "app.hml_project"` com 42501. Parâmetro personalizado
|
||||||
|
* não é gravável por esse papel, e `app.settings.jwt_exp` permanece intocado.
|
||||||
|
* A identidade persistente fica na tabela `adaptador_guard.project_identity`,
|
||||||
|
* lida pela função `adaptador_guard.adapter_project_identity()`. Um SET de
|
||||||
|
* sessão ou `options=-c` na URL não escreve nessa tabela.
|
||||||
|
*
|
||||||
|
* A função de leitura é SECURITY DEFINER, com `search_path = pg_catalog` e nome
|
||||||
|
* qualificado. Só `adaptador_web` e `adaptador_worker` recebem EXECUTE. A linha
|
||||||
|
* não é prova criptográfica do projeto. Quem tem a credencial administrativa
|
||||||
|
* pode apagar o schema e criar outra linha igual em outro banco. A cópia só
|
||||||
|
* passa se a URL também trouxer o pooler declarado, o usuário `postgres.<ref>`
|
||||||
|
* e o mesmo ref. Sem a linha, ou com divergência, a operação para.
|
||||||
|
*
|
||||||
|
* O Session Pooler pode devolver `session_user` só como `postgres`. O ref
|
||||||
|
* autenticado fica no usuário da URL e o vínculo com este projeto fica na
|
||||||
|
* tabela. Os dois têm de ser o mesmo ref. O nome do banco `postgres` e o host
|
||||||
|
* da região se repetem em outros projetos.
|
||||||
|
*/
|
||||||
|
export function evaluateProjectIdentity(identity: ProjectIdentity, env: EnvMap = process.env): void {
|
||||||
|
const ref = env.ADAPTER_DB_PROJECT_REF?.trim() ?? "";
|
||||||
|
if (!PROJECT_REF_PATTERN.test(ref)) throw new DatabaseGuardError("ref_ausente");
|
||||||
|
if (!identity.projectName || !identity.projectRef) throw new DatabaseGuardError("marcador_ausente");
|
||||||
|
if (identity.projectName !== AUTHORIZED_HML_PROJECT || identity.projectRef !== ref) {
|
||||||
|
throw new DatabaseGuardError("marcador_diverge");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateAdminConnection(
|
||||||
|
attestation: Omit<AdminAttestation, "projectName" | "projectRef">,
|
||||||
|
env: EnvMap = process.env,
|
||||||
|
): void {
|
||||||
|
const ref = env.ADAPTER_DB_PROJECT_REF?.trim() ?? "";
|
||||||
|
if (!PROJECT_REF_PATTERN.test(ref)) throw new DatabaseGuardError("ref_ausente");
|
||||||
|
if (attestation.databaseName !== "postgres") throw new DatabaseGuardError("banco_nome");
|
||||||
|
if (!attestation.hasAuth || !attestation.hasStorage) throw new DatabaseGuardError("schema_supabase_ausente");
|
||||||
|
const session = attestation.sessionUser;
|
||||||
|
if (session !== "postgres" && session !== `postgres.${ref}`) {
|
||||||
|
throw new DatabaseGuardError("usuario_admin_nao_comprovado");
|
||||||
|
}
|
||||||
|
if (attestation.rolsuper === null || attestation.rolbypassrls === null) {
|
||||||
|
throw new DatabaseGuardError("papel_nao_comprovado");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateAdminIdentity(attestation: AdminAttestation, env: EnvMap = process.env): void {
|
||||||
|
evaluateAdminConnection(attestation, env);
|
||||||
|
evaluateProjectIdentity({ projectName: attestation.projectName, projectRef: attestation.projectRef }, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateRuntimeRole(facts: RoleFacts, purpose: "web" | "worker", env: EnvMap = process.env): void {
|
||||||
|
if (facts.rolsuper === null || facts.rolbypassrls === null) {
|
||||||
|
throw new DatabaseGuardError("papel_nao_comprovado");
|
||||||
|
}
|
||||||
|
if (facts.rolsuper || facts.rolbypassrls) {
|
||||||
|
throw new DatabaseGuardError(purpose === "web" ? "web_com_credencial_admin" : "worker_privilegiado");
|
||||||
|
}
|
||||||
|
const expected = purpose === "web" ? "adaptador_web" : "adaptador_worker";
|
||||||
|
const ref = env.ADAPTER_DB_PROJECT_REF?.trim() ?? "";
|
||||||
|
const allowed = new Set([expected]);
|
||||||
|
if (PROJECT_REF_PATTERN.test(ref)) allowed.add(`${expected}.${ref}`);
|
||||||
|
if (!allowed.has(facts.sessionUser)) throw new DatabaseGuardError("papel_recusado");
|
||||||
|
}
|
||||||
|
|
||||||
|
type FactRow = {
|
||||||
|
session_user: string;
|
||||||
|
database_name: string;
|
||||||
|
rolsuper: boolean | null;
|
||||||
|
rolbypassrls: boolean | null;
|
||||||
|
has_auth: boolean;
|
||||||
|
has_storage: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function postgresErrorCode(error: unknown): string | null {
|
||||||
|
if (typeof error === "object" && error !== null && "code" in error && typeof error.code === "string") {
|
||||||
|
return error.code;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Código do driver, sem a mensagem. A mensagem pode conter usuário, host ou senha. */
|
||||||
|
function unavailableIdentityCode(error: unknown): string {
|
||||||
|
const code = postgresErrorCode(error);
|
||||||
|
if (code && /^[A-Z0-9_]{2,48}$/.test(code)) return `identidade_indisponivel:${code}`;
|
||||||
|
return "identidade_indisponivel";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRole(db: Db): Promise<FactRow> {
|
||||||
|
try {
|
||||||
|
const rows = await db.withService((query) =>
|
||||||
|
query.query<FactRow>(
|
||||||
|
`select session_user::text as session_user,
|
||||||
|
current_database()::text as database_name,
|
||||||
|
r.rolsuper,
|
||||||
|
r.rolbypassrls,
|
||||||
|
exists(select 1 from information_schema.schemata where schema_name = 'auth') as has_auth,
|
||||||
|
exists(select 1 from information_schema.schemata where schema_name = 'storage') as has_storage
|
||||||
|
from pg_catalog.pg_roles r
|
||||||
|
where r.rolname = session_user::text`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const facts = rows[0];
|
||||||
|
if (!facts) throw new DatabaseGuardError("papel_nao_comprovado");
|
||||||
|
return facts;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DatabaseGuardError) throw error;
|
||||||
|
throw new DatabaseGuardError(unavailableIdentityCode(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lê a linha gravada no banco. GUC de sessão e variável de ambiente não entram aqui. */
|
||||||
|
export async function readProjectIdentity(db: Db): Promise<ProjectIdentity> {
|
||||||
|
try {
|
||||||
|
const rows = await db.withService((query) =>
|
||||||
|
query.query<{ project_name: string; project_ref: string }>(
|
||||||
|
`select project_name, project_ref from adaptador_guard.adapter_project_identity()`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (rows.length !== 1) throw new DatabaseGuardError(rows.length === 0 ? "marcador_ausente" : "marcador_diverge");
|
||||||
|
return { projectName: rows[0]?.project_name ?? null, projectRef: rows[0]?.project_ref ?? null };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DatabaseGuardError) throw error;
|
||||||
|
const code = postgresErrorCode(error);
|
||||||
|
if (code === "42P01" || code === "3F000") throw new DatabaseGuardError("marcador_ausente");
|
||||||
|
throw new DatabaseGuardError("marcador_indisponivel");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confere o destino administrativo antes da gravação da identidade.
|
||||||
|
* Não cria a tabela e não aceita a ausência dela como sucesso de migration.
|
||||||
|
*/
|
||||||
|
export async function assertAdminTarget(db: Db, env: EnvMap = process.env): Promise<void> {
|
||||||
|
const facts = await readRole(db);
|
||||||
|
evaluateAdminConnection(
|
||||||
|
{
|
||||||
|
sessionUser: facts.session_user,
|
||||||
|
databaseName: facts.database_name,
|
||||||
|
rolsuper: facts.rolsuper,
|
||||||
|
rolbypassrls: facts.rolbypassrls,
|
||||||
|
hasAuth: facts.has_auth,
|
||||||
|
hasStorage: facts.has_storage,
|
||||||
|
},
|
||||||
|
env,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Barreira de destino. Em development só aceita host local. Em staging e
|
||||||
|
* produção a web e o worker recusam SUPERUSER e BYPASSRLS. Migrate, seed,
|
||||||
|
* flag, web e worker só seguem quando a tabela de identidade concorda com o
|
||||||
|
* ref da URL. Nenhum desses comandos grava essa tabela.
|
||||||
|
*/
|
||||||
|
export async function assertDatabaseGuard(
|
||||||
|
db: Db,
|
||||||
|
purpose: DbPurpose,
|
||||||
|
connectionString: string | null,
|
||||||
|
env: EnvMap = process.env,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!isDeployedEnv(env)) {
|
||||||
|
if (connectionString) assertLocalDatabaseUrl(connectionString);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!connectionString) throw new DatabaseGuardError("url_ausente");
|
||||||
|
if (purpose === "local") throw new DatabaseGuardError("seed_dev_fora_de_development");
|
||||||
|
|
||||||
|
const facts = await readRole(db);
|
||||||
|
if (purpose === "admin") {
|
||||||
|
evaluateAdminConnection(
|
||||||
|
{
|
||||||
|
sessionUser: facts.session_user,
|
||||||
|
databaseName: facts.database_name,
|
||||||
|
rolsuper: facts.rolsuper,
|
||||||
|
rolbypassrls: facts.rolbypassrls,
|
||||||
|
hasAuth: facts.has_auth,
|
||||||
|
hasStorage: facts.has_storage,
|
||||||
|
},
|
||||||
|
env,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
evaluateRuntimeRole(
|
||||||
|
{ sessionUser: facts.session_user, rolsuper: facts.rolsuper, rolbypassrls: facts.rolbypassrls },
|
||||||
|
purpose,
|
||||||
|
env,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const identity = await readProjectIdentity(db);
|
||||||
|
evaluateProjectIdentity(identity, env);
|
||||||
|
}
|
||||||
@@ -4,10 +4,17 @@ export interface Query {
|
|||||||
query<T = Row>(sql: string, params?: unknown[]): Promise<T[]>;
|
query<T = Row>(sql: string, params?: unknown[]): Promise<T[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Tx {
|
||||||
|
exec(sql: string): Promise<void>;
|
||||||
|
query<T = Row>(sql: string, params?: unknown[]): Promise<T[]>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Db {
|
export interface Db {
|
||||||
exec(sql: string): Promise<void>;
|
exec(sql: string): Promise<void>;
|
||||||
withUser<T>(userId: string, fn: (query: Query) => Promise<T>): Promise<T>;
|
withUser<T>(userId: string, fn: (query: Query) => Promise<T>): Promise<T>;
|
||||||
withService<T>(fn: (query: Query) => Promise<T>): Promise<T>;
|
withService<T>(fn: (query: Query) => Promise<T>): Promise<T>;
|
||||||
|
/** Uma conexão, uma transação. O callback inteiro confirma ou reverte junto. */
|
||||||
|
withTransaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -297,10 +297,8 @@ export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId:
|
|||||||
export async function cleanupExpired(deps: AppDeps): Promise<number> {
|
export async function cleanupExpired(deps: AppDeps): Promise<number> {
|
||||||
const rows = await deps.db.withService((query) =>
|
const rows = await deps.db.withService((query) =>
|
||||||
query.query<{ id: string; workspace_id: string; temp_storage_key: string; output_csv_key: string | null; output_xlsx_key: string | null }>(
|
query.query<{ id: string; workspace_id: string; temp_storage_key: string; output_csv_key: string | null; output_xlsx_key: string | null }>(
|
||||||
`select s.id, j.workspace_id, s.temp_storage_key, s.output_csv_key, s.output_xlsx_key
|
`select id, workspace_id, temp_storage_key, output_csv_key, output_xlsx_key
|
||||||
from public.spreadsheet_job_sources s
|
from public.adapter_list_expired_sources($1::timestamptz)`,
|
||||||
join public.spreadsheet_jobs j on j.id = s.job_id
|
|
||||||
where s.expires_at < $1::timestamptz and s.status <> 'expired'`,
|
|
||||||
[(deps.now ?? (() => new Date()))().toISOString()],
|
[(deps.now ?? (() => new Date()))().toISOString()],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -313,9 +311,7 @@ export async function cleanupExpired(deps: AppDeps): Promise<number> {
|
|||||||
deps.capture?.(error, { module: "spreadsheet-adapter", workspace_id: row.workspace_id, error_code: "CLEANUP_FAILED" });
|
deps.capture?.(error, { module: "spreadsheet-adapter", workspace_id: row.workspace_id, error_code: "CLEANUP_FAILED" });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await deps.db.withService((query) =>
|
await deps.db.withService((query) => query.query(`select public.adapter_mark_source_expired($1::uuid)`, [row.id]));
|
||||||
query.query(`update public.spreadsheet_job_sources set status = 'expired', output_csv_key = null, output_xlsx_key = null where id = $1`, [row.id]),
|
|
||||||
);
|
|
||||||
removed += 1;
|
removed += 1;
|
||||||
}
|
}
|
||||||
return removed;
|
return removed;
|
||||||
@@ -324,19 +320,14 @@ export async function cleanupExpired(deps: AppDeps): Promise<number> {
|
|||||||
export async function recoverStaleJobs(deps: AppDeps): Promise<number> {
|
export async function recoverStaleJobs(deps: AppDeps): Promise<number> {
|
||||||
const rows = await deps.db.withService((query) =>
|
const rows = await deps.db.withService((query) =>
|
||||||
query.query<{ id: string; user_id: string; workspace_id: string }>(
|
query.query<{ id: string; user_id: string; workspace_id: string }>(
|
||||||
`select id, user_id, workspace_id from public.spreadsheet_jobs where status = 'processing'`,
|
`select id, user_id, workspace_id from public.adapter_list_processing_jobs()`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
let recovered = 0;
|
let recovered = 0;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const owns = await deps.coordinator.ownsLock(lockKey(row.user_id), row.id).catch(() => false);
|
const owns = await deps.coordinator.ownsLock(lockKey(row.user_id), row.id).catch(() => false);
|
||||||
if (owns) continue;
|
if (owns) continue;
|
||||||
await deps.db.withService((query) =>
|
await deps.db.withService((query) => query.query(`select public.adapter_mark_job_interrupted($1::uuid)`, [row.id]));
|
||||||
query.query(
|
|
||||||
`update public.spreadsheet_jobs set status = 'failed', error_code = 'INTERRUPTED', updated_at = now() where id = $1 and status = 'processing'`,
|
|
||||||
[row.id],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
recovered += 1;
|
recovered += 1;
|
||||||
}
|
}
|
||||||
return recovered;
|
return recovered;
|
||||||
@@ -345,7 +336,7 @@ export async function recoverStaleJobs(deps: AppDeps): Promise<number> {
|
|||||||
export async function readJobForWorker(deps: AppDeps, jobId: string) {
|
export async function readJobForWorker(deps: AppDeps, jobId: string) {
|
||||||
return deps.db.withService((query) =>
|
return deps.db.withService((query) =>
|
||||||
query.query<{ user_id: string; workspace_id: string; progress: unknown }>(
|
query.query<{ user_id: string; workspace_id: string; progress: unknown }>(
|
||||||
`select user_id, workspace_id, progress from public.spreadsheet_jobs where id = $1 and status = 'queued'`,
|
`select user_id, workspace_id, progress from public.adapter_read_queued_job($1::uuid)`,
|
||||||
[jobId],
|
[jobId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -353,7 +344,7 @@ export async function readJobForWorker(deps: AppDeps, jobId: string) {
|
|||||||
|
|
||||||
export async function listQueuedJobIds(deps: AppDeps): Promise<string[]> {
|
export async function listQueuedJobIds(deps: AppDeps): Promise<string[]> {
|
||||||
const rows = await deps.db.withService((query) =>
|
const rows = await deps.db.withService((query) =>
|
||||||
query.query<{ id: string }>(`select id from public.spreadsheet_jobs where status = 'queued' order by created_at asc limit 5`),
|
query.query<{ id: string }>(`select id from public.adapter_list_queued_job_ids()`),
|
||||||
);
|
);
|
||||||
return rows.map((row) => row.id);
|
return rows.map((row) => row.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,15 +53,8 @@ async function loadContext(deps: AppDeps, userId: string, workspaceId: string):
|
|||||||
module_enabled: boolean;
|
module_enabled: boolean;
|
||||||
allow_upload: boolean;
|
allow_upload: boolean;
|
||||||
}>(
|
}>(
|
||||||
`select p.display_name, p.is_superadmin, w.name as workspace_name, m.role,
|
`select display_name, is_superadmin, role, workspace_name, module_enabled, allow_upload
|
||||||
coalesce(f.enabled, false) as module_enabled,
|
from public.adapter_worker_context($1, $2)`,
|
||||||
coalesce(s.allow_non_admin_model_upload, false) as allow_upload
|
|
||||||
from public.profiles p
|
|
||||||
join public.workspace_members m on m.user_id = p.id and m.workspace_id = $2
|
|
||||||
join public.workspaces w on w.id = $2
|
|
||||||
left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter'
|
|
||||||
left join public.spreadsheet_adapter_settings s on s.workspace_id = w.id
|
|
||||||
where p.id = $1`,
|
|
||||||
[userId, workspaceId],
|
[userId, workspaceId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Query } from "@/infra/db/types";
|
||||||
|
|
||||||
|
/** Tabelas que as migrations colocam sob RLS + FORCE RLS (migrations/0003_rls.sql). */
|
||||||
|
export const RLS_TABLES = [
|
||||||
|
"audit_log",
|
||||||
|
"profiles",
|
||||||
|
"spreadsheet_adapter_settings",
|
||||||
|
"spreadsheet_job_mappings",
|
||||||
|
"spreadsheet_job_sources",
|
||||||
|
"spreadsheet_jobs",
|
||||||
|
"spreadsheet_models",
|
||||||
|
"workspace_members",
|
||||||
|
"workspace_module_flags",
|
||||||
|
"workspaces",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type RlsRow = { relname: string; relkind: string; relrowsecurity: boolean; relforcerowsecurity: boolean };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Só tabelas comuns e particionadas (`relkind` r/p) do schema public. Sem esse
|
||||||
|
* filtro, `like 'spreadsheet%'` também traz índices (`*_pkey`, relkind `i`),
|
||||||
|
* que nunca têm RLS e fazem a verificação falhar por engano.
|
||||||
|
*/
|
||||||
|
export async function readRlsCatalog(query: Query): Promise<RlsRow[]> {
|
||||||
|
return query.query<RlsRow>(
|
||||||
|
`select c.relname, c.relkind::text as relkind, c.relrowsecurity, c.relforcerowsecurity
|
||||||
|
from pg_class c
|
||||||
|
join pg_namespace n on n.oid = c.relnamespace
|
||||||
|
where n.nspname = 'public'
|
||||||
|
and c.relkind in ('r', 'p')
|
||||||
|
and (c.relname like 'spreadsheet%' or c.relname = any($1::text[]))
|
||||||
|
order by c.relname`,
|
||||||
|
[[...RLS_TABLES]],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createReadyDb } from "../helpers/world";
|
||||||
|
|
||||||
|
describe("papéis de manutenção", () => {
|
||||||
|
it("cria web e worker sem superuser, sem bypass e sem herança", async () => {
|
||||||
|
const db = await createReadyDb();
|
||||||
|
const rows = await db.withService((query) =>
|
||||||
|
query.query<{ rolname: string; rolsuper: boolean; rolbypassrls: boolean; rolinherit: boolean; rolcanlogin: boolean }>(
|
||||||
|
`select rolname, rolsuper, rolbypassrls, rolinherit, rolcanlogin
|
||||||
|
from pg_roles
|
||||||
|
where rolname in ('adaptador_web', 'adaptador_worker', 'app_user')
|
||||||
|
order by rolname`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(rows.map((row) => row.rolname)).toEqual(["adaptador_web", "adaptador_worker", "app_user"]);
|
||||||
|
for (const row of rows) {
|
||||||
|
expect(row.rolsuper, row.rolname).toBe(false);
|
||||||
|
expect(row.rolbypassrls, row.rolname).toBe(false);
|
||||||
|
expect(row.rolcanlogin, row.rolname).toBe(false);
|
||||||
|
}
|
||||||
|
expect(rows.find((row) => row.rolname === "adaptador_web")?.rolinherit).toBe(false);
|
||||||
|
expect(rows.find((row) => row.rolname === "adaptador_worker")?.rolinherit).toBe(false);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("impede a web e o app_user de varrer a fila, e deixa o worker só nas funções", async () => {
|
||||||
|
const db = await createReadyDb();
|
||||||
|
const denied = [
|
||||||
|
`begin; set local role adaptador_web; select * from public.spreadsheet_jobs; commit;`,
|
||||||
|
`begin; set local role app_user; select * from public.adapter_list_queued_job_ids(); commit;`,
|
||||||
|
`begin; set local role adaptador_web; select * from public.adapter_worker_context('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', '11111111-1111-4111-8111-111111111111'); commit;`,
|
||||||
|
];
|
||||||
|
for (const statement of denied) {
|
||||||
|
await expect(db.exec(statement)).rejects.toThrow(/permission denied/i);
|
||||||
|
await db.exec("rollback");
|
||||||
|
}
|
||||||
|
await expect(
|
||||||
|
db.exec(`begin; set local role adaptador_worker; select * from public.adapter_list_queued_job_ids(); commit;`),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
const signature = await db.withService((query) =>
|
||||||
|
query.query<{ result: string; prosecdef: boolean; proconfig: string[] | null }>(
|
||||||
|
`select pg_get_function_result(p.oid) as result, p.prosecdef, p.proconfig
|
||||||
|
from pg_proc p
|
||||||
|
join pg_namespace n on n.oid = p.pronamespace
|
||||||
|
where n.nspname = 'public' and p.proname = 'adapter_read_queued_job'`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(signature[0]?.prosecdef).toBe(true);
|
||||||
|
expect(signature[0]?.proconfig?.join(" ")).toContain("search_path=pg_catalog");
|
||||||
|
expect(signature[0]?.result).not.toMatch(/content|cells|columns_schema|storage_key/i);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mantém SET LOCAL ROLE dentro da transação do usuário", async () => {
|
||||||
|
const db = await createReadyDb();
|
||||||
|
const rows = await db.withUser("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", (query) =>
|
||||||
|
query.query<{ current_user: string }>(`select current_user`),
|
||||||
|
);
|
||||||
|
expect(rows[0]?.current_user).toBe("app_user");
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { createModel } from "@/server/spreadsheet-adapter/service";
|
import { createModel } from "@/server/spreadsheet-adapter/service";
|
||||||
import { ctxOf, createReadyDb, depsOf, IDS } from "../helpers/world";
|
import { ctxOf, createReadyDb, depsOf, IDS } from "../helpers/world";
|
||||||
|
import { readRlsCatalog, RLS_TABLES } from "../helpers/rls-catalog";
|
||||||
|
|
||||||
describe("RLS e permissões", () => {
|
describe("RLS e permissões", () => {
|
||||||
it("impede leitura e escrita entre workspaces", async () => {
|
it("impede leitura e escrita entre workspaces", async () => {
|
||||||
@@ -71,4 +72,16 @@ describe("RLS e permissões", () => {
|
|||||||
expect(rows.every((row) => row.action !== "tamper")).toBe(true);
|
expect(rows.every((row) => row.action !== "tamper")).toBe(true);
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("verifica RLS e FORCE RLS só em tabelas, nunca em índices", async () => {
|
||||||
|
const db = await createReadyDb();
|
||||||
|
const rows = await db.withService(readRlsCatalog);
|
||||||
|
expect(rows.map((row) => row.relname)).toEqual([...RLS_TABLES]);
|
||||||
|
expect(rows.some((row) => row.relname.endsWith("_pkey"))).toBe(false);
|
||||||
|
for (const row of rows) {
|
||||||
|
expect(row.relrowsecurity, `${row.relname} sem RLS`).toBe(true);
|
||||||
|
expect(row.relforcerowsecurity, `${row.relname} sem FORCE RLS`).toBe(true);
|
||||||
|
}
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { createPostgresDb } from "@/infra/db/postgres";
|
import { createPostgresDb } from "@/infra/db/postgres";
|
||||||
import { migrate, pendingMigrations } from "@/infra/db/migrate";
|
import { migrate, pendingMigrations, readMigrationLedger, MigrationLedgerError } from "@/infra/db/migrate";
|
||||||
import type { Db } from "@/infra/db/types";
|
import type { Db } from "@/infra/db/types";
|
||||||
|
import { readRlsCatalog, RLS_TABLES } from "../helpers/rls-catalog";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RLS e isolamento multi-tenant contra PostgreSQL real. A suíte padrão usa
|
* RLS e isolamento multi-tenant contra PostgreSQL real. A suíte padrão usa
|
||||||
@@ -11,6 +12,13 @@ import type { Db } from "@/infra/db/types";
|
|||||||
const url = process.env.ADAPTER_TEST_DATABASE_URL;
|
const url = process.env.ADAPTER_TEST_DATABASE_URL;
|
||||||
const enabled = Boolean(url);
|
const enabled = Boolean(url);
|
||||||
|
|
||||||
|
function roleUrl(base: string, role: string): string {
|
||||||
|
const parsed = new URL(base);
|
||||||
|
parsed.username = role;
|
||||||
|
parsed.password = "";
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
const ALPHA = "11111111-1111-4111-8111-111111111111";
|
const ALPHA = "11111111-1111-4111-8111-111111111111";
|
||||||
const BETA = "22222222-2222-4222-8222-222222222222";
|
const BETA = "22222222-2222-4222-8222-222222222222";
|
||||||
const ANA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
const ANA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||||
@@ -68,14 +76,10 @@ describe.skipIf(!enabled)("PostgreSQL real", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("força RLS em todas as tabelas do módulo", async () => {
|
it("força RLS em todas as tabelas do módulo", async () => {
|
||||||
const rows = await db.withService((query) =>
|
const rows = await db.withService(readRlsCatalog);
|
||||||
query.query<{ relname: string; relrowsecurity: boolean; relforcerowsecurity: boolean }>(
|
expect(rows.map((row) => row.relname)).toEqual([...RLS_TABLES]);
|
||||||
`select relname, relrowsecurity, relforcerowsecurity from pg_class
|
|
||||||
where relname like 'spreadsheet%' or relname in ('profiles','workspaces','workspace_members','audit_log')`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(rows.length).toBeGreaterThan(0);
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
expect(["r", "p"], `${row.relname} não é tabela`).toContain(row.relkind);
|
||||||
expect(row.relrowsecurity, `${row.relname} sem RLS`).toBe(true);
|
expect(row.relrowsecurity, `${row.relname} sem RLS`).toBe(true);
|
||||||
expect(row.relforcerowsecurity, `${row.relname} sem FORCE RLS`).toBe(true);
|
expect(row.relforcerowsecurity, `${row.relname} sem FORCE RLS`).toBe(true);
|
||||||
}
|
}
|
||||||
@@ -111,16 +115,83 @@ describe.skipIf(!enabled)("PostgreSQL real", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("não deixa o papel da aplicação alterar auditoria", async () => {
|
it("não deixa o papel da aplicação alterar auditoria", async () => {
|
||||||
|
// workspace_id é uuid e resource_id é text: cada um tem o seu parâmetro,
|
||||||
|
// senão o Postgres recusa com "inconsistent types deduced for parameter".
|
||||||
await db.withService((query) =>
|
await db.withService((query) =>
|
||||||
query.query(
|
query.query(
|
||||||
`insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result)
|
`insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result)
|
||||||
values ($1,$2,'test.event','workspace',$1,'success')`,
|
values ($1::uuid, $2::uuid, 'test.event', 'workspace', $3::text, 'success')`,
|
||||||
[ALPHA, ANA],
|
[ALPHA, ANA, ALPHA],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A recusa tem de vir da falta de privilégio (42501), não de sintaxe ou tipo.
|
||||||
|
for (const statement of [
|
||||||
|
`update public.audit_log set result = 'error' where workspace_id = $1::uuid`,
|
||||||
|
`delete from public.audit_log where workspace_id = $1::uuid`,
|
||||||
|
]) {
|
||||||
|
await expect(
|
||||||
|
db.withUser(ANA, (query) => query.query(statement, [ALPHA])),
|
||||||
|
).rejects.toMatchObject({ code: "42501", message: expect.stringMatching(/permission denied/i) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.withService((query) =>
|
||||||
|
query.query<{ result: string }>(
|
||||||
|
`select result from public.audit_log where workspace_id = $1::uuid and action = 'test.event'`,
|
||||||
|
[ALPHA],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(rows.length).toBeGreaterThan(0);
|
||||||
|
expect(rows.every((row) => row.result === "success")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mantém web e worker sem bypass e sem varredura direta", async () => {
|
||||||
|
const roles = await db.withService((query) =>
|
||||||
|
query.query<{ rolname: string; rolsuper: boolean; rolbypassrls: boolean }>(
|
||||||
|
`select rolname, rolsuper, rolbypassrls from pg_roles
|
||||||
|
where rolname in ('adaptador_web', 'adaptador_worker', 'app_user')`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(roles).toHaveLength(3);
|
||||||
|
for (const role of roles) {
|
||||||
|
expect(role.rolsuper).toBe(false);
|
||||||
|
expect(role.rolbypassrls).toBe(false);
|
||||||
|
}
|
||||||
await expect(
|
await expect(
|
||||||
db.withUser(ANA, (query) => query.query(`update public.audit_log set result = 'tampered'`)),
|
db.exec(`do $$ begin set local role adaptador_web; perform 1 from public.spreadsheet_jobs; end $$;`),
|
||||||
).rejects.toThrow();
|
).rejects.toMatchObject({ code: "42501" });
|
||||||
|
await expect(
|
||||||
|
db.exec(`do $$ begin set local role app_user; perform * from public.adapter_list_queued_job_ids(); end $$;`),
|
||||||
|
).rejects.toMatchObject({ code: "42501" });
|
||||||
|
await db.exec(`do $$ begin set local role adaptador_worker; perform * from public.adapter_list_queued_job_ids(); end $$;`);
|
||||||
|
const queued = await db.withUser(ANA, (query) => query.query<{ current_user: string }>(`select current_user`));
|
||||||
|
expect(queued[0]?.current_user).toBe("app_user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("separa histórico ausente, íntegro e ilegível", async () => {
|
||||||
|
await db.exec("drop table public.schema_migrations");
|
||||||
|
await expect(readMigrationLedger(db)).resolves.toEqual({ state: "ausente" });
|
||||||
|
const aindaAusente = await db.withService((query) =>
|
||||||
|
query.query<{ name: string | null }>("select to_regclass('public.schema_migrations')::text as name"),
|
||||||
|
);
|
||||||
|
expect(aindaAusente[0]?.name ?? null).toBeNull();
|
||||||
|
await migrate(db, { applyIdentityShim: true });
|
||||||
|
const presente = await readMigrationLedger(db);
|
||||||
|
expect(presente.state).toBe("presente");
|
||||||
|
if (presente.state === "presente") expect(presente.ids).toHaveLength(5);
|
||||||
|
|
||||||
|
await db.exec(`
|
||||||
|
do $$ begin
|
||||||
|
create role historico_leitor nologin nosuperuser nobypassrls;
|
||||||
|
exception when duplicate_object then null;
|
||||||
|
end $$;
|
||||||
|
grant usage on schema public to historico_leitor;
|
||||||
|
alter role historico_leitor login;
|
||||||
|
`);
|
||||||
|
const leitor = createPostgresDb(roleUrl(url as string, "historico_leitor"));
|
||||||
|
await expect(readMigrationLedger(leitor)).rejects.toBeInstanceOf(MigrationLedgerError);
|
||||||
|
await leitor.close();
|
||||||
|
await db.exec("revoke all on schema public from historico_leitor; drop role historico_leitor");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pula o shim quando a decisão é não aplicar", async () => {
|
it("pula o shim quando a decisão é não aplicar", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { createPostgresDb } from "@/infra/db/postgres";
|
||||||
|
import { installProjectIdentity, projectIdentityDefinitionSql, revertProjectIdentity } from "@/infra/db/project-identity";
|
||||||
|
import { migrate } from "@/infra/db/migrate";
|
||||||
|
import { assertAdminTarget, assertDatabaseGuard, readProjectIdentity } from "@/infra/db/target-guard";
|
||||||
|
import type { Db } from "@/infra/db/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identidade persistente contra PostgreSQL 17 descartável.
|
||||||
|
* A suíte só roda com ADAPTER_TEST_DATABASE_URL. Sem essa URL o caso fica
|
||||||
|
* pendente e não conta como aprovação.
|
||||||
|
*/
|
||||||
|
const url = process.env.ADAPTER_TEST_DATABASE_URL;
|
||||||
|
const enabled = Boolean(url);
|
||||||
|
const REF = "abcd1234abcd1234wxyz";
|
||||||
|
const OUTRO_REF = "zzzzzzzzzzzzzzzzzzz1";
|
||||||
|
const env = {
|
||||||
|
ADAPTER_ENV: "staging",
|
||||||
|
ADAPTER_DB_PROJECT_REF: REF,
|
||||||
|
ADAPTER_DB_POOLER_HOST: "aws-1-us-west-2.pooler.supabase.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
|
||||||
|
function roleUrl(role: string): string {
|
||||||
|
const parsed = new URL(url as string);
|
||||||
|
parsed.username = role;
|
||||||
|
parsed.password = "";
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!enabled)("identidade no PostgreSQL 17", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = createPostgresDb(url as string);
|
||||||
|
await db.exec(`
|
||||||
|
drop schema if exists adaptador_guard cascade;
|
||||||
|
drop schema if exists public cascade;
|
||||||
|
create schema public;
|
||||||
|
drop schema if exists auth cascade;
|
||||||
|
create schema auth;
|
||||||
|
drop schema if exists storage cascade;
|
||||||
|
create schema storage;
|
||||||
|
`);
|
||||||
|
await db.exec(`
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if exists (select 1 from pg_catalog.pg_roles where rolname = 'adaptador_web') then
|
||||||
|
drop role adaptador_web;
|
||||||
|
end if;
|
||||||
|
if exists (select 1 from pg_catalog.pg_roles where rolname = 'adaptador_worker') then
|
||||||
|
drop role adaptador_worker;
|
||||||
|
end if;
|
||||||
|
if exists (select 1 from pg_catalog.pg_roles where rolname = 'app_user') then
|
||||||
|
drop role app_user;
|
||||||
|
end if;
|
||||||
|
if exists (select 1 from pg_catalog.pg_roles where rolname = 'ident_owner') then
|
||||||
|
drop role ident_owner;
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
`);
|
||||||
|
await db.exec(`alter database postgres set "app.settings.jwt_exp" = '3600'`);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!db) return;
|
||||||
|
await db.exec(`drop schema if exists adaptador_guard cascade`).catch(() => undefined);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grava a primeira identidade sem papéis web ou worker", async () => {
|
||||||
|
await db.exec(`
|
||||||
|
drop role if exists anon;
|
||||||
|
drop role if exists authenticated;
|
||||||
|
drop role if exists service_role;
|
||||||
|
create role anon nologin nosuperuser nobypassrls;
|
||||||
|
create role authenticated nologin nosuperuser nobypassrls;
|
||||||
|
create role service_role nologin nosuperuser bypassrls;
|
||||||
|
`);
|
||||||
|
try {
|
||||||
|
expect(await installProjectIdentity(db, REF)).toBe("criada");
|
||||||
|
expect(await installProjectIdentity(db, REF)).toBe("inalterada");
|
||||||
|
await expect(installProjectIdentity(db, OUTRO_REF)).rejects.toMatchObject({ code: "marcador_diverge" });
|
||||||
|
const estado = await db.withService((query) =>
|
||||||
|
query.query<{
|
||||||
|
operacional: number;
|
||||||
|
migrations: string | null;
|
||||||
|
flags: string | null;
|
||||||
|
anon_exec: boolean;
|
||||||
|
service_exec: boolean;
|
||||||
|
project_ref: string;
|
||||||
|
}>(
|
||||||
|
`select
|
||||||
|
(select count(*)::int from pg_catalog.pg_roles where rolname in ('adaptador_web', 'adaptador_worker')) as operacional,
|
||||||
|
to_regclass('public.schema_migrations')::text as migrations,
|
||||||
|
to_regclass('public.workspace_module_flags')::text as flags,
|
||||||
|
has_function_privilege('anon', 'adaptador_guard.adapter_project_identity()', 'execute') as anon_exec,
|
||||||
|
has_function_privilege('service_role', 'adaptador_guard.adapter_project_identity()', 'execute') as service_exec,
|
||||||
|
i.project_ref
|
||||||
|
from adaptador_guard.project_identity i`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(estado[0]).toEqual({
|
||||||
|
operacional: 0,
|
||||||
|
migrations: null,
|
||||||
|
flags: null,
|
||||||
|
anon_exec: false,
|
||||||
|
service_exec: false,
|
||||||
|
project_ref: REF,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await revertProjectIdentity(db).catch(async () => {
|
||||||
|
await db.exec(`drop schema if exists adaptador_guard cascade`).catch(() => undefined);
|
||||||
|
});
|
||||||
|
await db.exec(`
|
||||||
|
drop role if exists anon;
|
||||||
|
drop role if exists authenticated;
|
||||||
|
drop role if exists service_role;
|
||||||
|
`).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("não cria a identidade na migration e impede a web de alterá-la", async () => {
|
||||||
|
await db.exec(`create schema adaptador_guard`);
|
||||||
|
await expect(installProjectIdentity(db, REF)).rejects.toMatchObject({ code: "marcador_indisponivel" });
|
||||||
|
const vazio = await db.withService((query) =>
|
||||||
|
query.query<{ relname: string | null }>(
|
||||||
|
`select c.relname
|
||||||
|
from pg_catalog.pg_class c
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
||||||
|
where n.nspname = 'adaptador_guard' and c.relkind = 'r'`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(vazio).toEqual([]);
|
||||||
|
await db.exec(`drop schema adaptador_guard restrict`);
|
||||||
|
|
||||||
|
const original = projectIdentityDefinitionSql(REF);
|
||||||
|
const quebrado = original.replace(
|
||||||
|
/end\s*\$identity\$;\s*$/,
|
||||||
|
"raise exception 'falha_injetada';\nend\n$identity$;\n",
|
||||||
|
);
|
||||||
|
expect(quebrado).toContain("falha_injetada");
|
||||||
|
await expect(db.exec(quebrado)).rejects.toThrow(/falha_injetada/);
|
||||||
|
const aposFalha = await db.withService((query) =>
|
||||||
|
query.query<{ exists: boolean }>(
|
||||||
|
`select exists(select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') as exists`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(aposFalha[0]?.exists).toBe(false);
|
||||||
|
|
||||||
|
await migrate(db, { applyIdentityShim: true });
|
||||||
|
const antes = await db.withService((query) =>
|
||||||
|
query.query<{ exists: boolean }>(
|
||||||
|
`select exists(select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') as exists`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(antes[0]?.exists).toBe(false);
|
||||||
|
await db.exec(`
|
||||||
|
drop owned by adaptador_web, adaptador_worker, app_user;
|
||||||
|
drop role adaptador_web;
|
||||||
|
drop role adaptador_worker;
|
||||||
|
drop role app_user;
|
||||||
|
drop schema public cascade;
|
||||||
|
create schema public;
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(await installProjectIdentity(db, REF)).toBe("criada");
|
||||||
|
await migrate(db, { applyIdentityShim: true });
|
||||||
|
const loginAntes = await db.withService((query) =>
|
||||||
|
query.query<{ rolname: string; rolcanlogin: boolean; rolsuper: boolean; rolbypassrls: boolean }>(
|
||||||
|
`select rolname, rolcanlogin, rolsuper, rolbypassrls
|
||||||
|
from pg_catalog.pg_roles
|
||||||
|
where rolname in ('adaptador_web', 'adaptador_worker', 'app_user')`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(loginAntes).toHaveLength(3);
|
||||||
|
for (const role of loginAntes) {
|
||||||
|
expect(role.rolcanlogin, role.rolname).toBe(false);
|
||||||
|
expect(role.rolsuper, role.rolname).toBe(false);
|
||||||
|
expect(role.rolbypassrls, role.rolname).toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const semGrant = await db.withService((query) =>
|
||||||
|
query.query<{ web: boolean; worker: boolean; app: boolean }>(
|
||||||
|
`select
|
||||||
|
has_function_privilege('adaptador_web', 'adaptador_guard.adapter_project_identity()', 'execute') as web,
|
||||||
|
has_function_privilege('adaptador_worker', 'adaptador_guard.adapter_project_identity()', 'execute') as worker,
|
||||||
|
has_function_privilege('app_user', 'adaptador_guard.adapter_project_identity()', 'execute') as app`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(semGrant[0]).toEqual({ web: false, worker: false, app: false });
|
||||||
|
|
||||||
|
await db.exec(`alter role adaptador_web login; alter role adaptador_worker login`);
|
||||||
|
const webAntes = createPostgresDb(roleUrl("adaptador_web"));
|
||||||
|
await expect(assertDatabaseGuard(webAntes, "web", roleUrl("adaptador_web"), env)).rejects.toMatchObject({
|
||||||
|
code: "marcador_indisponivel",
|
||||||
|
});
|
||||||
|
await webAntes.close();
|
||||||
|
|
||||||
|
expect(await installProjectIdentity(db, REF)).toBe("inalterada");
|
||||||
|
const antesDaSegunda = await db.withService((query) =>
|
||||||
|
query.query<{ project_ref: string; xmin: string }>(
|
||||||
|
`select project_ref, xmin::text as xmin from adaptador_guard.project_identity`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(await installProjectIdentity(db, REF)).toBe("inalterada");
|
||||||
|
const depoisDaSegunda = await db.withService((query) =>
|
||||||
|
query.query<{ project_ref: string; xmin: string }>(
|
||||||
|
`select project_ref, xmin::text as xmin from adaptador_guard.project_identity`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(depoisDaSegunda).toEqual(antesDaSegunda);
|
||||||
|
await expect(installProjectIdentity(db, OUTRO_REF)).rejects.toMatchObject({ code: "marcador_diverge" });
|
||||||
|
const aposRefErrado = await db.withService((query) =>
|
||||||
|
query.query<{ project_ref: string }>(`select project_ref from adaptador_guard.project_identity`),
|
||||||
|
);
|
||||||
|
expect(aposRefErrado[0]?.project_ref).toBe(REF);
|
||||||
|
|
||||||
|
await assertAdminTarget(db, env);
|
||||||
|
await assertDatabaseGuard(db, "admin", url, env);
|
||||||
|
await expect(assertDatabaseGuard(db, "admin", url, { ...env, ADAPTER_DB_PROJECT_REF: OUTRO_REF })).rejects.toMatchObject({
|
||||||
|
code: "marcador_diverge",
|
||||||
|
});
|
||||||
|
|
||||||
|
const web = createPostgresDb(roleUrl("adaptador_web"));
|
||||||
|
const worker = createPostgresDb(roleUrl("adaptador_worker"));
|
||||||
|
try {
|
||||||
|
await assertDatabaseGuard(web, "web", roleUrl("adaptador_web"), env);
|
||||||
|
await assertDatabaseGuard(worker, "worker", roleUrl("adaptador_worker"), env);
|
||||||
|
await expect(
|
||||||
|
web.exec(`update adaptador_guard.project_identity set project_ref = '${OUTRO_REF}'`),
|
||||||
|
).rejects.toMatchObject({ code: "42501" });
|
||||||
|
await expect(worker.exec(`delete from adaptador_guard.project_identity`)).rejects.toMatchObject({ code: "42501" });
|
||||||
|
} finally {
|
||||||
|
await web.close();
|
||||||
|
await worker.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(db.exec(`update adaptador_guard.project_identity set project_name = project_name`)).rejects.toMatchObject({
|
||||||
|
code: "42501",
|
||||||
|
});
|
||||||
|
await expect(db.exec(`delete from adaptador_guard.project_identity`)).rejects.toMatchObject({ code: "42501" });
|
||||||
|
await expect(
|
||||||
|
db.exec(`do $$ begin set local role app_user; perform * from adaptador_guard.adapter_project_identity(); end $$;`),
|
||||||
|
).rejects.toMatchObject({ code: "42501" });
|
||||||
|
|
||||||
|
await db.exec(`
|
||||||
|
create role ident_owner nologin nosuperuser bypassrls;
|
||||||
|
grant usage on schema adaptador_guard to ident_owner;
|
||||||
|
alter table adaptador_guard.project_identity owner to ident_owner;
|
||||||
|
alter function adaptador_guard.adapter_project_identity() owner to ident_owner;
|
||||||
|
`);
|
||||||
|
const comoDono = await db.withService((query) =>
|
||||||
|
query.query<{ project_name: string; project_ref: string; rolsuper: boolean; rolbypassrls: boolean }>(
|
||||||
|
`select i.project_name, i.project_ref, r.rolsuper, r.rolbypassrls
|
||||||
|
from pg_catalog.pg_roles r
|
||||||
|
cross join adaptador_guard.adapter_project_identity() i
|
||||||
|
where r.rolname = 'ident_owner'`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(comoDono[0]).toMatchObject({
|
||||||
|
project_name: "adaptador-planilha-hml",
|
||||||
|
project_ref: REF,
|
||||||
|
rolsuper: false,
|
||||||
|
rolbypassrls: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lida = await readProjectIdentity(db);
|
||||||
|
expect(lida).toEqual({ projectName: "adaptador-planilha-hml", projectRef: REF });
|
||||||
|
|
||||||
|
const marcadores = await db.withService((query) =>
|
||||||
|
query.query<{ item: string }>(
|
||||||
|
`select item
|
||||||
|
from pg_catalog.pg_db_role_setting s
|
||||||
|
cross join lateral unnest(s.setconfig) as item
|
||||||
|
where s.setdatabase = (select oid from pg_catalog.pg_database where datname = current_database())
|
||||||
|
and s.setrole = 0`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const itens = marcadores.map((row) => row.item);
|
||||||
|
expect(itens).toContain("app.settings.jwt_exp=3600");
|
||||||
|
expect(itens.some((item) => item.startsWith("app.hml_project"))).toBe(false);
|
||||||
|
|
||||||
|
const internos = await db.withService((query) =>
|
||||||
|
query.query<{ schema_name: string; total: number }>(
|
||||||
|
`select n.nspname as schema_name, count(c.oid)::int as total
|
||||||
|
from pg_catalog.pg_namespace n
|
||||||
|
left join pg_catalog.pg_class c on c.relnamespace = n.oid and c.relkind in ('r', 'p', 'v', 'm')
|
||||||
|
where n.nspname in ('auth', 'storage')
|
||||||
|
group by n.nspname
|
||||||
|
order by n.nspname`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(internos).toEqual([
|
||||||
|
{ schema_name: "auth", total: 0 },
|
||||||
|
{ schema_name: "storage", total: 0 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const proteção = await db.withService((query) =>
|
||||||
|
query.query<{ relrowsecurity: boolean; relforcerowsecurity: boolean }>(
|
||||||
|
`select c.relrowsecurity, c.relforcerowsecurity
|
||||||
|
from pg_catalog.pg_class c
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
||||||
|
where n.nspname = 'adaptador_guard' and c.relname = 'project_identity'`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(proteção[0]).toEqual({ relrowsecurity: true, relforcerowsecurity: true });
|
||||||
|
|
||||||
|
const definidor = await db.withService((query) =>
|
||||||
|
query.query<{ proname: string; prosecdef: boolean; proconfig: string[] | null }>(
|
||||||
|
`select p.proname, p.prosecdef, p.proconfig
|
||||||
|
from pg_catalog.pg_proc p
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
||||||
|
where n.nspname = 'adaptador_guard'
|
||||||
|
order by p.proname`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(definidor).toEqual([
|
||||||
|
{ proname: "adapter_project_identity", prosecdef: true, proconfig: ["search_path=pg_catalog"] },
|
||||||
|
{ proname: "reject_identity_change", prosecdef: false, proconfig: ["search_path=pg_catalog"] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.exec(`
|
||||||
|
create table adaptador_guard.extra (id int);
|
||||||
|
`);
|
||||||
|
await expect(installProjectIdentity(db, REF)).rejects.toMatchObject({ code: "marcador_indisponivel" });
|
||||||
|
await db.exec(`drop table adaptador_guard.extra`);
|
||||||
|
|
||||||
|
await db.exec(`
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (select 1 from pg_catalog.pg_roles where rolname = 'anon') then
|
||||||
|
create role anon nologin nosuperuser nobypassrls;
|
||||||
|
end if;
|
||||||
|
if not exists (select 1 from pg_catalog.pg_roles where rolname = 'authenticated') then
|
||||||
|
create role authenticated nologin nosuperuser nobypassrls;
|
||||||
|
end if;
|
||||||
|
if not exists (select 1 from pg_catalog.pg_roles where rolname = 'service_role') then
|
||||||
|
create role service_role nologin nosuperuser bypassrls;
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
`);
|
||||||
|
expect(await installProjectIdentity(db, REF)).toBe("inalterada");
|
||||||
|
const privilegios = await db.withService((query) =>
|
||||||
|
query.query<{ papel: string; uso: boolean; leitura: boolean; alteracao: boolean; executar: boolean; criar: boolean }>(
|
||||||
|
`select papel, uso, leitura, alteracao, executar, criar
|
||||||
|
from (
|
||||||
|
select 'public'::text as papel,
|
||||||
|
has_schema_privilege('public', 'adaptador_guard', 'usage') as uso,
|
||||||
|
has_schema_privilege('public', 'adaptador_guard', 'create') as criar,
|
||||||
|
has_table_privilege('public', 'adaptador_guard.project_identity', 'select') as leitura,
|
||||||
|
has_table_privilege('public', 'adaptador_guard.project_identity', 'update') as alteracao,
|
||||||
|
has_function_privilege('public', 'adaptador_guard.adapter_project_identity()', 'execute') as executar
|
||||||
|
union all
|
||||||
|
select r.rolname,
|
||||||
|
has_schema_privilege(r.oid, 'adaptador_guard', 'usage'),
|
||||||
|
has_schema_privilege(r.oid, 'adaptador_guard', 'create'),
|
||||||
|
has_table_privilege(r.oid, 'adaptador_guard.project_identity', 'select'),
|
||||||
|
has_table_privilege(r.oid, 'adaptador_guard.project_identity', 'update'),
|
||||||
|
has_function_privilege(r.oid, 'adaptador_guard.adapter_project_identity()', 'execute')
|
||||||
|
from pg_catalog.pg_roles r
|
||||||
|
where r.rolname in ('anon', 'authenticated', 'service_role', 'app_user', 'adaptador_web', 'adaptador_worker')
|
||||||
|
) privilegio
|
||||||
|
order by papel`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const porPapel = new Map(privilegios.map((row) => [row.papel, row]));
|
||||||
|
for (const papel of ["public", "anon", "authenticated", "service_role", "app_user"]) {
|
||||||
|
expect(porPapel.get(papel), papel).toMatchObject({ uso: false, leitura: false, alteracao: false, executar: false, criar: false });
|
||||||
|
}
|
||||||
|
for (const papel of ["adaptador_web", "adaptador_worker"]) {
|
||||||
|
expect(porPapel.get(papel), papel).toMatchObject({ uso: true, leitura: false, alteracao: false, executar: true, criar: false });
|
||||||
|
}
|
||||||
|
await expect(
|
||||||
|
db.exec(`do $$ begin set local role service_role; perform * from adaptador_guard.adapter_project_identity(); end $$;`),
|
||||||
|
).rejects.toMatchObject({ code: "42501" });
|
||||||
|
await expect(
|
||||||
|
db.exec(`do $$ begin set local role adaptador_web; create table adaptador_guard.extra (id int); end $$;`),
|
||||||
|
).rejects.toMatchObject({ code: "42501" });
|
||||||
|
|
||||||
|
await db.exec(`create view public.identity_leak as select project_name from adaptador_guard.project_identity`);
|
||||||
|
await expect(revertProjectIdentity(db)).rejects.toMatchObject({ code: "reversao_recusada" });
|
||||||
|
const vazou = await db.withService((query) =>
|
||||||
|
query.query<{ nome: string | null }>(`select to_regclass('public.identity_leak')::text as nome`),
|
||||||
|
);
|
||||||
|
expect(vazou[0]?.nome).toBe("identity_leak");
|
||||||
|
const aindaLa = await db.withService((query) =>
|
||||||
|
query.query<{ exists: boolean }>(
|
||||||
|
`select exists(select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') as exists`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(aindaLa[0]?.exists).toBe(true);
|
||||||
|
await db.exec(`drop view public.identity_leak`);
|
||||||
|
await revertProjectIdentity(db);
|
||||||
|
const restante = await db.withService((query) =>
|
||||||
|
query.query<{ nspname: string; profiles: string | null }>(
|
||||||
|
`select n.nspname, to_regclass('public.profiles')::text as profiles
|
||||||
|
from pg_catalog.pg_namespace n
|
||||||
|
where n.nspname in ('public', 'auth', 'storage', 'adaptador_guard')
|
||||||
|
order by n.nspname`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(restante.map((row) => row.nspname)).toEqual(["auth", "public", "storage"]);
|
||||||
|
expect(restante.every((row) => row.profiles === "profiles")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(enabled)("identidade no PostgreSQL 17", () => {
|
||||||
|
it("PENDENTE: defina ADAPTER_TEST_DATABASE_URL para executar", () => {
|
||||||
|
expect(enabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import { X509Certificate } from "node:crypto";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import tls from "node:tls";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
AUTHORIZED_HML_PROJECT,
|
||||||
|
POOLER_HOST_PATTERN,
|
||||||
|
DatabaseGuardError,
|
||||||
|
assertDeployedUrl,
|
||||||
|
assertLocalDatabaseUrl,
|
||||||
|
buildPostgresOptions,
|
||||||
|
expectedPoolerHost,
|
||||||
|
resolvePoolMax,
|
||||||
|
scrubDbError,
|
||||||
|
} from "@/infra/db/connection-policy";
|
||||||
|
import { readFileSync, readdirSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { assertInitAuthorized, projectIdentityDefinitionSql, projectIdentityRevertSql } from "@/infra/db/project-identity";
|
||||||
|
import { SUPABASE_ROOT_CA_2021 } from "@/infra/db/supabase-root-ca";
|
||||||
|
import { assertAdminTarget, evaluateAdminIdentity, evaluateProjectIdentity, evaluateRuntimeRole, type AdminAttestation } from "@/infra/db/target-guard";
|
||||||
|
import type { Db } from "@/infra/db/types";
|
||||||
|
|
||||||
|
const REF = "abcdefghijabcdefghij";
|
||||||
|
const REF_COM_DIGITO = "abcd1234abcd1234wxyz";
|
||||||
|
const DASHBOARD_HOST = "aws-1-us-west-2.pooler.supabase.com";
|
||||||
|
|
||||||
|
function adminUrl(user = `postgres.${REF}`, sslmode = "verify-full", host = DASHBOARD_HOST): string {
|
||||||
|
return `postgres://${user}:segredo@${host}:5432/postgres?sslmode=${sslmode}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attestation(overrides: Partial<AdminAttestation> = {}): AdminAttestation {
|
||||||
|
return {
|
||||||
|
sessionUser: "postgres",
|
||||||
|
databaseName: "postgres",
|
||||||
|
rolsuper: false,
|
||||||
|
rolbypassrls: true,
|
||||||
|
hasAuth: true,
|
||||||
|
hasStorage: true,
|
||||||
|
projectName: AUTHORIZED_HML_PROJECT,
|
||||||
|
projectRef: REF,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = { ADAPTER_ENV: "staging", ADAPTER_DB_PROJECT_REF: REF, ADAPTER_DB_POOLER_HOST: DASHBOARD_HOST };
|
||||||
|
|
||||||
|
describe("barreira do banco de homologação", () => {
|
||||||
|
it("aceita o destino quando o catálogo, o papel e a URL concordam", () => {
|
||||||
|
expect(assertDeployedUrl(adminUrl(), "admin", env).hostname).toBe(DASHBOARD_HOST);
|
||||||
|
expect(() => evaluateAdminIdentity(attestation(), env)).not.toThrow();
|
||||||
|
expect(() => evaluateAdminIdentity(attestation({ sessionUser: `postgres.${REF}` }), env)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconhece o postgres do Supabase gerenciado sem tratar BYPASSRLS como autorização", () => {
|
||||||
|
expect(() => evaluateAdminIdentity(attestation({ rolsuper: false, rolbypassrls: true }), env)).not.toThrow();
|
||||||
|
expect(() =>
|
||||||
|
evaluateAdminIdentity(attestation({ projectName: null, projectRef: null, rolsuper: false, rolbypassrls: true }), env),
|
||||||
|
).toThrow(/marcador_ausente/);
|
||||||
|
expect(() =>
|
||||||
|
evaluateAdminIdentity(attestation({ sessionUser: "supabase_admin", rolsuper: false, rolbypassrls: true }), env),
|
||||||
|
).toThrow(/usuario_admin_nao_comprovado/);
|
||||||
|
expect(() => evaluateAdminIdentity(attestation({ rolsuper: null, rolbypassrls: true }), env)).toThrow(
|
||||||
|
/papel_nao_comprovado/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aceita Reference ID com dígito e recusa GUC ou variável como única prova", () => {
|
||||||
|
const envComDigito = { ...env, ADAPTER_DB_PROJECT_REF: REF_COM_DIGITO };
|
||||||
|
expect(() =>
|
||||||
|
evaluateProjectIdentity({ projectName: AUTHORIZED_HML_PROJECT, projectRef: REF_COM_DIGITO }, envComDigito),
|
||||||
|
).not.toThrow();
|
||||||
|
expect(() => evaluateProjectIdentity({ projectName: null, projectRef: null }, envComDigito)).toThrow(/marcador_ausente/);
|
||||||
|
expect(() => evaluateProjectIdentity({ projectName: AUTHORIZED_HML_PROJECT, projectRef: REF }, envComDigito)).toThrow(
|
||||||
|
/marcador_diverge/,
|
||||||
|
);
|
||||||
|
expect(() => evaluateAdminIdentity(attestation(), { ...env, ADAPTER_DB_PROJECT_REF: "ABCD1234ABCD1234WXYZ" })).toThrow(
|
||||||
|
/ref_ausente/,
|
||||||
|
);
|
||||||
|
expect(() => assertInitAuthorized({ ADAPTER_ENV: "staging" })).toThrow(/init_nao_autorizada/);
|
||||||
|
expect(() => assertInitAuthorized({ ADAPTER_ENV: "development", ADAPTER_INIT_PROJECT_IDENTITY: "1" })).toThrow(
|
||||||
|
/init_fora_de_homologacao/,
|
||||||
|
);
|
||||||
|
expect(() => assertInitAuthorized({ ADAPTER_ENV: "staging", ADAPTER_INIT_PROJECT_IDENTITY: "1" })).not.toThrow();
|
||||||
|
const sql = projectIdentityDefinitionSql(REF_COM_DIGITO);
|
||||||
|
expect(sql).toContain(REF_COM_DIGITO);
|
||||||
|
expect(sql.split("do $identity$")).toHaveLength(2);
|
||||||
|
expect(sql).toContain("security definer");
|
||||||
|
expect(sql).toContain("set search_path = pg_catalog");
|
||||||
|
expect(sql).toContain("revoke all on table adaptador_guard.project_identity from service_role");
|
||||||
|
expect(sql.toLowerCase()).not.toContain("jwt_exp");
|
||||||
|
expect(sql.toLowerCase()).not.toContain("alter database");
|
||||||
|
expect(sql).not.toContain("auth.");
|
||||||
|
expect(sql).not.toContain("storage.");
|
||||||
|
const prepared = readFileSync(path.join(process.cwd(), "scripts/sql/init-project-identity.sql"), "utf8");
|
||||||
|
expect(prepared).toContain("<<PROJECT_REF>>");
|
||||||
|
expect(prepared).toContain("security definer");
|
||||||
|
expect(prepared.toLowerCase()).not.toContain("cascade");
|
||||||
|
const revert = projectIdentityRevertSql();
|
||||||
|
const revertFile = readFileSync(path.join(process.cwd(), "scripts/sql/revert-project-identity.sql"), "utf8");
|
||||||
|
expect(revert).toContain("drop schema adaptador_guard restrict;");
|
||||||
|
expect(revert.toLowerCase()).not.toContain("cascade");
|
||||||
|
expect(revertFile).toContain("drop schema adaptador_guard restrict;");
|
||||||
|
expect(revertFile.toLowerCase()).not.toContain("cascade");
|
||||||
|
const initScript = readFileSync(path.join(process.cwd(), "scripts/init-project-identity.ts"), "utf8");
|
||||||
|
expect(initScript).not.toContain("console.log(databaseUrl");
|
||||||
|
expect(initScript).not.toContain("console.log(process.env");
|
||||||
|
expect(initScript).not.toContain("bootstrapScript");
|
||||||
|
expect(initScript).not.toContain("exitOnInvalidConfig");
|
||||||
|
expect(initScript).not.toMatch(/migrate\(/);
|
||||||
|
expect(initScript).not.toMatch(/from ["'].*seed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exige o host do Dashboard e recusa índice, região ou domínio diferentes", () => {
|
||||||
|
expect(POOLER_HOST_PATTERN.test(DASHBOARD_HOST)).toBe(true);
|
||||||
|
expect(expectedPoolerHost(env)).toBe(DASHBOARD_HOST);
|
||||||
|
expect(() => expectedPoolerHost({ ADAPTER_DB_POOLER_HOST: "aws-0-us-west-2.pooler.supabase.com" })).not.toThrow();
|
||||||
|
expect(() => assertDeployedUrl(adminUrl(`postgres.${REF}`, "verify-full", "aws-0-us-west-2.pooler.supabase.com"), "admin", env)).toThrow(
|
||||||
|
/destino_incompativel/,
|
||||||
|
);
|
||||||
|
expect(() => expectedPoolerHost({})).toThrow(/pooler_host_ausente/);
|
||||||
|
expect(() => expectedPoolerHost({ ADAPTER_DB_POOLER_HOST: "aws-1-eu-central-1.pooler.supabase.com" })).toThrow(
|
||||||
|
/pooler_host_recusado/,
|
||||||
|
);
|
||||||
|
expect(() => expectedPoolerHost({ ADAPTER_DB_POOLER_HOST: "supabase.elopro.app" })).toThrow(/pooler_host_recusado/);
|
||||||
|
expect(() => expectedPoolerHost({ ADAPTER_DB_POOLER_HOST: "db.abcdefghijabcdefghij.supabase.co" })).toThrow(
|
||||||
|
/pooler_host_recusado/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recusa identidade ausente, marcador ausente e marcador divergente", () => {
|
||||||
|
expect(() => evaluateAdminIdentity(attestation(), {})).toThrow(DatabaseGuardError);
|
||||||
|
expect(() => evaluateAdminIdentity(attestation({ projectName: null, projectRef: null }), env)).toThrow(/marcador_ausente/);
|
||||||
|
expect(() => evaluateAdminIdentity(attestation({ projectRef: "zzzzzzzzzzzzzzzzzzzz" }), env)).toThrow(/marcador_diverge/);
|
||||||
|
expect(() => evaluateProjectIdentity({ projectName: "outro-projeto", projectRef: REF }, env)).toThrow(/marcador_diverge/);
|
||||||
|
const migrations = readdirSync(path.join(process.cwd(), "migrations"), { recursive: true }).filter(
|
||||||
|
(file) => typeof file === "string" && file.endsWith(".sql"),
|
||||||
|
);
|
||||||
|
for (const file of migrations) {
|
||||||
|
const sql = readFileSync(path.join(process.cwd(), "migrations", file), "utf8");
|
||||||
|
expect(sql, file).not.toContain("adaptador_guard");
|
||||||
|
}
|
||||||
|
for (const file of ["scripts/migrate.ts", "scripts/seed-staging.ts", "scripts/seed-dev.ts", "scripts/set-flag.ts"]) {
|
||||||
|
expect(readFileSync(path.join(process.cwd(), file), "utf8"), file).not.toContain("installProjectIdentity");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("não aceita só o nome postgres, o host ou um usuário sem o ref", () => {
|
||||||
|
expect(() => evaluateAdminIdentity(attestation({ sessionUser: "postgres", hasAuth: false }), env)).toThrow(
|
||||||
|
/schema_supabase_ausente/,
|
||||||
|
);
|
||||||
|
expect(() => assertDeployedUrl(adminUrl("postgres"), "admin", env)).toThrow(/usuario_url_diverge/);
|
||||||
|
expect(() =>
|
||||||
|
assertDeployedUrl(
|
||||||
|
`postgres://postgres.${REF}:segredo@supabase.elopro.app:5432/postgres?sslmode=verify-full`,
|
||||||
|
"admin",
|
||||||
|
env,
|
||||||
|
),
|
||||||
|
).toThrow(/host_elopro_preservado/);
|
||||||
|
expect(() =>
|
||||||
|
assertDeployedUrl(`postgres://postgres.${REF}:segredo@db.exemplo.supabase.co:5432/postgres?sslmode=verify-full`, "admin", env),
|
||||||
|
).toThrow(/destino_incompativel/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recusa TLS fraco, parâmetro de sessão e pool acima do teto", () => {
|
||||||
|
expect(() => assertDeployedUrl(adminUrl(`postgres.${REF}`, "require"), "admin", env)).toThrow(/tls_inseguro/);
|
||||||
|
expect(() => assertDeployedUrl(adminUrl(`postgres.${REF}`, "disable"), "admin", env)).toThrow(/tls_inseguro/);
|
||||||
|
expect(() =>
|
||||||
|
assertDeployedUrl(`${adminUrl()}&options=-c%20app.hml_project%3Dadaptador-planilha-hml`, "admin", env),
|
||||||
|
).toThrow(/parametro_de_sessao_recusado/);
|
||||||
|
expect(() => resolvePoolMax("web", { ...env, ADAPTER_DB_POOL_MAX: "16" })).toThrow(/pool_recusado/);
|
||||||
|
expect(resolvePoolMax("web", env)).toBe(2);
|
||||||
|
expect(resolvePoolMax("worker", env)).toBe(2);
|
||||||
|
expect(resolvePoolMax("admin", env)).toBe(1);
|
||||||
|
const root = new X509Certificate(SUPABASE_ROOT_CA_2021);
|
||||||
|
expect(root.ca).toBe(true);
|
||||||
|
expect(root.fingerprint256).toBe("80:70:25:AD:50:D4:ED:21:9D:2C:9C:7D:29:9C:00:4F:82:4E:B0:0C:F7:F6:5A:FE:F6:07:D0:7B:72:E6:CA:FA");
|
||||||
|
expect(root.subject).toContain("CN=Supabase Root 2021 CA");
|
||||||
|
expect(SUPABASE_ROOT_CA_2021).not.toContain("PRIVATE KEY");
|
||||||
|
const deployed = buildPostgresOptions(adminUrl(), "admin", env);
|
||||||
|
const production = buildPostgresOptions(adminUrl(), "admin", { ...env, ADAPTER_ENV: "production" });
|
||||||
|
for (const options of [deployed, production]) {
|
||||||
|
expect(options.ssl).toEqual({ rejectUnauthorized: true, ca: SUPABASE_ROOT_CA_2021 });
|
||||||
|
expect(options).not.toHaveProperty("connection");
|
||||||
|
expect(JSON.stringify(options)).not.toContain("family");
|
||||||
|
expect(JSON.stringify(options)).not.toContain("rejectUnauthorized\":false");
|
||||||
|
}
|
||||||
|
const local = buildPostgresOptions("postgres://adaptador:local@127.0.0.1:5432/adaptador", "local", {
|
||||||
|
ADAPTER_ENV: "development",
|
||||||
|
});
|
||||||
|
expect(local).not.toHaveProperty("connection");
|
||||||
|
expect(local.ssl).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a CA pública não libera outro destino nem aceita certificado divergente", async () => {
|
||||||
|
expect(() =>
|
||||||
|
buildPostgresOptions(adminUrl(`postgres.${REF}`, "verify-full", "aws-0-us-west-2.pooler.supabase.com"), "admin", env),
|
||||||
|
).toThrow(/destino_incompativel/);
|
||||||
|
expect(() => assertInitAuthorized({ ADAPTER_ENV: "staging", ADAPTER_DB_PROJECT_REF: REF })).toThrow(/init_nao_autorizada/);
|
||||||
|
const options = buildPostgresOptions(adminUrl(), "admin", env);
|
||||||
|
expect(options.ssl).not.toHaveProperty("checkServerIdentity");
|
||||||
|
expect(options.ssl).not.toHaveProperty("servername");
|
||||||
|
expect(options.ssl?.rejectUnauthorized).toBe(true);
|
||||||
|
|
||||||
|
const dir = mkdtempSync(path.join(tmpdir(), "ca-divergente-"));
|
||||||
|
const keyPath = path.join(dir, "key.pem");
|
||||||
|
const certPath = path.join(dir, "cert.pem");
|
||||||
|
try {
|
||||||
|
execFileSync(
|
||||||
|
"openssl",
|
||||||
|
["req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-keyout", keyPath, "-out", certPath, "-days", "1", "-nodes", "-subj", "/CN=divergente.example"],
|
||||||
|
{ stdio: "ignore" },
|
||||||
|
);
|
||||||
|
const server = tls.createServer({ key: readFileSync(keyPath), cert: readFileSync(certPath) }, (socket) => socket.end());
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("porta_local_ausente");
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const socket = tls.connect({
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: address.port,
|
||||||
|
servername: "divergente.example",
|
||||||
|
rejectUnauthorized: true,
|
||||||
|
ca: SUPABASE_ROOT_CA_2021,
|
||||||
|
});
|
||||||
|
socket.once("secureConnect", () => {
|
||||||
|
socket.end();
|
||||||
|
reject(new Error("certificado_divergente_aceito"));
|
||||||
|
});
|
||||||
|
socket.once("error", (error: NodeJS.ErrnoException) => {
|
||||||
|
expect(error.code).toMatch(/SELF_SIGNED_CERT_IN_CHAIN|DEPTH_ZERO_SELF_SIGNED_CERT|UNABLE_TO_VERIFY_LEAF_SIGNATURE/);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recusa a credencial administrativa na web e BYPASSRLS no worker", () => {
|
||||||
|
expect(() => evaluateRuntimeRole({ sessionUser: "postgres", rolsuper: false, rolbypassrls: true }, "web", env)).toThrow(
|
||||||
|
/web_com_credencial_admin/,
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
evaluateRuntimeRole({ sessionUser: "adaptador_web", rolsuper: false, rolbypassrls: true }, "web", env),
|
||||||
|
).toThrow(/web_com_credencial_admin/);
|
||||||
|
expect(() =>
|
||||||
|
evaluateRuntimeRole({ sessionUser: "adaptador_worker", rolsuper: true, rolbypassrls: false }, "worker", env),
|
||||||
|
).toThrow(/worker_privilegiado/);
|
||||||
|
expect(() => evaluateRuntimeRole({ sessionUser: "postgres", rolsuper: true, rolbypassrls: true }, "web", env)).toThrow(
|
||||||
|
/web_com_credencial_admin/,
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
evaluateRuntimeRole({ sessionUser: `postgres.${REF}`, rolsuper: true, rolbypassrls: false }, "web", env),
|
||||||
|
).toThrow(/web_com_credencial_admin/);
|
||||||
|
expect(() =>
|
||||||
|
evaluateRuntimeRole({ sessionUser: "adaptador_worker", rolsuper: false, rolbypassrls: true }, "worker", env),
|
||||||
|
).toThrow(/worker_privilegiado/);
|
||||||
|
expect(() =>
|
||||||
|
evaluateRuntimeRole({ sessionUser: "adaptador_web", rolsuper: false, rolbypassrls: false }, "web", env),
|
||||||
|
).not.toThrow();
|
||||||
|
expect(() => evaluateProjectIdentity({ projectName: null, projectRef: null }, env)).toThrow(/marcador_ausente/);
|
||||||
|
expect(() =>
|
||||||
|
evaluateRuntimeRole({ sessionUser: `adaptador_worker.${REF}`, rolsuper: false, rolbypassrls: false }, "worker", env),
|
||||||
|
).not.toThrow();
|
||||||
|
expect(() => evaluateRuntimeRole({ sessionUser: "adaptador_web", rolsuper: null, rolbypassrls: false }, "web", env)).toThrow(
|
||||||
|
/papel_nao_comprovado/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("separa o development local do Supabase", () => {
|
||||||
|
expect(() => assertLocalDatabaseUrl("postgres://adaptador:local@postgres:5432/adaptador")).not.toThrow();
|
||||||
|
expect(() => assertLocalDatabaseUrl("postgres://adaptador:local@127.0.0.1:55432/adaptador")).not.toThrow();
|
||||||
|
expect(() => assertLocalDatabaseUrl(adminUrl())).toThrow(/development_nao_acessa_supabase/);
|
||||||
|
expect(() => assertLocalDatabaseUrl("postgres://postgres:local@supabase.elopro.app:5432/postgres")).toThrow(
|
||||||
|
/host_elopro_preservado/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifica a falha de conexão pelo código do driver e omite a mensagem", async () => {
|
||||||
|
const db = {
|
||||||
|
exec: async () => undefined,
|
||||||
|
close: async () => undefined,
|
||||||
|
withUser: async () => {
|
||||||
|
throw new Error("nao");
|
||||||
|
},
|
||||||
|
withService: async () => {
|
||||||
|
throw Object.assign(new Error("postgres://postgres.ref:segredo@host/db family"), { code: "42704" });
|
||||||
|
},
|
||||||
|
} as Db;
|
||||||
|
await expect(assertAdminTarget(db, env)).rejects.toMatchObject({
|
||||||
|
code: "identidade_indisponivel:42704",
|
||||||
|
});
|
||||||
|
await expect(assertAdminTarget(db, env)).rejects.not.toThrow(/segredo/);
|
||||||
|
|
||||||
|
const semCodigo = {
|
||||||
|
...db,
|
||||||
|
withService: async () => {
|
||||||
|
throw new Error("postgres://postgres.ref:segredo@host/db");
|
||||||
|
},
|
||||||
|
} as Db;
|
||||||
|
await expect(assertAdminTarget(semCodigo, env)).rejects.toMatchObject({ code: "identidade_indisponivel" });
|
||||||
|
await expect(assertAdminTarget(semCodigo, env)).rejects.not.toThrow(/segredo/);
|
||||||
|
|
||||||
|
const codigoInseguro = {
|
||||||
|
...db,
|
||||||
|
withService: async () => {
|
||||||
|
throw Object.assign(new Error("falha"), { code: "postgres://segredo" });
|
||||||
|
},
|
||||||
|
} as Db;
|
||||||
|
await expect(assertAdminTarget(codigoInseguro, env)).rejects.toMatchObject({ code: "identidade_indisponivel" });
|
||||||
|
await expect(assertAdminTarget(codigoInseguro, env)).rejects.not.toThrow(/segredo/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("não deixa senha nem URL no erro visível", () => {
|
||||||
|
const scrubbed = scrubDbError(new Error("falha em postgres://postgres.abc:segredo@aws-0-us-west-2.pooler.supabase.com:5432/postgres"));
|
||||||
|
expect(scrubbed).not.toContain("segredo");
|
||||||
|
expect(scrubbed).not.toContain("postgres://postgres");
|
||||||
|
expect(scrubbed).toContain("postgres://***");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { assertDeployedEnv, assertMigrationAdminEnv, collectMigrationAdminIssues } from "@/config/env";
|
||||||
|
import { DatabaseGuardError, buildPostgresOptions } from "@/infra/db/connection-policy";
|
||||||
|
import { SUPABASE_ROOT_CA_2021 } from "@/infra/db/supabase-root-ca";
|
||||||
|
import { evaluateProjectIdentity } from "@/infra/db/target-guard";
|
||||||
|
import { AUTHORIZED_HML_PROJECT } from "@/infra/db/connection-policy";
|
||||||
|
import {
|
||||||
|
MigrationLedgerError,
|
||||||
|
pendingMigrations,
|
||||||
|
readMigrationLedger,
|
||||||
|
} from "@/infra/db/migration-manifest";
|
||||||
|
import { createMemoryDb } from "@/infra/db/pglite";
|
||||||
|
import { migrate } from "@/infra/db/migrate";
|
||||||
|
import type { Db, Query } from "@/infra/db/types";
|
||||||
|
|
||||||
|
const REF = "abcd1234abcd1234wxyz";
|
||||||
|
const OUTRO = "zzzzzzzzzzzzzzzzzzz1";
|
||||||
|
const HOST = "aws-1-us-west-2.pooler.supabase.com";
|
||||||
|
|
||||||
|
function adminUrl(user = `postgres.${REF}`, database = "postgres", host = HOST, sslmode = "verify-full"): string {
|
||||||
|
return `postgres://${user}:segredo@${host}:5432/${database}?sslmode=${sslmode}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminEnv = {
|
||||||
|
ADAPTER_ENV: "staging",
|
||||||
|
ADAPTER_DB_PROJECT_REF: REF,
|
||||||
|
ADAPTER_DB_POOLER_HOST: HOST,
|
||||||
|
ADAPTER_MIGRATION_DATABASE_URL: adminUrl(),
|
||||||
|
ADAPTER_APPLY_IDENTITY_SHIM: "1",
|
||||||
|
};
|
||||||
|
|
||||||
|
function banco(query: Query["query"]): Db {
|
||||||
|
return {
|
||||||
|
exec: async () => undefined,
|
||||||
|
withUser: async (_userId, fn) => fn({ query }),
|
||||||
|
withService: async (fn) => fn({ query }),
|
||||||
|
withTransaction: async (fn) => fn({ exec: async () => undefined, query }),
|
||||||
|
close: async () => undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("migrate administrativo", () => {
|
||||||
|
it("aceita staging só com a configuração administrativa", () => {
|
||||||
|
expect(collectMigrationAdminIssues(adminEnv)).toEqual([]);
|
||||||
|
expect(() => assertMigrationAdminEnv(adminEnv)).not.toThrow();
|
||||||
|
const options = buildPostgresOptions(adminEnv.ADAPTER_MIGRATION_DATABASE_URL, "admin", adminEnv);
|
||||||
|
expect(options.ssl?.rejectUnauthorized).toBe(true);
|
||||||
|
expect(options.ssl?.ca).toBe(SUPABASE_ROOT_CA_2021);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("não exige web, Redis, Storage, login nem TTL", () => {
|
||||||
|
const env = { ...adminEnv };
|
||||||
|
expect(env).not.toHaveProperty("DATABASE_URL");
|
||||||
|
expect(env).not.toHaveProperty("REDIS_URL");
|
||||||
|
expect(env).not.toHaveProperty("SUPABASE_STORAGE_BUCKET");
|
||||||
|
expect(env).not.toHaveProperty("ADAPTER_STAGING_AUTH");
|
||||||
|
expect(env).not.toHaveProperty("SPREADSHEET_TEMP_TTL_SECONDS");
|
||||||
|
expect(() => assertMigrationAdminEnv(env)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recusa Reference ID ausente e divergente", () => {
|
||||||
|
expect(collectMigrationAdminIssues({ ...adminEnv, ADAPTER_DB_PROJECT_REF: "" }).map((issue) => issue.variable)).toContain(
|
||||||
|
"ADAPTER_DB_PROJECT_REF",
|
||||||
|
);
|
||||||
|
expect(() => buildPostgresOptions(adminUrl(`postgres.${OUTRO}`), "admin", adminEnv)).toThrow(DatabaseGuardError);
|
||||||
|
expect(() =>
|
||||||
|
evaluateProjectIdentity({ projectName: AUTHORIZED_HML_PROJECT, projectRef: OUTRO }, adminEnv),
|
||||||
|
).toThrow(/marcador_diverge/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recusa host, banco, usuário e TLS fora do destino", () => {
|
||||||
|
expect(collectMigrationAdminIssues({ ...adminEnv, ADAPTER_DB_POOLER_HOST: "db.exemplo.supabase.co" }).map((item) => item.variable)).toContain(
|
||||||
|
"ADAPTER_DB_POOLER_HOST",
|
||||||
|
);
|
||||||
|
expect(() => buildPostgresOptions(adminUrl(`postgres.${REF}`, "postgres", "aws-9-us-west-2.pooler.supabase.com"), "admin", adminEnv)).toThrow(
|
||||||
|
/destino_incompativel/,
|
||||||
|
);
|
||||||
|
expect(() => buildPostgresOptions(adminUrl(`postgres.${REF}`, "template1"), "admin", adminEnv)).toThrow(/destino_incompativel/);
|
||||||
|
expect(() => buildPostgresOptions(adminUrl(`adaptador_web.${REF}`), "admin", adminEnv)).toThrow(/usuario_url_diverge/);
|
||||||
|
expect(() => buildPostgresOptions(adminUrl(`postgres.${REF}`, "postgres", HOST, "require"), "admin", adminEnv)).toThrow(/tls_inseguro/);
|
||||||
|
expect(() =>
|
||||||
|
buildPostgresOptions(
|
||||||
|
`postgres://postgres.${REF}:segredo@supabase.elopro.app:5432/postgres?sslmode=verify-full`,
|
||||||
|
"admin",
|
||||||
|
{ ...adminEnv, ADAPTER_DB_POOLER_HOST: "supabase.elopro.app" },
|
||||||
|
),
|
||||||
|
).toThrow(/host_elopro_preservado|pooler/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recusa shim ausente e ambiente que não é staging", () => {
|
||||||
|
const semShim = { ...adminEnv } as Record<string, string | undefined>;
|
||||||
|
delete semShim.ADAPTER_APPLY_IDENTITY_SHIM;
|
||||||
|
expect(collectMigrationAdminIssues(semShim).map((issue) => issue.variable)).toContain("ADAPTER_APPLY_IDENTITY_SHIM");
|
||||||
|
expect(collectMigrationAdminIssues({ ...adminEnv, ADAPTER_APPLY_IDENTITY_SHIM: "true" }).map((issue) => issue.variable)).toContain(
|
||||||
|
"ADAPTER_APPLY_IDENTITY_SHIM",
|
||||||
|
);
|
||||||
|
expect(collectMigrationAdminIssues({ ...adminEnv, ADAPTER_ENV: "production" }).map((issue) => issue.variable)).toContain("ADAPTER_ENV");
|
||||||
|
expect(collectMigrationAdminIssues({ ...adminEnv, ADAPTER_ENV: "development" }).map((issue) => issue.variable)).toContain("ADAPTER_ENV");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mantém a aplicação e o worker sujeitos à configuração operacional", () => {
|
||||||
|
expect(() => assertDeployedEnv({ ADAPTER_ENV: "staging", ADAPTER_APPLY_IDENTITY_SHIM: "1" })).toThrow(/DATABASE_URL/);
|
||||||
|
expect(() => assertDeployedEnv({ ADAPTER_ENV: "staging", ADAPTER_APPLY_IDENTITY_SHIM: "1" })).toThrow(/REDIS_URL/);
|
||||||
|
expect(() => assertDeployedEnv({ ADAPTER_ENV: "staging", ADAPTER_APPLY_IDENTITY_SHIM: "1", DATABASE_URL: adminUrl(), REDIS_URL: "redis://host" })).toThrow(
|
||||||
|
/SUPABASE_STORAGE_BUCKET/,
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
assertDeployedEnv({
|
||||||
|
ADAPTER_ENV: "staging",
|
||||||
|
ADAPTER_APPLY_IDENTITY_SHIM: "1",
|
||||||
|
DATABASE_URL: adminUrl(),
|
||||||
|
REDIS_URL: "redis://host",
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: "https://exemplo.supabase.co",
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: "anon",
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY: "service",
|
||||||
|
SUPABASE_STORAGE_BUCKET: "privado",
|
||||||
|
SPREADSHEET_TEMP_TTL_SECONDS: "3600",
|
||||||
|
}),
|
||||||
|
).toThrow(/ADAPTER_STAGING_AUTH/);
|
||||||
|
const worker = readFileSync(path.join(process.cwd(), "scripts", "worker.ts"), "utf8");
|
||||||
|
const migrateScript = readFileSync(path.join(process.cwd(), "scripts", "migrate.ts"), "utf8");
|
||||||
|
expect(worker).toContain('bootstrapScript("worker")');
|
||||||
|
expect(worker).not.toContain("exitOnInvalidMigrationConfig");
|
||||||
|
expect(migrateScript).toContain("exitOnInvalidMigrationConfig");
|
||||||
|
expect(migrateScript).toContain('process.argv.includes("--plan")');
|
||||||
|
expect(migrateScript).toContain('process.argv.includes("--dry-run")');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("histórico do dry-run", () => {
|
||||||
|
it("não transforma falha de leitura em histórico vazio", async () => {
|
||||||
|
const db = banco(async (sql) => {
|
||||||
|
if (sql.includes("to_regclass")) return [{ name: "schema_migrations" }];
|
||||||
|
throw Object.assign(new Error("permission denied"), { code: "42501" });
|
||||||
|
});
|
||||||
|
await expect(readMigrationLedger(db)).rejects.toBeInstanceOf(MigrationLedgerError);
|
||||||
|
await expect(pendingMigrations(db, { applyIdentityShim: true })).rejects.toBeInstanceOf(MigrationLedgerError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("identifica histórico ausente sem criar a tabela", async () => {
|
||||||
|
const db = await createMemoryDb();
|
||||||
|
await expect(readMigrationLedger(db)).resolves.toEqual({ state: "ausente" });
|
||||||
|
const ainda = await db.withService((query) =>
|
||||||
|
query.query<{ name: string | null }>("select to_regclass('public.schema_migrations')::text as name"),
|
||||||
|
);
|
||||||
|
expect(ainda[0]?.name ?? null).toBeNull();
|
||||||
|
await expect(pendingMigrations(db, { applyIdentityShim: true })).resolves.toHaveLength(5);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lê o histórico íntegro depois da aplicação local", async () => {
|
||||||
|
const db = await createMemoryDb();
|
||||||
|
await migrate(db, { applyIdentityShim: true });
|
||||||
|
const ledger = await readMigrationLedger(db);
|
||||||
|
expect(ledger).toEqual({
|
||||||
|
state: "presente",
|
||||||
|
ids: [
|
||||||
|
"0001_platform_shim.sql",
|
||||||
|
"0002_spreadsheet_adapter.sql",
|
||||||
|
"0003_rls.sql",
|
||||||
|
"0004_audit_append_only.sql",
|
||||||
|
"0005_maintenance_roles.sql",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await expect(pendingMigrations(db, { applyIdentityShim: true })).resolves.toEqual([]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,6 +32,8 @@
|
|||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
"src/**/*.ts",
|
"src/**/*.ts",
|
||||||
"src/**/*.tsx",
|
"src/**/*.tsx",
|
||||||
|
"scripts/smoke-local.ts",
|
||||||
|
"scripts/rollback-local-probe.ts",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
".next/dev/types/**/*.ts"
|
".next/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in new issue
Block a user