From 16c13ad35d22cc997f34da53b18f7fe6b86116d4 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Fri, 19 Jun 2026 18:07:03 +0200 Subject: [PATCH] =?UTF-8?q?security(rbac):=20autoriza=C3=A7=C3=A3o=20por?= =?UTF-8?q?=20cargo=20em=20or=C3=A7amentos=20e=20contratos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateia 18 rotas (tenantGuard + requireCapability), espelhando o frontend: - orcamentos (GET/POST/PUT/DELETE) → 'orcamentos' (funcionário + dono) - contratos (modelos CRUD+duplicar; instâncias CRUD+enviar+assinar+cancelar+ gerar-cobranca; GETs) → 'contratos' (funcionário + dono) 'assinar' incluído: usa tenantGuard+loadContratoScoped (quem assina é membro da clínica registrando a assinatura via ContratosView — não há fluxo de contraparte externa). Todos os endpoints de contrato são usados só no ContratosView (funcionário+dono). Propostas (marketplace, autorização por posse do profissional) ficam para sub-pass próprio. Validado em DEV: dono acessa (fake→404); dentista 403. Co-Authored-By: Claude Opus 4.8 --- backend/server.js | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/backend/server.js b/backend/server.js index 475f1ce..e3986c8 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4033,7 +4033,7 @@ app.delete('/api/protese/os/:id', tenantGuard, async (req, res) => { }); // --- CONTRATOS --- -app.get('/api/orcamentos', tenantGuard, async (req, res) => { +app.get('/api/orcamentos', tenantGuard, requireCapability('orcamentos'), async (req, res) => { try { if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); const { paciente_id, status } = req.query; @@ -4072,7 +4072,7 @@ app.get('/api/orcamentos', tenantGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.post('/api/orcamentos', tenantGuard, async (req, res) => { +app.post('/api/orcamentos', tenantGuard, requireCapability('orcamentos'), async (req, res) => { try { if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); const { items, ...contrato } = req.body; @@ -4093,7 +4093,7 @@ app.post('/api/orcamentos', tenantGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.put('/api/orcamentos/:id', tenantGuard, async (req, res) => { +app.put('/api/orcamentos/:id', tenantGuard, requireCapability('orcamentos'), async (req, res) => { try { if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); const own = await pool.query('SELECT financeiro_gerado FROM orcamentos WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]); @@ -4155,7 +4155,7 @@ async function gerarFinanceiroOrcamento(o, req) { return lanc.filter(l => l.valor > 0).length; } -app.delete('/api/orcamentos/:id', tenantGuard, async (req, res) => { +app.delete('/api/orcamentos/:id', tenantGuard, requireCapability('orcamentos'), async (req, res) => { try { if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); const own = await pool.query('SELECT 1 FROM orcamentos WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]); @@ -7065,7 +7065,7 @@ const MODELO_CONTRATO_ROW = 'id, clinica_id, nome, categoria, tipo_relacao, corp app.get('/api/contratos/variaveis', authGuard, async (_req, res) => res.json(VARIAVEIS_CONTRATO)); -app.get('/api/contratos/modelos', tenantGuard, async (req, res) => { +app.get('/api/contratos/modelos', tenantGuard, requireCapability('contratos'), async (req, res) => { const clinicaId = req.clinicaId || null; try { const { rows } = await pool.query( @@ -7076,7 +7076,7 @@ app.get('/api/contratos/modelos', tenantGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.post('/api/contratos/modelos', tenantGuard, async (req, res) => { +app.post('/api/contratos/modelos', tenantGuard, requireCapability('contratos'), async (req, res) => { if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); const { nome, categoria, tipo_relacao, corpo, vigencia_dias } = req.body || {}; if (!nome?.trim() || !corpo?.trim()) return res.status(400).json({ error: 'Nome e corpo são obrigatórios.' }); @@ -7090,7 +7090,7 @@ app.post('/api/contratos/modelos', tenantGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.put('/api/contratos/modelos/:id', tenantGuard, async (req, res) => { +app.put('/api/contratos/modelos/:id', tenantGuard, requireCapability('contratos'), async (req, res) => { if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); const { nome, categoria, tipo_relacao, corpo, vigencia_dias } = req.body || {}; if (!nome?.trim() || !corpo?.trim()) return res.status(400).json({ error: 'Nome e corpo são obrigatórios.' }); @@ -7104,7 +7104,7 @@ app.put('/api/contratos/modelos/:id', tenantGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.delete('/api/contratos/modelos/:id', tenantGuard, async (req, res) => { +app.delete('/api/contratos/modelos/:id', tenantGuard, requireCapability('contratos'), async (req, res) => { if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); try { const { rowCount } = await pool.query( @@ -7115,7 +7115,7 @@ app.delete('/api/contratos/modelos/:id', tenantGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.post('/api/contratos/modelos/:id/duplicar', tenantGuard, async (req, res) => { +app.post('/api/contratos/modelos/:id/duplicar', tenantGuard, requireCapability('contratos'), async (req, res) => { if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); try { const { rows } = await pool.query( @@ -7139,7 +7139,7 @@ function renderContratoCorpo(corpo, valores) { return String(corpo || '').replace(/\{\{(\w+)\}\}/g, (m, k) => (v[k] != null && String(v[k]).trim() !== '') ? String(v[k]) : m); } -app.post('/api/contratos/instancias', tenantGuard, async (req, res) => { +app.post('/api/contratos/instancias', tenantGuard, requireCapability('contratos'), async (req, res) => { if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); const b = req.body || {}; if (!b.titulo?.trim()) return res.status(400).json({ error: 'Título obrigatório.' }); @@ -7168,7 +7168,7 @@ app.post('/api/contratos/instancias', tenantGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.get('/api/contratos/instancias', tenantGuard, async (req, res) => { +app.get('/api/contratos/instancias', tenantGuard, requireCapability('contratos'), async (req, res) => { if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); try { const { rows } = await pool.query( @@ -7178,7 +7178,7 @@ app.get('/api/contratos/instancias', tenantGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.get('/api/contratos/instancias/:id', tenantGuard, async (req, res) => { +app.get('/api/contratos/instancias/:id', tenantGuard, requireCapability('contratos'), async (req, res) => { if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); try { const { rows } = await pool.query('SELECT * FROM contratos WHERE id=$1 AND deleted_at IS NULL AND clinica_origem_id=$2', [req.params.id, req.clinicaId]); @@ -7192,7 +7192,7 @@ app.get('/api/contratos/instancias/:id', tenantGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.put('/api/contratos/instancias/:id', tenantGuard, async (req, res) => { +app.put('/api/contratos/instancias/:id', tenantGuard, requireCapability('contratos'), async (req, res) => { if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); const b = req.body || {}; try { @@ -7213,7 +7213,7 @@ app.put('/api/contratos/instancias/:id', tenantGuard, async (req, res) => { }); // H4.2 — gera manualmente a cobrança recorrente da competência atual de um contrato vigente -app.post('/api/contratos/instancias/:id/gerar-cobranca', tenantGuard, async (req, res) => { +app.post('/api/contratos/instancias/:id/gerar-cobranca', tenantGuard, requireCapability('contratos'), async (req, res) => { if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); try { const { rows } = await pool.query('SELECT * FROM contratos WHERE id=$1 AND deleted_at IS NULL AND clinica_origem_id=$2', [req.params.id, req.clinicaId]); @@ -7228,7 +7228,7 @@ app.post('/api/contratos/instancias/:id/gerar-cobranca', tenantGuard, async (req } catch (err) { res.status(500).json({ error: err.message }); } }); -app.delete('/api/contratos/instancias/:id', tenantGuard, async (req, res) => { +app.delete('/api/contratos/instancias/:id', tenantGuard, requireCapability('contratos'), async (req, res) => { if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); try { const { rowCount } = await pool.query('UPDATE contratos SET deleted_at=NOW() WHERE id=$1 AND clinica_origem_id=$2 AND deleted_at IS NULL', [req.params.id, req.clinicaId]); @@ -7245,7 +7245,7 @@ async function loadContratoScoped(id, clinicaId) { const reqIp = (req) => (String(req.headers['x-forwarded-for'] || '').split(',')[0].trim()) || req.socket?.remoteAddress || null; // Enviar para assinatura: rascunho → aguardando_assinatura (cria assinaturas pendentes por parte). -app.post('/api/contratos/instancias/:id/enviar', tenantGuard, async (req, res) => { +app.post('/api/contratos/instancias/:id/enviar', tenantGuard, requireCapability('contratos'), async (req, res) => { if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); try { const c = await loadContratoScoped(req.params.id, req.clinicaId); @@ -7266,7 +7266,7 @@ app.post('/api/contratos/instancias/:id/enviar', tenantGuard, async (req, res) = }); // Assinar uma parte (aceite eletrônico simples do usuário logado: ip/hash/user-agent). -app.post('/api/contratos/instancias/:id/assinar', tenantGuard, async (req, res) => { +app.post('/api/contratos/instancias/:id/assinar', tenantGuard, requireCapability('contratos'), async (req, res) => { if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); const parteId = req.body?.parteId; if (!parteId) return res.status(400).json({ error: 'parteId obrigatório.' }); @@ -7297,7 +7297,7 @@ app.post('/api/contratos/instancias/:id/assinar', tenantGuard, async (req, res) } catch (err) { res.status(500).json({ error: err.message }); } }); -app.post('/api/contratos/instancias/:id/cancelar', tenantGuard, async (req, res) => { +app.post('/api/contratos/instancias/:id/cancelar', tenantGuard, requireCapability('contratos'), async (req, res) => { if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); try { const { rowCount } = await pool.query(