security(rbac): autorização por cargo nas rotas financeiras (backend)

Adiciona requireCapability(cap) — espelha o isViewAllowed do frontend
(superadmin global; dono donoclinica/donoconsultorio/admin; cargo com
permissoes explícitas da funcao; senão mapa padrão por papel) — e aplica
APÓS o tenantGuard em 22 rotas:
- financeiro: GET/POST/PUT/DELETE + relatorio  (cap 'financeiro')
- glosas: listar/itens/recebiveis/criar/recorrer/recuperar/excluir (cap 'glosas')
- comissoes: regras GET/PUT/DELETE + relatorio (cap 'comissoes')
- repasses: fechar/listar/detalhe/pagar/cancelar/comprovante (cap 'comissoes')
/repasses/comprovante/:id/raw fica de fora (URL assinada, sem tenantGuard).

Antes: bastava ter vínculo (tenantGuard) p/ chamar a API — UI escondia, backend não.
Validado em DEV: dono 200; dentista 403; outra clínica 403 (isolamento intacto).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-19 16:01:54 +02:00
parent 1559387a7c
commit f4e7fe37b1
+64 -22
View File
@@ -267,6 +267,48 @@ function authGuard(req, res, next) {
}
}
// ── RBAC intra-clínica ───────────────────────────────────────────────────────
// Exige que o usuário tenha a "capability" (chave de view do frontend) no workspace
// ATIVO. Espelha o isViewAllowed do frontend: superadmin global → ok; dono
// (donoclinica/donoconsultorio/admin) → ok; cargo com permissões explícitas → ok se
// inclui a capability; senão, mapa padrão por papel. DEVE rodar APÓS o tenantGuard.
const CAP_OWNER_ROLES = ['donoclinica', 'donoconsultorio', 'admin'];
const CAP_DEFAULTS = {
paciente: ['tratamentos', 'notificacoes', 'configuracoes'],
dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'],
biomedico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'proteses', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'],
};
function requireCapability(cap) {
return async function (req, res, next) {
try {
const userId = req.authUser && req.authUser.userId;
if (!userId) return res.status(401).json({ error: 'Não autenticado.' });
const { rows: ur } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [userId]);
if (ur[0] && ur[0].role === 'superadmin') return next();
const clinicaId = req.clinicaId;
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const { rows } = await pool.query(
`SELECT v.role, f.permissoes FROM vinculos v LEFT JOIN funcoes f ON f.id = v.funcao_id
WHERE v.usuario_id = $1 AND v.clinica_id = $2`, [userId, clinicaId]);
if (!rows.length) return res.status(403).json({ error: 'ACESSO NEGADO: sem vínculo com esta unidade.' });
const role = rows[0].role;
if (CAP_OWNER_ROLES.includes(role)) return next();
let perms = rows[0].permissoes;
if (typeof perms === 'string') { try { perms = JSON.parse(perms); } catch { perms = null; } }
if (Array.isArray(perms) && perms.length > 0) {
if (perms.includes(cap)) return next();
return res.status(403).json({ error: `ACESSO NEGADO: seu cargo não inclui '${cap}'.` });
}
if ((CAP_DEFAULTS[role] || []).includes(cap)) return next();
return res.status(403).json({ error: `ACESSO NEGADO: seu cargo não permite '${cap}'.` });
} catch (e) {
return res.status(500).json({ error: 'Erro interno de autorização.' });
}
};
}
// =============================================================================
// AUDITORIA GENÉRICA + PENDÊNCIAS DE AGENDA (Fase 1)
@@ -2056,7 +2098,7 @@ app.get('/api/reports/productivity', tenantGuard, async (req, res) => {
});
// --- RELATÓRIO FINANCEIRO (H3.1 período / H3.2 por paciente e profissional) ---
app.get('/api/financeiro/relatorio', tenantGuard, async (req, res) => {
app.get('/api/financeiro/relatorio', tenantGuard, requireCapability('financeiro'), async (req, res) => {
try {
const clinicaId = req.clinicaId;
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
@@ -2149,7 +2191,7 @@ app.get('/api/financeiro/relatorio', tenantGuard, async (req, res) => {
// ═══ GLOSAS DE CONVÊNIO ══════════════════════════════════════════════════════
// Glosa = valor negado pelo convênio sobre um recebível. Acumula em financeiro.valor_glosa
// e mantém histórico em `glosas` (com recurso/recuperação).
app.get('/api/glosas', tenantGuard, async (req, res) => {
app.get('/api/glosas', tenantGuard, requireCapability('glosas'), async (req, res) => {
try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const { status } = req.query;
@@ -2169,7 +2211,7 @@ app.get('/api/glosas', tenantGuard, async (req, res) => {
});
// Recebíveis de convênio elegíveis a glosa (GTO/convênio, ainda não totalmente glosados/pagos)
app.get('/api/glosas/recebiveis', tenantGuard, async (req, res) => {
app.get('/api/glosas/recebiveis', tenantGuard, requireCapability('glosas'), async (req, res) => {
try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const { rows } = await pool.query(`
@@ -2185,7 +2227,7 @@ app.get('/api/glosas/recebiveis', tenantGuard, async (req, res) => {
});
// Itens da GTO por trás de um recebível (para glosa por item) — com flag de já glosado
app.get('/api/glosas/itens/:financeiroId', tenantGuard, async (req, res) => {
app.get('/api/glosas/itens/:financeiroId', tenantGuard, requireCapability('glosas'), async (req, res) => {
try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
// confirma que o recebível é desta clínica
@@ -2201,7 +2243,7 @@ app.get('/api/glosas/itens/:financeiroId', tenantGuard, async (req, res) => {
});
// Registra glosa sobre um recebível
app.post('/api/glosas', tenantGuard, async (req, res) => {
app.post('/api/glosas', tenantGuard, requireCapability('glosas'), async (req, res) => {
try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const { financeiro_id, motivo, gto_item_id, item_descricao, protocolo, prazo_recurso } = req.body;
@@ -2227,7 +2269,7 @@ app.post('/api/glosas', tenantGuard, async (req, res) => {
});
// Abre recurso na glosa (em andamento) — registra protocolo + prazo, mantém valor_glosa
app.post('/api/glosas/:id/recorrer', tenantGuard, async (req, res) => {
app.post('/api/glosas/:id/recorrer', tenantGuard, requireCapability('glosas'), async (req, res) => {
try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const { protocolo, prazo_recurso } = req.body;
@@ -2241,7 +2283,7 @@ app.post('/api/glosas/:id/recorrer', tenantGuard, async (req, res) => {
});
// Recurso ganho / glosa recuperada → devolve o valor ao recebível
app.post('/api/glosas/:id/recuperar', tenantGuard, async (req, res) => {
app.post('/api/glosas/:id/recuperar', tenantGuard, requireCapability('glosas'), async (req, res) => {
try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const { rows } = await pool.query('SELECT * FROM glosas WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]);
@@ -2256,7 +2298,7 @@ app.post('/api/glosas/:id/recuperar', tenantGuard, async (req, res) => {
});
// Exclui glosa (correção) — devolve o valor se estava ativa
app.delete('/api/glosas/:id', tenantGuard, async (req, res) => {
app.delete('/api/glosas/:id', tenantGuard, requireCapability('glosas'), async (req, res) => {
try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const { rows } = await pool.query('SELECT * FROM glosas WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]);
@@ -2335,7 +2377,7 @@ async function computeComissoes(q, clinicaId, { inicio, fim, base = 'recebido',
}
// Regras de comissão (% por procedimento + padrão)
app.get('/api/comissoes/regras', tenantGuard, async (req, res) => {
app.get('/api/comissoes/regras', tenantGuard, requireCapability('comissoes'), async (req, res) => {
try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const { rows } = await pool.query(`
@@ -2352,7 +2394,7 @@ app.get('/api/comissoes/regras', tenantGuard, async (req, res) => {
});
// Upsert de regra (procedimento_id null = padrão da clínica)
app.put('/api/comissoes/regras', tenantGuard, async (req, res) => {
app.put('/api/comissoes/regras', tenantGuard, requireCapability('comissoes'), async (req, res) => {
try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const procId = req.body.procedimento_id || null;
@@ -2371,7 +2413,7 @@ app.put('/api/comissoes/regras', tenantGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/comissoes/regras/:id', tenantGuard, async (req, res) => {
app.delete('/api/comissoes/regras/:id', tenantGuard, requireCapability('comissoes'), async (req, res) => {
try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const { rowCount } = await pool.query('DELETE FROM comissao_regras WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]);
@@ -2381,7 +2423,7 @@ app.delete('/api/comissoes/regras/:id', tenantGuard, async (req, res) => {
});
// Relatório de comissão ao vivo (não persiste)
app.get('/api/comissoes/relatorio', tenantGuard, async (req, res) => {
app.get('/api/comissoes/relatorio', tenantGuard, requireCapability('comissoes'), async (req, res) => {
try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const { inicio, fim, base, profissionalId } = req.query;
@@ -2391,7 +2433,7 @@ app.get('/api/comissoes/relatorio', tenantGuard, async (req, res) => {
});
// Fecha repasse do período (snapshot imutável por profissional + auditoria)
app.post('/api/repasses/fechar', tenantGuard, async (req, res) => {
app.post('/api/repasses/fechar', tenantGuard, requireCapability('comissoes'), async (req, res) => {
try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const { inicio, fim, base, profissionalId } = req.body;
@@ -2414,7 +2456,7 @@ app.post('/api/repasses/fechar', tenantGuard, async (req, res) => {
});
// Lista fechamentos de repasse
app.get('/api/repasses', tenantGuard, async (req, res) => {
app.get('/api/repasses', tenantGuard, requireCapability('comissoes'), async (req, res) => {
try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const { rows } = await pool.query(`
@@ -2429,7 +2471,7 @@ app.get('/api/repasses', tenantGuard, async (req, res) => {
});
// Detalhe de um fechamento (lançamentos snapshot)
app.get('/api/repasses/:id', tenantGuard, async (req, res) => {
app.get('/api/repasses/:id', tenantGuard, requireCapability('comissoes'), async (req, res) => {
try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const { rows } = await pool.query('SELECT * FROM repasses WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]);
@@ -2440,7 +2482,7 @@ app.get('/api/repasses/:id', tenantGuard, async (req, res) => {
});
// Cancela um fechamento (não apaga — auditoria)
app.post('/api/repasses/:id/cancelar', tenantGuard, async (req, res) => {
app.post('/api/repasses/:id/cancelar', tenantGuard, requireCapability('comissoes'), async (req, res) => {
try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const { rows } = await pool.query('SELECT profissional_nome, status, financeiro_id FROM repasses WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]);
@@ -2460,7 +2502,7 @@ app.post('/api/repasses/:id/cancelar', tenantGuard, async (req, res) => {
});
// Marca o repasse como PAGO e lança a DESPESA correspondente no financeiro (Gap 3)
app.post('/api/repasses/:id/pagar', tenantGuard, async (req, res) => {
app.post('/api/repasses/:id/pagar', tenantGuard, requireCapability('comissoes'), async (req, res) => {
try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const { rows } = await pool.query('SELECT * FROM repasses WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]);
@@ -2484,7 +2526,7 @@ app.post('/api/repasses/:id/pagar', tenantGuard, async (req, res) => {
});
// Anexa comprovante (imagem/PDF) ao repasse pago
app.post('/api/repasses/:id/comprovante', tenantGuard, ortoUpload.single('arquivo'), async (req, res) => {
app.post('/api/repasses/:id/comprovante', tenantGuard, requireCapability('comissoes'), ortoUpload.single('arquivo'), async (req, res) => {
try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
if (!req.file) return res.status(400).json({ error: 'Arquivo obrigatório.' });
@@ -3527,7 +3569,7 @@ function normalizeFinanceiro(body) {
return b;
}
app.get('/api/financeiro', tenantGuard, async (req, res) => {
app.get('/api/financeiro', tenantGuard, requireCapability('financeiro'), async (req, res) => {
try {
const clinicaId = req.clinicaId;
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
@@ -3536,7 +3578,7 @@ app.get('/api/financeiro', tenantGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/financeiro', tenantGuard, async (req, res) => {
app.post('/api/financeiro', tenantGuard, requireCapability('financeiro'), async (req, res) => {
try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const ins = buildInsert('financeiro', { ...normalizeFinanceiro(req.body), clinica_id: req.clinicaId });
@@ -3546,7 +3588,7 @@ app.post('/api/financeiro', tenantGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/financeiro/:id', tenantGuard, async (req, res) => {
app.put('/api/financeiro/:id', tenantGuard, requireCapability('financeiro'), async (req, res) => {
try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const own = await pool.query('SELECT * FROM financeiro WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]);
@@ -3569,7 +3611,7 @@ app.put('/api/financeiro/:id', tenantGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/financeiro/:id', tenantGuard, async (req, res) => {
app.delete('/api/financeiro/:id', tenantGuard, requireCapability('financeiro'), async (req, res) => {
try {
const clinicaId = req.clinicaId;
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });