Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,21 @@ POSTGRES_PORT=5432
# ---------------------------------------------------------------------------
# MinIO
# ---------------------------------------------------------------------------
# Credencial de administracao. NAO e a que a aplicacao usa: serve para criar buckets, politica e a
# conta de servico, e para entrar no console. Guarde no cofre de senhas e nao a distribua.
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=troque-em-producao
MINIO_PORT=9000

# Conta de servico da API. Alcanca apenas objetos dos dois buckets abaixo — nao cria bucket, nao apaga
# bucket, nao administra o MinIO. E o que limita o estrago de um .env exposto.
# Gere com: openssl rand -base64 24
MINIO_APP_USER=econtabil-app
MINIO_APP_PASSWORD=troque-em-producao-tambem

# A porta 9000 (API do MinIO) nao e publicada: o acervo so e alcancavel pela rede interna do Compose.
# O console fica preso ao loopback da maquina; para acessar de fora, use tunel SSH.
MINIO_CONSOLE_PORT=9001

MINIO_BUCKET_XMLS=xmls
MINIO_BUCKET_CERTIFICADOS=certificados

Expand Down
2 changes: 2 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
- [ ] `ECONTABIL_CRYPTO_KEY`, `JWT_KEY` e senhas geradas para o ambiente — **nunca** as do exemplo
- [ ] `ECONTABIL_CRYPTO_KEY` guardada em cofre de senhas, fora do servidor
- [ ] `CORS_ORIGENS` com a origem exata do frontend; vazio significa política fechada
- [ ] `MINIO_APP_PASSWORD` gerada para o ambiente, diferente da root — a API nunca usa a credencial de administração
- [ ] Nenhuma porta de banco ou de object storage publicada além do necessário (`docker compose ps`)
- [ ] TLS terminando no proxy reverso, com `Api__RedirecionarHttps` coerente
- [ ] Administrador inicial criado e a senha do `.env` trocada pelo endpoint de usuários

Expand Down
35 changes: 35 additions & 0 deletions RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,41 @@ que os workers drenam só transfere a espera de lugar. Cada worker fica bloquead
chamada à SEFAZ — cerca de nove segundos —, não em processamento, então o número pode passar bem do
total de núcleos.

### Acesso ao MinIO

O object store **não é publicado na rede**. A porta 9000 fica só na rede interna do Compose, onde a API
a alcança pelo nome do serviço. Publicá-la colocaria todo o acervo — XMLs e certificados cifrados — ao
alcance de quem chegasse à máquina, por fora da API, que é onde vivem a autenticação e o log.

O console fica preso ao loopback (`127.0.0.1:9001`). Para acessá-lo de outra máquina:

```bash
ssh -L 9001:127.0.0.1:9001 usuario@servidor
# depois abra http://localhost:9001 no seu navegador
```

Existem **duas credenciais**, e a distinção importa:

| Credencial | Para quê | Poderes |
|---|---|---|
| `MINIO_ROOT_*` | Administração e console | Tudo |
| `MINIO_APP_*` | A API | Ler, gravar e apagar objetos nos dois buckets — nada além |

A conta da aplicação **não cria bucket, não apaga bucket e não administra o MinIO**. É o que limita o
estrago de um `.env` exposto ou de um token comprometido. Quem provisiona é o `minio-init`, com a
credencial de administração.

> **Cuidado com `mc rb --force`.** Ele esvazia o bucket antes de tentar removê-lo, e apagar objeto é uma
> permissão que a conta da aplicação tem. A recusa vem só no último passo — depois de o acervo já ter
> ido. Para conferir permissões, use um bucket descartável, nunca o de produção.

Se um bucket sumir, a API sobe e registra no log qual falta, com readiness reprovando. Recrie com a
credencial de administração:

```bash
docker compose run --rm minio-init
```

### Falha ao arquivar no object storage

O arquivamento repete sozinho até três vezes, com espera crescente e jitter, quando a falha é de rede ou
Expand Down
41 changes: 33 additions & 8 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,15 @@ services:
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
# A porta da API do MinIO não é publicada. O acervo inteiro — XMLs e certificados cifrados — fica
# alcançável só pela rede interna do Compose, onde a API o encontra pelo nome do serviço. Publicá-la
# colocaria o object store ao alcance de quem chegasse à máquina, **por fora** da API, que é onde
# vivem a autenticação, as políticas e o log.
#
# O console fica preso ao loopback: útil para inspeção local, invisível para a rede. Para alcançá-lo
# de outra máquina, use um túnel SSH em vez de abrir a porta.
ports:
- "${MINIO_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
- "127.0.0.1:${MINIO_CONSOLE_PORT:-9001}:9001"
volumes:
- miniodata:/data
healthcheck:
Expand All @@ -46,9 +52,14 @@ services:
networks: [econtabil-net]
restart: unless-stopped

