Repository navigation
fix(financial): sob o bypass do RBAC, a alçada deixa de recusar quem o /me acabou de liberar - #1015
Merged
Merged
Conversation
…o /me acabou de liberar Sob `AUTH_RBAC_MODE=bypass` (ADR-0052) existem **três** camadas que decidem sobre permissão, e só duas o honravam: `authorize` vira no-op ✅, o `GET /me` anuncia o catálogo inteiro (`list-user-permissions.ts:35`) ✅, e a `approval-policy` do domínio (`approval-policy.ts:27`) lia `canApprove` do banco cru e recusava ❌. **Medido em 09/09, ambiente local:** o `/me` de um usuário provisionado pelo ETL legado devolvia **47 permissões, incluindo `payable:approve`**; o banco lhe dava **uma**, e não era essa. Ao aprovar, ele tomava um `approver-missing-permission` que contradizia o que o `/me` acabara de lhe dizer — depois de o `authorize` no-op o ter deixado entrar até o fundo para morrer lá. Do lado do usuário, prometer e negar a mesma coisa não se distingue de defeito. **A decisão está no ADR-0069**, `supersedes` parcial do 0052: sob bypass a `approval-policy` deixa de barrar por PERMISSÃO. Aplicada como **decorator** — `withRbacBypass` sobre o `ApproverAuthorityReader` —, composto num ponto só, no composition root do `financial`. Embutir isto no `user-read.drizzle.ts` faria o `auth` mentir sobre os próprios papéis para **todo** consumidor, inclusive a tela de gestão de acessos; comportamento transversal é decorator e nunca código dentro do provedor. O `server.ts` traduz o modo num booleano — o `financial` não passa a conhecer `RbacMode`, que é vocabulário do `auth`.⚠️ **O decorator envolve o reader que vai ao `approveDocument`, e SÓ ele** (`depsForApprove`). O mesmo port responde a duas perguntas, e só a primeira é controle de acesso: - `approveDocument` — o **chamador autenticado** pode aprovar este valor? (#609) → **afrouxada**; - `saveDocument`/`submitDraft` — o `approverRef` **indicado** tem alçada? (#289/#297) → **enforçada**. Compor no `deps` compartilhado é o caminho óbvio, e foi o primeiro que escrevi. Afrouxa também a segunda — e o efeito não é simétrico: a indicação **grava** `approverRef` apontando para quem não é aprovador, e a linha **sobrevive ao religar da flag**. Seria dano que o #634 não desfaz. **Prova invertida (medida, não suposta):** devolvendo o decorator ao `deps`, o `POST /documents` responde **201 com o `approverRef` inválido persistido**, e o caso novo da borda acusa. **Três limites são parte da decisão, e é por eles que ela se desfaz por engano.** Os cinco casos de `approver-authority-reader.rbac-bypass.test.ts` existem para fixá-los: - `null` continua `null` — o bypass afrouxa **permissão**, nunca a existência do sujeito, e `approver-not-found` sobrevive; - o **teto** passa intacto — quem tem papel com alçada continua limitado por ele (#299/#609); - `list` passa intacto — `escalate` é **roteamento de negócio**, não controle de acesso: mexer nele mudaria para quem o documento é encaminhado, não quem pode aprová-lo. Na borda, o par bypass ligado × desligado sobre a **mesma** autoridade (`canApprove: false`) é o que prova que o flag é a variável, e não outra diferença de montagem: ligado dá 200 e `Approved`; desligado dá 422, o documento permanece `Open` e o slug interno não vaza no body.⚠️ **O custo aceito, e é o principal: sob bypass todo autenticado aprova qualquer valor.** Quem não tem papel aprovador tem teto `null` — `maxLimit` devolve `null` para conjunto vazio (`user-read.drizzle.ts:42`) — e `null` é **SEM TETO** pela regra binária do #299 (`approval-policy.ts:28-31`). Então "o teto continua valendo" **não protege** exatamente a população que esta mudança libera. Reabre, enquanto o bypass durar, o buraco que o #609 fechou — e **vale em produção**, porque `server.ts:165` fixa o modo por código, com o risco assumido por escrito no #634. **O gatilho de reversão é o #634 — e ele custa DUAS linhas, não zero.** São duas marcas `← religar` no `server.ts`: o `rbacMode` fixado e o `rbacBypass: true` da composição do `financial`. Apagar só a primeira religa a rota e o `/me` e **deixa a policy do domínio afrouxada**, com o RBAC já enforçado — o pior dos dois mundos, e **nada mecânico acusa**: não há erro de tipo, teste vermelho nem lint. O literal não deriva de `rbacMode` porque o ESLint recusa comparação que o compilador prova sempre verdadeira (a mesma razão que impede envolver o banner num `if`); o preço é a nota, que é o único guarda existente. O ADR-0052 ganha o ponteiro para cá — sem ele, quem abrir o 0052 lê como norma corrente um alcance que já mudou, que é o defeito que a `auth-module.md` já registra para o par 0024/0055. As citações passam a ser por **marcador**, não por número de linha: a primeira versão deste ADR dizia `server.ts:165`, e as duas linhas que este mesmo commit acrescentou empurraram o alvo para a `:166` — a `:165` virou justamente o `resolveRbacMode` que a instrução manda voltar a usar. Quem seguisse a citação apagaria a leitura da env e deixaria o hardcode de pé. **Alternativa recusada pelo dono:** consertar o `/me`, subtraindo `payable:approve` do catálogo anunciado sob bypass e mantendo a policy a barrar. Também elimina a contradição, e tinha precedente no próprio repositório (`revoke-role.ts:88` chama `authorize` direto para sobreviver ao bypass). Foi recusada porque, sob bypass, a promessa é *"todo autenticado é super-usuário"*, e uma regra de domínio que segue cobrando permissão é **exceção** à promessa, não correção dela. A análise ficou escrita no ADR para quem for reabrir.⚠️ Esta decisão **não alcança** outras policies de domínio que leiam permissão do banco. Hoje esta é a única identificada, e o `financial` já recebe o booleano — seguir é barato, mas é deliberado. Gate verde: typecheck + format:check + lint + test (11.844 testes, 0 falhas, 20 skip esperados). Refs: #634, #609, #299 Assisted-by: Claude-Code:claude-opus-5 Claude-Session: https://claude.ai/code/session_01T3DKgMk376thR2ygRa3rA7
`node --test` sem `--test-concurrency` dimensiona o paralelismo por `os.availableParallelism()`. Na máquina de desenvolvimento isso devolve **28**, contra **15 GiB** de RAM — e cada worker do runner custa ~370 MB medidos (Node completo com `--experimental-strip-types` + `--enable-source-maps`). O default pedia ~10,4 GB num box que já roda a sessão gráfica: o gate empurrava desktop, `mysqld` e navegador para a swap de 4 GiB, que não voltava. Não é micro-otimização de estilo. `pnpm test` é chamado pelo hook `Stop` (`stop-quality-gate.sh:97`) e pelo pre-commit, então o pico acontece a **cada turno encerrado**, não só quando alguém roda a suíte à mão. Medido depois do cap: 6 workers, **2,2 GB de pico**, ~155s, suíte verde. O cap não muda o que é executado, só quantos arquivos concorrem. No CI (`ubuntu-latest`, 4 vCPU / 16 GiB) ele **sobe** a concorrência de 4 para 6, a ~2,2 GB de pico — folgado dentro do orçamento do runner. A suíte de integração continua em `--test-concurrency=1`, por outro motivo: isolamento de banco (`.claude/rules/testing.md`).⚠️ Não elevar nem remover "para acelerar" sem antes medir `free -h` contra `nproc`. A razão RAM/core aqui é ~0,54 GB e o worker quer 0,37 GB. Suíte lenta demais se resolve reduzindo o custo por worker ou dividindo a suíte, não soltando o paralelismo. Assisted-by: Claude-Code:claude-opus-5 Claude-Session: https://claude.ai/code/session_01T3DKgMk376thR2ygRa3rA7
Contributor
Author
|
Os dois achados fora do escopo deste PR, registrados em vez de consertados aqui:
Consertar qualquer um deles aqui misturaria |
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. |
…a recusar GHSA-2x7j-588g-ccc2 (`high`, CVSS 7.5 `AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H`) foi publicada em **08/09/2026 21:33 UTC**: complexidade quadrática no `addressparser` do nodemailer permite DoS remoto por lista de endereços forjada. Vulneráveis `<9.1.0`; corrigido em `>=9.1.0`. O pin era `9.0.1` (`package.json:124`, de c66ff54), e nodemailer é `dependencies` — então quem acusa é o job **bloqueante** `audit (produção — blocking)`, não o report-only: pnpm audit --prod --audit-level=high --ignore-registry-errors → exit 1 │ Paths │ .>nodemailer │ 4 vulnerabilities found · 3 moderate | 1 high O vermelho não veio de diff nenhum: é o relógio. A advisory saiu 28h antes do run do PR #1015, e a `dev` só está verde porque o último `audit` dela é de **06/09** — dois dias antes de a advisory existir. Qualquer branch que rodar o gate a partir de agora pega o mesmo exit 1. Sobe para **9.1.1**, a última da linha 9.x (publicada em 01/09, fora da quarentena de `minimumReleaseAge: 1440`). A `10.0.2` existe, mas saiu em **09/09 20:19 UTC** — major de menos de 24h, que a quarentena recusaria de todo modo, e que exigiria revisar a API antes: o adapter em `src/modules/notifications/adapters/email/nodemailer.ts:15-17` importa `createTransport` mais os tipos de `nodemailer/lib/smtp-pool` e `smtp-transport`, caminhos internos que major move sem aviso. Pin exato porque `dependencies` carrega o que serve tráfego (`.claude/rules/supply-chain.md`), e a graça do pin é exatamente esta: subir de versão vira ato deliberado, visível em diff de PR. Os demais `high` do relatório — axios, form-data, undici — entram por `@usebruno/cli`, que é devDependency; ficam no job report-only, por desenho, e este commit não os toca. Verificado: `pnpm audit --prod --audit-level=high` responde `No known vulnerabilities found`. Gate completo verde — typecheck, format:check, lint e 11.844 testes, 0 falhas, 20 skip esperados. Assisted-by: Claude-Code:claude-opus-5 Claude-Session: https://claude.ai/code/session_01G17M4M3A3c6bd6jmCPAk2W
This was referenced Sep 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Sob
AUTH_RBAC_MODE=bypass(ADR-0052) existem três camadas que decidem sobre permissão, e só duas o honravam:authorizevira no-op ✅, oGET /meanuncia o catálogo inteiro (list-user-permissions.ts:35) ✅, e aapproval-policydo domínio (approval-policy.ts:27) liacanApprovedo banco cru e recusava ❌.Medido em 09/09, ambiente local: o
/mede um usuário provisionado pelo ETL legado devolvia 47 permissões, incluindopayable:approve; o banco lhe dava uma, e não era essa. Ao aprovar, ele tomava umapprover-missing-permissionque contradizia o que o/meacabara de lhe dizer — depois de oauthorizeno-op o ter deixado entrar até o fundo para morrer lá. Do lado do usuário, prometer e negar a mesma coisa não se distingue de defeito.A decisão está no ADR-0069,
supersedesparcial do 0052.O que muda
Um decorator —
withRbacBypasssobre oApproverAuthorityReader— composto num ponto só, no composition root dofinancial. Embutir nouser-read.drizzle.tsfaria oauthmentir sobre os próprios papéis para todo consumidor, inclusive a tela de gestão de acessos.approveDocument, e só ele (depsForApprove). O mesmo port responde a duas perguntas, e apenas a primeira é controle de acesso:approveDocumentsaveDocument·submitDraftapproverRefindicado tem alçada? (#289/#297)A segunda é roteamento — o mesmo motivo que deixa
listintacto. Compor nodepscompartilhado (o caminho óbvio, e o primeiro que escrevi) afrouxa também ela, e o efeito não é simétrico: a indicação gravaapproverRefapontando para quem não é aprovador, e a linha sobrevive ao religar da flag. Prova invertida, medida: devolvendo o decorator aodeps, oPOST /documentsresponde 201 com oapproverRefinválido persistido, e o caso novo da borda acusa.Limites fixados por teste
Os cinco casos de
approver-authority-reader.rbac-bypass.test.ts:nullcontinuanull(approver-not-foundsobrevive — o bypass afrouxa permissão, nunca a existência do sujeito); o teto passa intacto (#299/#609); falha de leitura propaga sem máscara;listpassa intacto.Na borda, o par bypass ligado × desligado sobre a mesma autoridade (
canApprove: false) prova que o flag é a variável: ligado dá 200 eApproved; desligado dá 422, o documento permaneceOpen, e o slug interno não vaza no body.Sob bypass, todo autenticado aprova qualquer valor. Quem não tem papel aprovador tem teto
null(user-read.drizzle.ts:42), enullé SEM TETO pela regra binária do #299. "O teto continua valendo" não protege a população que esta mudança libera. Reabre, enquanto o bypass durar, o buraco que o #609 fechou — e vale em produção, com o risco assumido por escrito no #634.Reversão custa DUAS linhas, não zero. Duas marcas
← religarnoserver.ts: orbacModefixado e orbacBypass: true. Apagar só a primeira religa a rota e o/mee deixa a policy do domínio afrouxada — e nada mecânico acusa. O literal não deriva derbacModeporque o ESLint recusa comparação que o compilador prova sempre verdadeira.🔴 Risco calculado que o revisor precisa pesar antes do merge
O custo acima está escrito como "sob bypass todo autenticado aprova qualquer valor". Essa frase só descreve um risco contido se "autenticado" for um conjunto controlado — e hoje não é.
POST /api/v2/auth/register(auth/adapters/http/plugin.ts:46-74) não tempreHandler: nemrequireAuth, nemauthorize. Eserver.tsmontaauthHttpPlugin(authDeps)incondicionalmente, sem gate de ambiente — diferente dovanSandbox, que é fail-closed no composition root. A rota é pública onde o binário subir.Somando com este PR: anônimo → autenticado → aprova pagamento de qualquer valor. Q.A. mediu a cadeia ponta a ponta na stack local e chegou de conta anônima a documento aprovado em 4 minutos, com as 47 permissões idênticas às do admin.
Isso é pré-existente e independente deste diff — está na
devhoje. O que este PR muda é que ele remove o último gate que ainda separava quem pode de quem não pode aprovar. Fica registrado aqui como risco calculado, com issue própria, porque consertar/registerneste PR misturariaauthnum diff definancial.Achados menores também fora de escopo, com issue própria:
undo-approval.ts:55gravaactor: nullliteral e oUndoApprovalCommandnem carrega usuário, emborareq.userIdesteja disponível no handler e oapprovevizinho o use —PayableApprovedtraz o ator,ApprovalUndonevemNone.Gate
typecheck+format:check+lint+test— 11.844 testes, 0 falhas, 20 skip esperados.Revisão:
/code-review high(5 achados; os 3 de dentro do diff corrigidos aqui, ver abaixo) e/security-review(nenhum High/Medium).Achados do code-review tratados neste PR: o decorator alcançava três call sites e não um (corrigido + teste com prova invertida); o ADR afirmava que religar não custava código (corrigido — são duas linhas); as citações
server.ts:165derivaram para:166por causa do próprio commit e passaram a ser por marcador, não por número. O ADR-0052 ganhou o ponteiro para o 0069 — sem ele, quem abre o 0052 lê como norma corrente um alcance que já mudou, que é o defeito que aauth-module.mdregistra para o par 0024/0055.O segundo commit é independente:
chore(ci)fixa--test-concurrency=6(28 CPUs para 15 GiB derrubavam a sessão gráfica; 6 workers, 2,2 GB de pico).Refs: #634, #609, #299
https://claude.ai/code/session_01T3DKgMk376thR2ygRa3rA7