Files
adaptador-planilhas/docs/spreadsheet-adapter/23-storage-permissions.md
T
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

116 lines
6.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).