From 116200ccc6b54d4c396d9df379d22bda3dcba0d2 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Fri, 19 Jun 2026 17:52:32 +0200 Subject: [PATCH] =?UTF-8?q?security(rbac):=20autoriza=C3=A7=C3=A3o=20por?= =?UTF-8?q?=20cargo=20nos=20cadastros=20administrativos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refatora a autorização: extrai capabilityCore; requireCapability passa a derivar o clinicaId da requisição (param/body/query) quando não há tenantGuard; adiciona requireCapabilityRow(cap, tabela) p/ rotas /:id cujo clinicaId vem da LINHA. Gateia 12 rotas (cap por cargo, espelhando o frontend): - especialidades/procedimentos: POST + PUT/:id + DELETE (dono OU biomédico) - planos: POST + PUT/:id + DELETE (dono) - dentistas: POST + PUT/:id + DELETE/:id (dono) GETs de catálogo seguem abertos (dados de referência usados em toda a app). Corrige também risco pré-existente: POST/PUT de catálogo usavam authGuard SEM validar vínculo (escrita cross-tenant) — agora validam vínculo + cargo. Deferido: rotas /reorder (sem clinicaId/:id; baixo impacto) e horários de dentista (grupo agenda). Validado em DEV: dono 404(fake)/dentista 403; GETs 200; negação por linha sem apagar dados. Co-Authored-By: Claude Opus 4.8 --- backend/server.js | 88 ++++++++++++++++++++++++++++------------------- 1 file changed, 53 insertions(+), 35 deletions(-) diff --git a/backend/server.js b/backend/server.js index affbfee..a85e1ca 100644 --- a/backend/server.js +++ b/backend/server.js @@ -280,32 +280,50 @@ const CAP_DEFAULTS = { 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'], }; +// Núcleo da decisão de capability (req.clinicaId já resolvido). +async function capabilityCore(cap, req, res, next) { + 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}'.` }); +} + +// Capability pela clínica da REQUISIÇÃO (param/body/query). Valida vínculo + cargo. 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.' }); - } + if (!req.clinicaId) req.clinicaId = req.params.clinicaId || req.body?.clinica_id || req.query?.clinicaId || req.body?.clinicaId || null; + return await capabilityCore(cap, req, res, next); + } catch (e) { return res.status(500).json({ error: 'Erro interno de autorização.' }); } + }; +} + +// Capability pela clínica da LINHA do recurso (tabela + req.params.id) — para PUT/DELETE /:id +// quando o clinicaId não vem na requisição. Itens globais (clinica_id NULL) → só superadmin. +function requireCapabilityRow(cap, table) { + return async function (req, res, next) { + try { + const { rows } = await pool.query(`SELECT clinica_id AS cid FROM ${table} WHERE id = $1`, [req.params.id]); + if (!rows.length) return res.status(404).json({ error: 'Recurso não encontrado.' }); + req.clinicaId = rows[0].cid || null; + return await capabilityCore(cap, req, res, next); + } catch (e) { return res.status(500).json({ error: 'Erro interno de autorização.' }); } }; } @@ -1946,7 +1964,7 @@ function sanitizeDentista(d) { return out; } -app.post('/api/dentistas', tenantGuard, async (req, res) => { +app.post('/api/dentistas', tenantGuard, requireCapability('dentistas'), async (req, res) => { try { const d = req.body; let tempPassword = null, contaCriada = false; @@ -1993,7 +2011,7 @@ app.put('/api/dentistas/reorder', authGuard, async (req, res) => { } }); -app.put('/api/dentistas/:id', authGuard, async (req, res) => { +app.put('/api/dentistas/:id', authGuard, requireCapabilityRow('dentistas', 'dentistas'), async (req, res) => { try { if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM dentistas WHERE id = $1', [req.params.id])) return; const upd = buildUpdate('dentistas', sanitizeDentista(req.body), 'id', req.params.id); @@ -2002,7 +2020,7 @@ app.put('/api/dentistas/:id', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.delete('/api/dentistas/:id', authGuard, async (req, res) => { +app.delete('/api/dentistas/:id', authGuard, requireCapabilityRow('dentistas', 'dentistas'), async (req, res) => { try { if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM dentistas WHERE id = $1', [req.params.id])) return; const { id } = req.params; @@ -3347,7 +3365,7 @@ app.get('/api/especialidades', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.post('/api/especialidades', authGuard, async (req, res) => { +app.post('/api/especialidades', authGuard, requireCapability('especialidades'), async (req, res) => { try { const { rows: userRows } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser.userId]); const isSuperAdmin = userRows[0]?.role === 'superadmin'; @@ -3376,7 +3394,7 @@ app.put('/api/especialidades/reorder', authGuard, async (req, res) => { } }); -app.put('/api/especialidades/:id', authGuard, async (req, res) => { +app.put('/api/especialidades/:id', authGuard, requireCapabilityRow('especialidades', 'especialidades'), async (req, res) => { try { if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM especialidades WHERE id = $1', [req.params.id])) return; const upd = buildUpdate('especialidades', req.body, 'id', req.params.id); @@ -3385,7 +3403,7 @@ app.put('/api/especialidades/:id', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.delete('/api/especialidades/:id', tenantGuard, async (req, res) => { +app.delete('/api/especialidades/:id', tenantGuard, requireCapability('especialidades'), async (req, res) => { try { const clinicaId = req.clinicaId; if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); @@ -3407,7 +3425,7 @@ app.get('/api/planos', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.post('/api/planos', authGuard, async (req, res) => { +app.post('/api/planos', authGuard, requireCapability('planos'), async (req, res) => { try { const { rows: userRows } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser.userId]); const isSuperAdmin = userRows[0]?.role === 'superadmin'; @@ -3418,7 +3436,7 @@ app.post('/api/planos', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.put('/api/planos/:id', authGuard, async (req, res) => { +app.put('/api/planos/:id', authGuard, requireCapabilityRow('planos', 'planos'), async (req, res) => { try { if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM planos WHERE id = $1', [req.params.id])) return; const upd = buildUpdate('planos', req.body, 'id', req.params.id); @@ -3427,7 +3445,7 @@ app.put('/api/planos/:id', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.delete('/api/planos/:id', tenantGuard, async (req, res) => { +app.delete('/api/planos/:id', tenantGuard, requireCapability('planos'), async (req, res) => { try { const clinicaId = req.clinicaId; if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); @@ -3467,7 +3485,7 @@ app.get('/api/procedimentos', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.post('/api/procedimentos', authGuard, async (req, res) => { +app.post('/api/procedimentos', authGuard, requireCapability('procedimentos'), async (req, res) => { try { const { rows: userRows } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser.userId]); const isSuperAdmin = userRows[0]?.role === 'superadmin'; @@ -3527,7 +3545,7 @@ app.put('/api/procedimentos/reorder', authGuard, async (req, res) => { } }); -app.put('/api/procedimentos/:id', authGuard, async (req, res) => { +app.put('/api/procedimentos/:id', authGuard, requireCapabilityRow('procedimentos', 'procedimentos'), async (req, res) => { try { if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM procedimentos WHERE id = $1', [req.params.id])) return; const { valoresPlanos, ...proc } = req.body; @@ -3550,7 +3568,7 @@ app.put('/api/procedimentos/:id', authGuard, async (req, res) => { } }); -app.delete('/api/procedimentos/:id', tenantGuard, async (req, res) => { +app.delete('/api/procedimentos/:id', tenantGuard, requireCapability('procedimentos'), async (req, res) => { try { const clinicaId = req.clinicaId; if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });