From f4e7fe37b1afc99cc271fd6f45ebd620ecadb710 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Fri, 19 Jun 2026 16:01:54 +0200 Subject: [PATCH] =?UTF-8?q?security(rbac):=20autoriza=C3=A7=C3=A3o=20por?= =?UTF-8?q?=20cargo=20nas=20rotas=20financeiras=20(backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/server.js | 86 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 64 insertions(+), 22 deletions(-) diff --git a/backend/server.js b/backend/server.js index c6b9773..a00cbfd 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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.' });