# Contêiner de uma execução só: cria os buckets e sai. Deixa o ambiente pronto
# sem passo manual. A criação é idempotente, então conviverá sem atrito com a
# verificação que a própria API fará no startup quando o storage for implementado.
# Contêiner de uma execução só: cria os buckets, a política e a conta de serviço da aplicação, e sai.
#
# A conta de serviço existe para que a API **não** use a credencial root. A API precisa de quatro
# ações em dois buckets; com a root ela teria todas, em todos — e um token vazado ou um `.env` exposto
# daria administração do object store: apagar bucket, trocar política, criar usuário, ler tudo.
#
# Todos os comandos são idempotentes: `mc mb --ignore-existing`, `policy create` e `user add`
# sobrescrevem sem erro, então o serviço pode rodar a cada `up` sem efeito colateral.
minio-init:
image: minio/mc:latest
container_name: econtabil-minio-init
Expand All @@ -58,16 +69,29 @@ services:
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_APP_USER: ${MINIO_APP_USER}
MINIO_APP_PASSWORD: ${MINIO_APP_PASSWORD}
BUCKET_XMLS: ${MINIO_BUCKET_XMLS}
BUCKET_CERTIFICADOS: ${MINIO_BUCKET_CERTIFICADOS}
entrypoint: >
/bin/sh -c "
set -e &&
mc alias set econtabil http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
mc mb --ignore-existing econtabil/$$BUCKET_XMLS &&
mc mb --ignore-existing econtabil/$$BUCKET_CERTIFICADOS &&
mc anonymous set none econtabil/$$BUCKET_XMLS &&
mc anonymous set none econtabil/$$BUCKET_CERTIFICADOS &&
echo 'buckets prontos'
printf '%s'
'{\"Version\":\"2012-10-17\",\"Statement\":['
'{\"Effect\":\"Allow\",\"Action\":[\"s3:GetObject\",\"s3:PutObject\",\"s3:DeleteObject\"],'
'\"Resource\":[\"arn:aws:s3:::'$$BUCKET_XMLS'/*\",\"arn:aws:s3:::'$$BUCKET_CERTIFICADOS'/*\"]},'
'{\"Effect\":\"Allow\",\"Action\":[\"s3:ListBucket\",\"s3:GetBucketLocation\"],'
'\"Resource\":[\"arn:aws:s3:::'$$BUCKET_XMLS'\",\"arn:aws:s3:::'$$BUCKET_CERTIFICADOS'\"]}]}'
> /tmp/politica-app.json &&
mc admin policy create econtabil econtabil-app /tmp/politica-app.json &&
mc admin user add econtabil $$MINIO_APP_USER $$MINIO_APP_PASSWORD &&
mc admin policy attach econtabil econtabil-app --user $$MINIO_APP_USER &&
echo 'buckets, politica e conta de servico prontos'
"
networks: [econtabil-net]
restart: "no"
Expand All @@ -91,8 +115,9 @@ services:
Api__RedirecionarHttps: "false"
ConnectionStrings__Postgres: "Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
Minio__Endpoint: "minio:9000"
Minio__AccessKey: ${MINIO_ROOT_USER}
Minio__SecretKey: ${MINIO_ROOT_PASSWORD}
# Conta de serviço, não a root: a API só alcança objetos nos dois buckets. Ver o minio-init.
Minio__AccessKey: ${MINIO_APP_USER}
Minio__SecretKey: ${MINIO_APP_PASSWORD}
Minio__UsarSsl: "false"
Minio__BucketXmls: ${MINIO_BUCKET_XMLS}
Minio__BucketCertificados: ${MINIO_BUCKET_CERTIFICADOS}
Expand Down
11 changes: 9 additions & 2 deletions src/eContabil.Infrastructure/Storage/ProvisionadorDeBuckets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@
namespace eContabil.Infrastructure.Storage;

