security(rbac): autorização por cargo na gestão de equipe/clínica

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 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-19 17:33:17 +02:00
parent f4e7fe37b1
commit 9ad65c358f
+19 -14
View File
@@ -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 });