diff --git a/.env.example b/.env.example index aeb2f65..e293ac1 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/RELEASE.md b/RELEASE.md index d561261..30a12a9 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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 diff --git a/RUNBOOK.md b/RUNBOOK.md index bd98ae6..05246de 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 7735b44..085c75e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: @@ -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 @@ -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" @@ -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} diff --git a/src/eContabil.Infrastructure/Storage/ProvisionadorDeBuckets.cs b/src/eContabil.Infrastructure/Storage/ProvisionadorDeBuckets.cs index e972245..573a1d9 100644 --- a/src/eContabil.Infrastructure/Storage/ProvisionadorDeBuckets.cs +++ b/src/eContabil.Infrastructure/Storage/ProvisionadorDeBuckets.cs @@ -7,13 +7,18 @@ namespace eContabil.Infrastructure.Storage; /// -/// 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. /// /// /// No Compose quem cria é o serviço minio-init, 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. @@ -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); } diff --git a/src/econtabil-web/src/app/features/documentos/documento-detalhe.component.spec.ts b/src/econtabil-web/src/app/features/documentos/documento-detalhe.component.spec.ts index a112c6c..3cfa39a 100644 --- a/src/econtabil-web/src/app/features/documentos/documento-detalhe.component.spec.ts +++ b/src/econtabil-web/src/app/features/documentos/documento-detalhe.component.spec.ts @@ -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); @@ -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), diff --git a/src/econtabil-web/src/app/features/documentos/documento-detalhe.component.ts b/src/econtabil-web/src/app/features/documentos/documento-detalhe.component.ts index cd147b1..a06b794 100644 --- a/src/econtabil-web/src/app/features/documentos/documento-detalhe.component.ts +++ b/src/econtabil-web/src/app/features/documentos/documento-detalhe.component.ts @@ -263,6 +263,15 @@ const MANIFESTACAO: Readonly>> = { download Baixar XML + } @else if (doc.dadosFiscaisExtraidos) { + +

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

} @else {

A SEFAZ entregou apenas o resumo desta nota. O XML completo é liberado depois que a diff --git a/tests/eContabil.Infrastructure.Tests/Storage/MinioFixture.cs b/tests/eContabil.Infrastructure.Tests/Storage/MinioFixture.cs index d7131e5..a8d2694 100644 --- a/tests/eContabil.Infrastructure.Tests/Storage/MinioFixture.cs +++ b/tests/eContabil.Infrastructure.Tests/Storage/MinioFixture.cs @@ -24,6 +24,10 @@ public sealed class MinioFixture : IAsyncLifetime private const string Usuario = "minioadmin"; private const string Senha = "minioadmin123"; + ///

Credencial que a aplicação usa — restrita aos dois buckets, como em produção. + 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) @@ -72,18 +76,77 @@ public async ValueTask InitializeAsync() await CriarBucketAsync(Opcoes.Value.BucketXmls); await CriarBucketAsync(Opcoes.Value.BucketCertificados); + + await CriarContaDeServicoAsync(); + } + + /// + /// Cria a conta de serviço da aplicação com a mesma política restrita do docker-compose. + /// + /// + /// 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 mc que já vem na imagem do MinIO, porque o SDK .NET não expõe a API + /// de administração. + /// + 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}"); + } } /// - /// 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. /// /// /// Pipeline de verdade, e não ResiliencePipeline.Empty: é 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 — + /// é root e serve ao preparo dos testes, mas o adaptador recebe a conta restrita. /// public MinioXmlStorage CriarStorage() => - new(Cliente, Opcoes, _pipelines ??= MontarPipelines()); + new(ClienteDaAplicacao, Opcoes, _pipelines ??= MontarPipelines()); + + /// O cliente com a credencial restrita, como a aplicação o recebe. + 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? _pipelines; diff --git a/tests/eContabil.Infrastructure.Tests/Storage/PoliticaDaContaDeServicoTests.cs b/tests/eContabil.Infrastructure.Tests/Storage/PoliticaDaContaDeServicoTests.cs new file mode 100644 index 0000000..756fd7d --- /dev/null +++ b/tests/eContabil.Infrastructure.Tests/Storage/PoliticaDaContaDeServicoTests.cs @@ -0,0 +1,78 @@ +using Minio.DataModel.Args; +using Xunit; + +namespace eContabil.Infrastructure.Tests.Storage; + +/// +/// A conta que a aplicação usa alcança objetos nos dois buckets, e nada além disso. +/// +/// +/// A outra metade do teste de armazenamento. Os testes do adaptador provam que a conta **consegue** +/// gravar e ler; estes provam que ela **não consegue** o resto. Sem eles, alargar a política para +/// s3:* deixaria a suíte inteira verde — e a aplicação voltaria a ter administração do object +/// store sem que nenhuma linha de código mudasse. +/// +[Collection(nameof(MinioTestes))] +public class PoliticaDaContaDeServicoTests(MinioFixture minio) +{ + [Fact] + public async Task ContaDaAplicacao_NaoCriaBucket() + { + // Quem provisiona é quem tem a credencial de administração. A aplicação apenas confere. + if (minio.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + await Assert.ThrowsAnyAsync(() => minio.ClienteDaAplicacao.MakeBucketAsync( + new MakeBucketArgs().WithBucket("bucket-que-a-aplicacao-nao-deveria-criar"), + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ContaDaAplicacao_NaoApagaBucket() + { + // É o comando que esvazia o acervo antes de remover o bucket. Negá-lo é a diferença entre um + // engano custar um objeto e custar cinco anos de guarda fiscal. + if (minio.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + await Assert.ThrowsAnyAsync(() => minio.ClienteDaAplicacao.RemoveBucketAsync( + new RemoveBucketArgs().WithBucket(minio.Opcoes.Value.BucketXmls), + TestContext.Current.CancellationToken)); + + // E o bucket continua lá. + Assert.True(await minio.Cliente.BucketExistsAsync( + new BucketExistsArgs().WithBucket(minio.Opcoes.Value.BucketXmls), + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ContaDaAplicacao_NaoAlcancaBucketDeFora() + { + // A política nomeia os dois buckets. Um terceiro — criado por outro sistema no mesmo MinIO — + // fica fora do alcance da aplicação. + if (minio.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + const string alheio = "bucket-de-outro-sistema"; + + if (!await minio.Cliente.BucketExistsAsync( + new BucketExistsArgs().WithBucket(alheio), TestContext.Current.CancellationToken)) + { + await minio.Cliente.MakeBucketAsync( + new MakeBucketArgs().WithBucket(alheio), TestContext.Current.CancellationToken); + } + + await Assert.ThrowsAnyAsync(() => minio.ClienteDaAplicacao.StatObjectAsync( + new StatObjectArgs().WithBucket(alheio).WithObject("qualquer.txt"), + TestContext.Current.CancellationToken)); + } +}