/// <summary>
/// Cria os buckets do object storage, se ainda não existirem.
/// Confere os buckets do object storage no boot e tenta criá-los quando ausentes.
/// </summary>
/// <remarks>
/// No Compose quem cria é o serviço <c>minio-init</c>, mas ele só existe ali. Em qualquer outro destino
/// — Kubernetes, MinIO gerenciado, S3 — a aplicação subiria com os buckets ausentes, e a falha só
/// apareceria no primeiro upload de certificado, no meio de um cadastro.
///
/// A criação costuma ser negada, e isso é o desenho funcionando: a aplicação usa uma conta de serviço
/// restrita aos dois buckets, sem permissão para criar bucket nenhum. Quem provisiona é quem tem a
/// credencial de administração. O valor deste código está na **conferência** — o log diz qual bucket
/// falta antes de o primeiro cadastro descobrir isso.
///
/// Falha aqui **não** derruba a aplicação: storage fora no momento do boot é transitório, e o readiness
/// já reprova a instância enquanto durar. Derrubar o processo transformaria uma indisponibilidade
/// passageira num contêiner reiniciando em laço.
Expand Down Expand Up @@ -58,6 +63,8 @@ public static async Task ProvisionarAsync(IServiceProvider servicos, Cancellatio

[LoggerMessage(
Level = LogLevel.Error,
Message = "Não foi possível provisionar o bucket {Bucket}; o readiness seguirá reprovando")]
Message = "Bucket {Bucket} ausente e não foi possível criá-lo. A conta de serviço da aplicação " +
"não tem permissão para criar bucket, por desenho: crie-o com a credencial de " +
"administração antes de seguir. O readiness continuará reprovando até lá")]
private static partial void ProvisionamentoFalhou(ILogger log, Exception excecao, string bucket);
}
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,19 @@ describe('DocumentoDetalheComponent', () => {
expect(screen.queryByText('Itens')).not.toBeInTheDocument();
});

it('nao promete download quando o arquivo saiu do acervo', async () => {
// Estado alcancavel: os dados foram lidos de um XML que existiu, e o arquivo nao esta mais la.
// Dizer 'so veio o resumo' contaria uma historia que os itens logo acima desmentem.
await montar({ ...BASE, possuiXml: false, dadosFiscaisExtraidos: true });

await waitFor(() =>
expect(screen.getByRole('status')).toHaveTextContent(/arquivo não está mais disponível/i),
);

// Sem botao nenhum: desabilitado sugeriria que basta esperar, e o arquivo nao volta.
expect(screen.queryByRole('button', { name: /baixar xml/i })).not.toBeInTheDocument();
});

