security(rbac): autorização por cargo nos cadastros administrativos

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 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-19 17:52:32 +02:00
parent 9ad65c358f
commit 116200ccc6
+53 -35
View File
@@ -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.' });