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:
+64
-22
@@ -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.' });
|
||||
|
||||
Reference in New Issue
Block a user