# MFA obrigatório nos quatro consoles administrativos

Data: 2026-07-27
Mandato do dono, na véspera das primeiras subidas de cliente em produção.

## O que o dono pediu

Todos os usuários de **coreadmin**, **pixadmin**, **spbadmin** e **staadmin**, exceto
`admin@monetarie.com`, passam a ter MFA obrigatório no **login** e nas **operações que
envolvem a parte financeira**. O MFA deve poder ser ligado e desligado **por usuário**.
O RBAC dos quatro deve ser revisado. Quem ainda não tem MFA deve ser forçado a cadastrar
**no próximo login**.

Decisões tomadas pelo dono nesta sessão:

1. **Isenção**: coluna `mfa_enforced` por usuário nos quatro sistemas, default `true`,
   alterável só por `super_admin` e registrada em auditoria. Além disso
   `admin@monetarie.com` fica isento por política fixa no código, sem depender do banco.
2. **Escopo da operação**: move dinheiro **e** muda risco. Ou seja TED e PIX manual,
   devolução, estorno, aprovação de lote, liquidação, aporte e transferência entre contas;
   mais alteração de limite e alçada, cadastro e exclusão de chave PIX, bloqueio e
   desbloqueio de conta, criação de admin e mudança de RBAC.
3. **STA**: construir a infraestrutura de MFA do zero, no mesmo padrão dos outros.
4. **RBAC**: auditar os quatro, corrigir o que for furo de segurança e reportar o resto
   com evidência antes de mexer.

Complemento do dono sobre o deploy: subir o código nos dois ambientes com o enforcement
**desligado em PRD**, e ligar **só em HML** para provar empiricamente que funciona.

## Estado atual, provado (arquivo:linha)

Levantado em 27/07 lendo o código e a AWS. Nada aqui é inferência.

### coreadmin (`core-api`)

| Fato | Evidência |
|---|---|
| A tabela `platform_admins` já tem `totp_secret`, `totp_enabled`, `mfa_backup_codes`, `mfa_enforced` (default `true`) e o status `pending_mfa_setup` | `core/backend/lib/monetarie/schemas/platform/admin.ex:34-39,80` |
| O login **não pede MFA**: só checa blocked, suspended, locked e bcrypt | `core/backend/lib/monetarie/use_cases/platform/admins.ex:156-181` |
| Após a senha, emite JWT de 8h e grava o cookie direto | `core/backend/lib/monetarie_web/controllers/auth_controller.ex:40-104` |
| O plug `EnsureMFA` tem ramo para `platform_admin` e lê `resource[:totp_enabled]`, que existe de fato: quem monta o resource das requisições autenticadas é o Guardian, e ele preenche o campo | `core/backend/lib/monetarie_web/plugs/ensure_mfa.ex:67-74` e `core/backend/lib/monetarie_web/auth/guardian.ex:167-175` |
| `EnsureMFA` cobra apenas **matrícula** (se o admin tem TOTP cadastrado), nunca o código no ato, e está aplicado em **6 dos 34** blocos `pipe_through` do router | `core/backend/lib/monetarie_web/router.ex:389,2458,2474,2977,3193,3562` |
| `MfaGuard` (MFA por operação) lê `Schemas.Relational.User`, que é o cliente do IB. Operação financeira feita pelo admin não é desafiada | `core/backend/lib/monetarie_web/auth/mfa_guard.ex:76` |
| Env vivo: PRD `MFA_ENFORCEMENT=members` (resolve para `platform_admins: :optional`), HML `optional`. `REQUIRE_TRANSACTION_MFA=true` nos dois | task defs `monetarie-core-api-prod:120` e `monetarie-core-api-homolog:231` |
| Não existe tela de cadastro de MFA do próprio admin no core-admin front | `core/apps/admin/src/router/index.ts` (sem rota de MFA) |

### pixadmin (`pix-api`)

| Fato | Evidência |
|---|---|
| `MfaPolicy` já existe, com a isenção de `admin@monetarie.com(.br)` | `pix/backend/apps/shared/lib/shared/auth/mfa_policy.ex:13` |
| **O login não bloqueia**: com `mfa_enabled=false` grava o cookie de sessão e devolve sessão completa, só marcando `mfa_setup_required: true` no corpo. É dica para o front; quem chama a API direto entra sem MFA | `pix/backend/apps/dict_service/lib/dict_service_web/controllers/auth_controller.ex:85-109` |
| O front já tem o fluxo inteiro | `pix/frontend/admin/src/views/security/MfaSetupView.vue`, `services/mfa.ts` |
| Não existe MFA por operação no `spi_service` | busca por `verify_totp`/`MfaPolicy` em `spi_service` sem resultado |

### spbadmin (`spb-api`, que é o `bacen_gateway`)

| Fato | Evidência |
|---|---|
| Existe motor TOTP e `MFAController` com setup, verify e enable | `spb/services/bacen_gateway/lib/bacen_gateway/auth/mfa_engine.ex`, `.../bacen_gateway_web/router.ex:711-714` |
| O MFA é guardado em `system_settings`, não por usuário; a tabela `users` do SPB não tem coluna de MFA | `spb/services/bacen_gateway/lib/bacen_gateway_web/controllers/mfa_controller.ex:95-107`, `.../bacen_gateway/auth/user.ex:12-17` |
| O login **não referencia MFA** em lugar nenhum | `spb/services/bacen_gateway/lib/bacen_gateway_web/controllers/auth_controller.ex:23` |
| O `api_gateway` tem proxy para `auth-service.spb.svc.cluster.local:4001` (DNS de Kubernetes, não existe na ECS) com fallback por `SPB_ADMIN_PASSWORD`, caminho que ignora tudo | `spb/services/api_gateway/lib/api_gateway_web/controllers/auth_proxy_controller.ex:16,44-78` |

### staadmin (`sta-api`)

| Fato | Evidência |
|---|---|
| **Zero MFA**. O schema tem só email, name, password_hash, role, status, last_login_at e metadata | `sta/backend/lib/sta_connector/auth/user.ex:30-37` |
| A autenticação é bcrypt puro | `sta/backend/lib/sta_connector/auth.ex:28-46` |
| Existe login por variável de ambiente como fallback | `sta/backend/lib/sta_connector_web/controllers/api/admin/auth_controller.ex:163-184` |
| O front não tem nenhuma tela de MFA | busca por `mfa`/`totp` em `sta/admin-portal/src` sem resultado |

### SSO cruzado, que muda o desenho

O Core emite token SSO (`iss: monetarie-sso`, com `target_system`) aceito pelos três
`sso_auth.ex` de PIX, SPB e STA. Uma sessão do coreadmin entra nas três cabines.

Dois furos graves achados aqui:

1. **PIX ignora o RBAC para qualquer token do Core.**
   `pix/backend/apps/shared/lib/shared/plugs/require_permission.ex:55-81` devolve a `conn`
   sem checar nada quando o token tem `iss: "monetarie"`. Ou seja, qualquer token do Core,
   inclusive de um admin com papel `viewer` ou `support`, tem acesso total a todo endpoint
   do PIX. O moduledoc da linha 14 afirma que usuários de SSO passam pelo RBAC normal, que
   é o oposto do que o código faz. Somado ao Core não ter MFA no login, a senha sozinha de
   um `viewer` abre a cabine PIX inteira.
2. **SPB promove a admin quem não tem papel.**
   `spb/services/api_gateway/lib/api_gateway_web/plugs/sso_auth.ex:41` faz
   `role: claims["role"] || "admin"`.

Consequência para o desenho: MFA só no Core não basta, porque cada cabine tem login local
próprio; e MFA só nas cabines não basta, porque o SSO do Core entra sem desafio. Tem que
fechar os dois lados, e o token SSO só pode ser emitido para sessão que passou MFA.

## Desenho

### Política, igual nos quatro

Um módulo por sistema, mesmo contrato:

- `exempt?(email)`: verdadeiro só para `admin@monetarie.com` e `admin@monetarie.com.br`.
- `setup_required?(email, mfa_enforced, mfa_enabled)`: verdadeiro quando não é isento,
  `mfa_enforced` é verdadeiro e `mfa_enabled` é falso.
- `challenge_required?(email, mfa_enforced, mfa_enabled)`: verdadeiro quando não é isento,
  `mfa_enforced` é verdadeiro e `mfa_enabled` é verdadeiro.

O `mfa_enforced` sai do banco, por usuário, default `true`. Só `super_admin` altera, e a
alteração vai para o log de auditoria.

### Login em duas etapas

Contrato único para os quatro backends:

1. `POST /login` com senha correta.
2. Se `challenge_required?`: responde `200` com `mfa_required: true` e um **token de
   desafio** de vida curta (5 minutos), que não abre nenhuma rota de negócio. Nenhum
   cookie de sessão é gravado.
3. `POST /login/mfa` com o token de desafio mais o TOTP. Código válido emite a sessão real.
4. Se `setup_required?`: responde `200` com `mfa_setup_required: true` e um **token de
   setup** de vida curta, que abre **apenas** as rotas de cadastro de MFA. Concluído o
   cadastro, a sessão real é emitida. Isso é o "forçar o cadastro no próximo login".
5. Se `exempt?` ou `mfa_enforced` falso: sessão real direto, como hoje.

O ponto que o PIX errou e que não se repete: enquanto o MFA não é resolvido, **nenhum
token de sessão é emitido**. Marcar uma flag no corpo da resposta e confiar no front não
é controle de acesso.