it('permite baixar quando o XML completo existe', async () => {
await montar(BASE);

Expand All @@ -231,7 +244,17 @@ describe('DocumentoDetalheComponent', () => {

it('explica a ausencia do XML quando so ha resumo', async () => {
// Botão que falharia é pior que botão desabilitado com explicação: o usuário fica tentando.
await montar({ ...BASE, origemConteudo: OrigemConteudo.Resumo, possuiXml: false });
//
// `dadosFiscaisExtraidos: false` é o que caracteriza o resumo de verdade: nenhum XML foi lido, e
// por isso não há item nem imposto. Com a marca ligada, o documento estaria no outro caso — dados
// lidos de um XML que depois saiu do acervo.
await montar({
...BASE,
origemConteudo: OrigemConteudo.Resumo,
possuiXml: false,
dadosFiscaisExtraidos: false,
itens: [],
});

await waitFor(() =>
expect(screen.getByRole('status')).toHaveTextContent(/ciência da operação/i),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,15 @@ const MANIFESTACAO: Readonly<Partial<Record<ManifestacaoStatus, string>>> = {
<mat-icon>download</mat-icon>
Baixar XML
</button>
} @else if (doc.dadosFiscaisExtraidos) {
<!--
Os dados vieram de um XML que existiu: dizer que só chegou o resumo seria contar uma
história que os itens logo acima desmentem.
-->
<p class="aviso" role="status">
Os dados desta nota foram lidos do XML, mas o arquivo não está mais disponível no
acervo. Os itens e impostos acima continuam válidos; apenas o download não é possível.
</p>
} @else {
<p class="aviso" role="status">
A SEFAZ entregou apenas o resumo desta nota. O XML completo é liberado depois que a
Expand Down
69 changes: 66 additions & 3 deletions tests/eContabil.Infrastructure.Tests/Storage/MinioFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ public sealed class MinioFixture : IAsyncLifetime
private const string Usuario = "minioadmin";
private const string Senha = "minioadmin123";

/// <summary>Credencial que a aplicação usa — restrita aos dois buckets, como em produção.</summary>
private const string ContaDeServico = "econtabil-app";
private const string SenhaDaConta = "conta-de-servico-do-teste";

private readonly MinioContainer _container = new MinioBuilder("minio/minio:latest")
.WithUsername(Usuario)
.WithPassword(Senha)
Expand Down Expand Up @@ -72,18 +76,77 @@ public async ValueTask InitializeAsync()

await CriarBucketAsync(Opcoes.Value.BucketXmls);
await CriarBucketAsync(Opcoes.Value.BucketCertificados);

await CriarContaDeServicoAsync();
}

/// <summary>
/// Cria a conta de serviço da aplicação com a mesma política restrita do <c>docker-compose</c>.
/// </summary>
/// <remarks>
/// A aplicação não usa a credencial root em lugar nenhum: ela tem uma conta que só alcança objetos
/// nos dois buckets. Sem reproduzir isso aqui, a suíte provaria que o adaptador funciona com poderes
/// que ele não tem em produção — e apertar a política adiante quebraria a captura sem nenhum teste
/// vermelho para avisar.
///
/// Os comandos rodam pelo <c>mc</c> que já vem na imagem do MinIO, porque o SDK .NET não expõe a API
/// de administração.
/// </remarks>
private async Task CriarContaDeServicoAsync()
{
var politica = $$"""
{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject"],
"Resource":["arn:aws:s3:::{{Opcoes.Value.BucketXmls}}/*",
"arn:aws:s3:::{{Opcoes.Value.BucketCertificados}}/*"]},
{"Effect":"Allow","Action":["s3:ListBucket","s3:GetBucketLocation"],
"Resource":["arn:aws:s3:::{{Opcoes.Value.BucketXmls}}",
"arn:aws:s3:::{{Opcoes.Value.BucketCertificados}}"]}]}
""";

await ExecutarAsync("sh", "-c", $"printf '%s' '{politica.ReplaceLineEndings(" ")}' > /tmp/p.json");
await ExecutarAsync("mc", "alias", "set", "local", "http://127.0.0.1:9000", Usuario, Senha);
await ExecutarAsync("mc", "admin", "policy", "create", "local", "app", "/tmp/p.json");
await ExecutarAsync("mc", "admin", "user", "add", "local", ContaDeServico, SenhaDaConta);
await ExecutarAsync("mc", "admin", "policy", "attach", "local", "app", "--user", ContaDeServico);
}

private async Task ExecutarAsync(params string[] comando)
{
var resultado = await _container.ExecAsync(comando);

if (resultado.ExitCode != 0)
{
// Falha aqui não é ambiente ausente: o contêiner subiu. É defeito do próprio preparo, e
// engolir deixaria os testes rodando com uma política que não é a de produção.
throw new InvalidOperationException(
$"Preparo da conta de serviço falhou ({string.Join(' ', comando)}): {resultado.Stderr}");
}
}

/// <summary>
/// Monta o adaptador com o pipeline de resiliência real, como a aplicação o registra.
/// Monta o adaptador como a aplicação o monta: conta de serviço restrita e pipeline real.
/// </summary>
/// <remarks>
/// Pipeline de verdade, e não <c>ResiliencePipeline.Empty</c>: é o registro real que decide o que é
/// falha transitória, e testar com um pipeline vazio provaria que o adaptador funciona numa
/// configuração que nunca roda em lugar nenhum.
/// configuração que nunca roda em lugar nenhum. Vale o mesmo para a credencial — <see cref="Cliente"/>
/// é root e serve ao preparo dos testes, mas o adaptador recebe a conta restrita.
/// </remarks>
public MinioXmlStorage CriarStorage() =>
new(Cliente, Opcoes, _pipelines ??= MontarPipelines());
new(ClienteDaAplicacao, Opcoes, _pipelines ??= MontarPipelines());

/// <summary>O cliente com a credencial restrita, como a aplicação o recebe.</summary>
public IMinioClient ClienteDaAplicacao => _clienteDaAplicacao ??= ClienteRestrito();

private IMinioClient? _clienteDaAplicacao;

private IMinioClient ClienteRestrito() =>
new MinioClient()
.WithEndpoint(Opcoes.Value.Endpoint)
.WithCredentials(ContaDeServico, SenhaDaConta)
.WithSSL(false)
.Build();

private ResiliencePipelineProvider<string>? _pipelines;

Expand Down
Loading
Loading