From 9ad65c358f9abf5248b983ca985fea08eb7fdba7 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Fri, 19 Jun 2026 17:33:17 +0200 Subject: [PATCH] =?UTF-8?q?security(rbac):=20autoriza=C3=A7=C3=A3o=20por?= =?UTF-8?q?=20cargo=20na=20gest=C3=A3o=20de=20equipe/cl=C3=ADnica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aplica requireCapability('gestao-equipe') (dono-only por padrão; ou cargo com a permissão) APÓS tenantGuard em 12 rotas: membros, setores e funcoes (GET + mutações). - reset-senha: troca authGuard → tenantGuard + requireCapability (antes não validava o vínculo do solicitante; só hierarquia). - PUT /clinicas/:id/color: eleva de 'tem vínculo' para 'dono/admin' (igual a /nome). - nome, PUT /clinicas/:clinicaId e PUT /areas já tinham checagem de dono (mantidos). - GET /clinicas/:clinicaId (branding) segue legível por qualquer membro (por design). Checagens de hierarquia (nivelNaClinica) preservadas como defesa secundária. Validado em DEV: dono ok; dentista 403 (inclui /color); outra clínica 403. Co-Authored-By: Claude Opus 4.8 --- backend/server.js | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/backend/server.js b/backend/server.js index a00cbfd..affbfee 100644 --- a/backend/server.js +++ b/backend/server.js @@ -644,7 +644,7 @@ app.post('/api/clinicas', async (req, res) => { } }); -app.get('/api/clinicas/:clinicaId/membros', tenantGuard, async (req, res) => { +app.get('/api/clinicas/:clinicaId/membros', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { try { const { rows } = await pool.query(` SELECT u.id, u.nome, u.email, v.role, v.setor_id, v.funcao_id, @@ -659,7 +659,7 @@ app.get('/api/clinicas/:clinicaId/membros', tenantGuard, async (req, res) => { } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); -app.post('/api/clinicas/:clinicaId/membros', tenantGuard, async (req, res) => { +app.post('/api/clinicas/:clinicaId/membros', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { const { email, role, setor_id, funcao_id, nome } = req.body; if (!email || !role) return res.status(400).json({ success: false, message: 'E-mail e função são obrigatórios.' }); try { @@ -679,7 +679,7 @@ app.post('/api/clinicas/:clinicaId/membros', tenantGuard, async (req, res) => { } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); -app.put('/api/clinicas/:clinicaId/membros/:userId', tenantGuard, async (req, res) => { +app.put('/api/clinicas/:clinicaId/membros/:userId', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { const { role, setor_id, funcao_id } = req.body; try { const reqNivel = await nivelNaClinica(req.authUser.userId, req.params.clinicaId); @@ -695,7 +695,7 @@ app.put('/api/clinicas/:clinicaId/membros/:userId', tenantGuard, async (req, res // Reset de senha pelo dono/admin da clínica → gera nova senha temporária (resolve // contas criadas sem senha ou esquecidas). Alvo precisa ser membro desta unidade. -app.post('/api/clinicas/:clinicaId/membros/:userId/reset-senha', authGuard, async (req, res) => { +app.post('/api/clinicas/:clinicaId/membros/:userId/reset-senha', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { const clinicaId = req.params.clinicaId, alvo = req.params.userId, requester = req.authUser.userId; try { const { rows: tv } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [alvo, clinicaId]); @@ -712,7 +712,7 @@ app.post('/api/clinicas/:clinicaId/membros/:userId/reset-senha', authGuard, asyn } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); -app.delete('/api/clinicas/:clinicaId/membros/:userId', tenantGuard, async (req, res) => { +app.delete('/api/clinicas/:clinicaId/membros/:userId', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { try { if (await nivelNaClinica(req.authUser.userId, req.params.clinicaId) <= await nivelNaClinica(req.params.userId, req.params.clinicaId)) { return res.status(403).json({ success: false, message: 'Você só pode remover membros de nível inferior ao seu.' }); @@ -723,14 +723,14 @@ app.delete('/api/clinicas/:clinicaId/membros/:userId', tenantGuard, async (req, }); // --- SETORES (por clínica) --- -app.get('/api/clinicas/:clinicaId/setores', tenantGuard, async (req, res) => { +app.get('/api/clinicas/:clinicaId/setores', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { try { const { rows } = await pool.query('SELECT id, nome, descricao, cor FROM setores WHERE clinica_id = $1 ORDER BY nome', [req.params.clinicaId]); res.json({ success: true, setores: rows }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); -app.post('/api/clinicas/:clinicaId/setores', tenantGuard, async (req, res) => { +app.post('/api/clinicas/:clinicaId/setores', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { const { nome, descricao, cor } = req.body; if (!nome) return res.status(400).json({ success: false, message: 'Nome é obrigatório.' }); try { @@ -740,7 +740,7 @@ app.post('/api/clinicas/:clinicaId/setores', tenantGuard, async (req, res) => { } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); -app.put('/api/clinicas/:clinicaId/setores/:id', tenantGuard, async (req, res) => { +app.put('/api/clinicas/:clinicaId/setores/:id', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { const { nome, descricao, cor } = req.body; try { await pool.query('UPDATE setores SET nome = COALESCE($1, nome), descricao = $2, cor = COALESCE($3, cor) WHERE id = $4 AND clinica_id = $5', @@ -749,7 +749,7 @@ app.put('/api/clinicas/:clinicaId/setores/:id', tenantGuard, async (req, res) => } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); -app.delete('/api/clinicas/:clinicaId/setores/:id', tenantGuard, async (req, res) => { +app.delete('/api/clinicas/:clinicaId/setores/:id', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { try { await pool.query('UPDATE vinculos SET setor_id = NULL WHERE setor_id = $1 AND clinica_id = $2', [req.params.id, req.params.clinicaId]); await pool.query('DELETE FROM setores WHERE id = $1 AND clinica_id = $2', [req.params.id, req.params.clinicaId]); @@ -758,14 +758,14 @@ app.delete('/api/clinicas/:clinicaId/setores/:id', tenantGuard, async (req, res) }); // --- FUNÇÕES (por clínica) --- -app.get('/api/clinicas/:clinicaId/funcoes', tenantGuard, async (req, res) => { +app.get('/api/clinicas/:clinicaId/funcoes', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { try { const { rows } = await pool.query('SELECT id, nome, descricao, permissoes, nivel FROM funcoes WHERE clinica_id = $1 ORDER BY nivel DESC, nome', [req.params.clinicaId]); res.json({ success: true, funcoes: rows }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); -app.post('/api/clinicas/:clinicaId/funcoes', tenantGuard, async (req, res) => { +app.post('/api/clinicas/:clinicaId/funcoes', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { const { nome, descricao, permissoes, nivel } = req.body; if (!nome) return res.status(400).json({ success: false, message: 'Nome é obrigatório.' }); try { @@ -776,7 +776,7 @@ app.post('/api/clinicas/:clinicaId/funcoes', tenantGuard, async (req, res) => { } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); -app.put('/api/clinicas/:clinicaId/funcoes/:id', tenantGuard, async (req, res) => { +app.put('/api/clinicas/:clinicaId/funcoes/:id', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { const { nome, descricao, permissoes, nivel } = req.body; try { await pool.query( @@ -788,7 +788,7 @@ app.put('/api/clinicas/:clinicaId/funcoes/:id', tenantGuard, async (req, res) => } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); -app.delete('/api/clinicas/:clinicaId/funcoes/:id', tenantGuard, async (req, res) => { +app.delete('/api/clinicas/:clinicaId/funcoes/:id', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { try { await pool.query('UPDATE vinculos SET funcao_id = NULL WHERE funcao_id = $1 AND clinica_id = $2', [req.params.id, req.params.clinicaId]); await pool.query('DELETE FROM funcoes WHERE id = $1 AND clinica_id = $2', [req.params.id, req.params.clinicaId]); @@ -800,7 +800,12 @@ app.delete('/api/clinicas/:clinicaId/funcoes/:id', tenantGuard, async (req, res) app.put('/api/clinicas/:id/color', authGuard, async (req, res) => { const { cor } = req.body; if (!cor || !/^#[0-9a-fA-F]{6}$/.test(cor)) return res.status(400).json({ error: 'Cor inválida (use #RRGGBB).' }); - if (!await userHasVinculo(req.authUser?.userId, req.params.id)) return res.status(403).json({ error: 'Acesso negado: unidade de outra conta.' }); + // Só dono/admin da unidade altera a identidade visual. + const { rows: _own } = await pool.query( + `SELECT 1 FROM clinicas WHERE id=$1 AND owner_id=$2 + UNION SELECT 1 FROM vinculos WHERE clinica_id=$1 AND usuario_id=$2 AND role = ANY(ARRAY['donoclinica','donoconsultorio','admin']) LIMIT 1`, + [req.params.id, req.authUser?.userId]); + if (!_own.length) return res.status(403).json({ error: 'Apenas o dono/admin pode alterar a cor da unidade.' }); try { await pool.query('UPDATE clinicas SET cor = $1 WHERE id = $2', [cor, req.params.id]); res.json({ success: true });