### MFA por operação

Guard chamado no topo de cada operação das duas listas do item 2 das decisões. Devolve
códigos distinguíveis (`mfa_setup_required`, `mfa_required`, `mfa_invalid`, `mfa_replay`)
para o front reagir. O código é de uso único, queimado em Redis com `SET NX`, no mesmo
padrão já provado em `MonetarieWeb.Auth.MfaGuard`. Se o Redis cair, o antirreplay se perde
mas o TOTP continua sendo exigido e validado.

### SSO

O Core passa a incluir a claim de MFA no token SSO, e só a emite para sessão que passou o
desafio. As três cabines exigem essa claim. O bypass de RBAC do PIX para `iss: "monetarie"`
sai, e o default `|| "admin"` do SPB vira fail-closed.

## Deploy

Código nos dois ambientes. Em **PRD o enforcement continua desligado** (`MFA_ENFORCEMENT`
segue `members`, que é `platform_admins: :optional`), conforme o dono pediu. Em **HML o
enforcement é ligado** para a prova empírica. Nenhum deploy de pix ou spb com o dono
operando money-path.

Provas exigidas em HML antes de considerar pronto:

- login sem MFA não emite sessão;
- o cadastro forçado funciona e termina em sessão real;
- código válido entra, código repetido é recusado;
- operação de dinheiro sem código é recusada;
- `admin@monetarie.com` continua entrando sem MFA;
- desligar `mfa_enforced` de um usuário realmente o libera.

---

# Prova empírica em homologação (2026-07-27)

Tag `homolog-c6bcc223-mfa-20260727` nos oito serviços. Os testes rodaram de
dentro da VPC, por ECS Exec, batendo no HTTP real do serviço (`127.0.0.1:4000`),
não em mock nem em teste de unidade.

## Os seis cenários exigidos

| # | Cenário | Resultado |
|---|---|---|
| 1 | Login de quem não tem MFA **não emite sessão** | HTTP 200, `mfa_setup_required=true`, `setup_token` presente, **sem** `token` e **sem** `permissions` |
| 1b | O `setup_token` abre rota de negócio? | `GET /api/admin/auth/me` → **HTTP 401** |
| 2 | Cadastro forçado funciona e termina em sessão | QR Code PNG base64 entregue; código válido → HTTP 200 com `token` e **10** códigos de backup; no banco `totp_enabled=true`, `status=active` |
| 3 | Código válido entra, código errado é recusado | Login → `mfa_required=true` sem `token`; código errado → **401 `mfa_invalid`**; código válido → **200** com `token` |
| 4 | Operação de dinheiro sem código é recusada | Interruptor desligado: guard inerte (403 vem do RBAC). Ligado: sem código → **403 `mfa_required`**; com código → passa o MFA (para no RBAC seguinte); mesmo código de novo → **403 `mfa_replay`**; código errado → **403 `mfa_invalid`** |
| 5 | `admin@monetarie.com` continua entrando sem MFA | Login real com a senha do Secrets Manager → **HTTP 200 com `token`**, sem pedir MFA, mesmo com `mfa_enforced=true` e `totp_enabled=false` |
| 6 | Desligar `mfa_enforced` libera de fato | Com a flag `false` → entra direto com `token`; religando → volta a pedir MFA |

## Estado das quatro cabines em homologação

- **Core**: 7 repos migrados, login em duas etapas provado ponta a ponta.
- **PIX**: 7 usuários, todos com `mfa_enforced=true`. Token de desafio e de
  cadastro **não** viram sessão; o de desafio segue valendo no próprio passo.
- **SPB**: 201 usuários, todos com `mfa_enforced=true`. Mesmo resultado nos
  tokens de etapa.
- **STA**: colunas `mfa_backup_codes`, `mfa_enabled`, `mfa_enforced`,
  `mfa_secret`, `mfa_verified_at` criadas. Motor TOTP gera e confere. Login de
  emergência por variável de ambiente **desligado**.

## Dois fatos que mudam o plano de produção

1. **O console STA em PRODUÇÃO tem UM único usuário: `admin@monetarie.com`,
   `super_admin`, ativo.** Como essa conta é isenta por política fixa, ligar o
   MFA e fechar o login por variável de ambiente no STA **não tranca ninguém**.
2. **O `eval` de migration deste projeto sai com código 2 mesmo quando dá
   certo.** Provado: a segunda execução, sem nada para aplicar, também saiu 2,
   com os três repos em "already up" e nenhum erro no log. A migration do PIX
   aplicou (coluna conferida no banco: `mfa_enforced`, default `true`, NOT
   NULL). SPB e STA saíram 0. É artefato de encerramento, não falha.
