security(rbac): autorização por cargo em orçamentos e contratos

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 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-19 18:07:03 +02:00
parent 5b58a8a01c
commit 16c13ad35d
+18 -18
View File
@@ -4033,7 +4033,7 @@ app.delete('/api/protese/os/:id', tenantGuard, async (req, res) => {
}); });
// --- CONTRATOS --- // --- CONTRATOS ---
app.get('/api/orcamentos', tenantGuard, async (req, res) => { app.get('/api/orcamentos', tenantGuard, requireCapability('orcamentos'), async (req, res) => {
try { try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const { paciente_id, status } = req.query; 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 }); } } 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 { try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const { items, ...contrato } = req.body; 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 }); } } 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 { try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); 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]); 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; 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 { try {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); 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]); 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/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; const clinicaId = req.clinicaId || null;
try { try {
const { rows } = await pool.query( 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 }); } } 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.' }); if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const { nome, categoria, tipo_relacao, corpo, vigencia_dias } = req.body || {}; 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.' }); 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 }); } } 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.' }); if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const { nome, categoria, tipo_relacao, corpo, vigencia_dias } = req.body || {}; 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.' }); 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 }); } } 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.' }); if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
try { try {
const { rowCount } = await pool.query( 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 }); } } 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.' }); if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
try { try {
const { rows } = await pool.query( 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); 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.' }); if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const b = req.body || {}; const b = req.body || {};
if (!b.titulo?.trim()) return res.status(400).json({ error: 'Título obrigatório.' }); 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 }); } } 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.' }); if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
try { try {
const { rows } = await pool.query( 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 }); } } 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.' }); if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
try { 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]); 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 }); } } 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.' }); if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const b = req.body || {}; const b = req.body || {};
try { 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 // 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.' }); if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
try { 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]); 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 }); } } 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.' }); if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
try { 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]); 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; 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). // 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.' }); if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
try { try {
const c = await loadContratoScoped(req.params.id, req.clinicaId); 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). // 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.' }); if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const parteId = req.body?.parteId; const parteId = req.body?.parteId;
if (!parteId) return res.status(400).json({ error: 'parteId obrigatório.' }); 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 }); } } 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.' }); if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
try { try {
const { rowCount } = await pool.query( const { rowCount } = await pool.query(