Segurança — o piso¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-05
O mínimo que todo app da casa cumpre, nas duas stacks. Não é threat modeling formal (desproporcional pro nosso tamanho) — é o piso que evita as falhas baratas de evitar. Destilado em 2026-07-05 do que os pilotos praticam; onde a prática divergia, o lado escolhido está declarado.
Authn / authz¶
- Fail-closed por default: a regra de segurança nega; os paths públicos são a
exceção explícita (
intercept-url-mapcom a allowlist, não uma denylist). Bypass de desenvolvimento existe como tri-estado declarado —enabled/bypass(só dev/test) / fail-closed em produção — numaSecurityRule/config nomeada, nunca umifsolto no handler. - Micronaut:
micronaut-securitynativo comauthentication: bearer; papel via claim/TokenValidatorconsumido por@Secured("ROLE_…")no endpoint. Filtro HTTP sempre@ServerFilter(o@Filterlegado ignora@Order— armadilha). - A allowlist do
intercept-url-mapé por prefixo uniforme (/api/**Bearer, infra) — view pública NÃO. A exceção de path do fail-closed serve para o que é homogêneo por prefixo. Uma view server-render anônima (landing, health público, callback sem Bearer) se libera com@Secured(SecurityRule.IS_ANONYMOUS)na própria rota (rota-exata por construção, colada ao handler, sem allowlist paralela pra driftar); allowlist programática/dinâmica de rotas públicas vai numaisPublicView()naSecurityRulecustom (rota exata). O que nunca serve pra view é um pattern greedy (/*,/**) nointercept-url-map: oConfigurationInterceptUrlMapRuleroda em ordem -100, antes daSecurityRulecustom (0), e a primeira que devolve ALLOWED/REJECTED vence — um intercept greedy com ALLOWED ganha da regra que protegia a rota irmã e deixa/adminpúblico sem 500 e sem sinal. Receita de view em Java/Micronaut §UI. - Tenancy (
cliente_id): o servidor decide. A identidade do cliente vem da claim do JWT validada no servidor e cruzada com o dado (FK) — nunca de um parâmetro/header que o cliente controla. No Flutter, restrição no cliente (ex. domínio@xadm.com.brno login) é UX, não segurança — o servidor revalida sempre. - Comparação de segredo em tempo constante: shared secret/token se compara com
MessageDigest.isEqual(Java), nuncaequals/==(timing attack). - Endpoint público tem rate limit (piloto: 60 req/min/IP no login). Ação administrativa/sensível gera log INFO de auditoria (quem, o quê, path).
- Flutter: JWT em
flutter_secure_storage(não SharedPreferences); 401 em qualquer chamada → logout automático; senha nunca é persistida — vive só em memória; em prefs vão apenas id/nome. Evento de auditoria de segurança pertence ao backend (evento client-side é bypassável — visibilidade, não auditoria).
Validação de input¶
- Todo record de request valida com
jakarta.validation(@Body @Valid+@NotBlank/@Pattern/@Size…). O processor (micronaut-validation-processor) já está no build dos apps — o que falta é o hábito; a partir desta versão, endpoint novo sem@Validno request é lacuna de review. O erro de validação sai no formato RFC 7807 da casa — e se um consumidor NOSSO ramifica nocodedo corpo, esse code precisa ser uniforme entre borda e service para a mesma classe de falta (ver Discriminador de máquina). - Query nunca concatenada: Micronaut Data / prepared statement com parâmetro. String interpolada em SQL é bloqueador de review, sem exceção.
- Upload/arquivo do usuário: valide extensão E conteúdo esperado antes de processar (os processadores XLS validam por linha e não falham o lote — o padrão certo).
Segredos¶
- Client-grade ≠ segredo. Client-grade (DSN do GlitchTip, app-key de analytics, URL
pública) embarca no bundle/repo por design — mora no
docs/app.jsone a Central de Apps o gerencia. Segredo de verdade (key S3 de escrita, token de serviço, chave privada) nunca entra em repo,.ia/, log ou fixture — vive em secret da org no Forgejo / env do Coolify (constituição §1.9). ${ENV_VAR:default-dev-inócuo}noapplication.yml; chave PEM aceita conteúdo-ou-caminho (AUTH_PRIVATE_KEY>AUTH_PRIVATE_KEY_PATH). Segredo ausente → o app degrada com comportamento declarado (503 no endpoint, no-op com WARN, fail-closed em prod) — nunca sobe fingindo que a feature funciona.- ENV de auth/URL inter-app: nomeie pelo DESTINO da chamada, não pelo papel. Um Bearer/base-URL
entre apps da casa mora em
<APP>_URL+<APP>_API_TOKEN= "como chamar o<APP>". Cada app valida o próprio<SELF>_API_TOKEN; cada chamador seta<DESTINO>_URL+<DESTINO>_API_TOKEN. O efeito é um mapa de segredo coerente: um token tem nome global (WEBSTORM_API_TOKEN) com o mesmo valor no callee que valida e em todo caller que manda — para a aresta A→B, tanto A quanto B têmB_API_TOKENigual. Num app que é chamador e chamado, a env diz o fluxo de cara:WEBSTORM_API_TOKEN(meu inbound),INTEGRADOR_API_TOKEN/PARCEIRO_API_TOKEN(pra chamar). Anti-padrão: nomear pelo papel (API_BEARER_TOKEN= "meu inbound",WEBHOOK_TOKEN= "meu outbound") — o mesmo nome vira valores diferentes por servidor e não diz a direção; foi o que colidiu na integração N-hop. Trade-off declarado: é um token por callee (todos os callers de B partilhamB_API_TOKEN) — simples e suficiente pro N-hop interno; revogar um caller exige rotacionar B pra todos. Só antecipe nome por aresta (<CALLEE>_<CALLER>_API_TOKEN) quando revogação por caller virar requisito real. Rotação fica óbvia:grep <APP>_API_TOKENacha o conjunto exato a girar de uma vez (casa com "vazou → rotaciona" abaixo). Escopo: só auth/URL HTTP inter-app (+ terceirosPARCEIRO_*); DB (DATASOURCES_*), Firebase/sessão (AUTH_*), observabilidade (SENTRY_DSN) seguem o padrão do framework/produto e ficam como estão. Receita de config e migração de env — sem cascata no yaml (o Micronaut não aninha placeholder; ver a armadilha), via rename atômico ou resolve-in-code — em Java/Micronaut §Específico. - Fórmula de derivação de token é segredo se não há key separada. Se um token de auth é derivável de uma fórmula sem um segredo à parte, a fórmula É o segredo — não vai em javadoc/comentário em claro (quem lê o fonte cunha um token válido = bypass). O certo é Kerckhoffs: derive por HMAC com a key em env/secret — aí o algoritmo pode ser público e só a key é secreta. Vale também para quem opera com o fonte à mão (agente incluso): peça o token; não o compute a partir da fórmula (/xadm-setup).
- Teste gera a própria chave (task Gradle, ex.
genAuthTestKeys) — chave de produção nunca aparece em teste/fixture. - Vazou → rotaciona. Apagar o arquivo não basta; o Git guarda (já aconteceu com as keys DOCS_S3 — foram rotacionadas).
- Log não carrega senha nem token — nem em breadcrumb (regra escrita na fachada de log dos dois lados).
Checklist do revisor (PR)¶
Ao revisar qualquer PR, os 60 segundos de segurança:
- Segredo no diff? (string alta-entropia, DSN com senha, PEM) → bloqueia + rotaciona.
- Endpoint novo tem
@Secured/regra e o público está na allowlist de propósito? (View pública:@Secured(IS_ANONYMOUS)na rota — ouisPublicView()se for allowlist programática —, nunca porintercept-url-mapgreedy. Rota de view sem decisão = 401.) - Request novo tem
@Valid? Query tem parâmetro (não concatenação)? - Migração destrutiva tem cabeçalho de justificativa +
IF EXISTS? (banco) - Fixture nova está anonimizada (ou justificada no javadoc)?
- Dado sensível novo em
docs/? → só emprivado/(constituição §5).