Files
adaptador-planilhas/.github/scripts/storage-permissions.sh
Ferraz Fagundes 2be97ea969
ci / lint, typecheck, testes e build (push) Has been cancelled
ci / Postgres e Redis reais (push) Has been cancelled
ci / imagem Docker Linux (push) Has been cancelled
fix(docker): inicializar permissoes do storage com seguranca
2026-10-16 23:47:38 -03:00

186 lines
9.6 KiB
Bash
Executable File

#!/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)"