From 0fc69bbcd352db9a9fb8aa2b82d9accbc4b3ef42 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Fri, 19 Jun 2026 18:15:10 +0200 Subject: [PATCH] =?UTF-8?q?security(rbac):=20sub-pass=20cl=C3=ADnico=20?= =?UTF-8?q?=E2=80=94=20fecha=20o=20RBAC=20intra-cl=C3=ADnica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateia 16 rotas clínicas remanescentes a 'agenda' (staff clínico; bloqueia paciente): - coleções (requireCapability): agenda/presenca (GET+POST), agenda/pendencias, agenda/atividade, agenda/importar-google, interesses (GET+POST), dentistas/horarios (POST), dentistas/:dentistaId/horarios (GET) - /:id (requireCapabilityRow, clinicaId da linha): agenda/pendencias/:id/resolver, dentistas/horarios/:id (DELETE), agendamentos/:id/{avaliacao,historico}, pacientes/:id/{score,faltas,familia} As /:id eram authGuard SEM validação de tenant — agora o requireCapabilityRow valida vínculo+cargo pela clínica da linha (fecha também o gap cross-tenant). Confirmado que telas de paciente (MeusTratamentos/Notifications) não usam nenhuma delas; detalhes de paciente são só do PatientsView (funcionário+dono). Validado em DEV: dono+dentista passam em tudo (200/404); row real→200, fake→404. Residual cosmético (fora do sub-pass): rotas /reorder (sem clinicaId/:id, baixo impacto). Co-Authored-By: Claude Opus 4.8 --- backend/server.js | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/backend/server.js b/backend/server.js index e3986c8..61ad1b2 100644 --- a/backend/server.js +++ b/backend/server.js @@ -2042,7 +2042,7 @@ app.delete('/api/dentistas/:id', authGuard, requireCapabilityRow('dentistas', 'd }); // --- GESTÃO DE HORÁRIOS --- -app.get('/api/dentistas/:dentistaId/horarios', authGuard, async (req, res) => { +app.get('/api/dentistas/:dentistaId/horarios', authGuard, requireCapability('agenda'), async (req, res) => { try { if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM dentistas WHERE id = $1', [req.params.dentistaId])) return; const { dentistaId } = req.params; @@ -2059,7 +2059,7 @@ app.get('/api/dentistas/:dentistaId/horarios', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.post('/api/dentistas/horarios', authGuard, async (req, res) => { +app.post('/api/dentistas/horarios', authGuard, requireCapability('agenda'), async (req, res) => { try { if (!await assertCidScoped(req, res, req.body?.clinica_id)) return; const { id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo } = req.body; @@ -2075,7 +2075,7 @@ app.post('/api/dentistas/horarios', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.delete('/api/dentistas/horarios/:id', authGuard, async (req, res) => { +app.delete('/api/dentistas/horarios/:id', authGuard, requireCapabilityRow('agenda', 'dentistas_horarios'), async (req, res) => { try { if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM dentistas_horarios WHERE id = $1', [req.params.id])) return; await pool.query('DELETE FROM dentistas_horarios WHERE id = $1', [req.params.id]); @@ -3010,7 +3010,7 @@ app.get('/api/avaliacoes', authGuard, tenantGuard, async (req, res) => { }); // Avaliação ligada a um agendamento (o dentista abre pelo slot de avaliação). -app.get('/api/agendamentos/:id/avaliacao', authGuard, async (req, res) => { +app.get('/api/agendamentos/:id/avaliacao', authGuard, requireCapabilityRow('agenda', 'agendamentos'), async (req, res) => { try { if (!await loadAgendamentoScoped(req, res)) return; const { rows } = await pool.query('SELECT * FROM avaliacoes WHERE agendamento_id = $1 ORDER BY created_at DESC LIMIT 1', [req.params.id]); @@ -3100,7 +3100,7 @@ app.delete('/api/avaliacoes/fotos/:id', authGuard, async (req, res) => { }); // Histórico (auditoria) de um agendamento. -app.get('/api/agendamentos/:id/historico', authGuard, async (req, res) => { +app.get('/api/agendamentos/:id/historico', authGuard, requireCapabilityRow('agenda', 'agendamentos'), async (req, res) => { try { if (!await loadAgendamentoScoped(req, res)) return; const { rows } = await pool.query( @@ -3111,7 +3111,7 @@ app.get('/api/agendamentos/:id/historico', authGuard, async (req, res) => { }); // Pendências "a reagendar" da clínica (default: abertas). -app.get('/api/agenda/pendencias', authGuard, async (req, res) => { +app.get('/api/agenda/pendencias', authGuard, requireCapability('agenda'), async (req, res) => { try { const { clinicaId, status } = req.query; if (!clinicaId) return res.json({ success: true, pendencias: [] }); @@ -3123,7 +3123,7 @@ app.get('/api/agenda/pendencias', authGuard, async (req, res) => { }); // Baixa manual de pendência ("não vai reagendar" / dispensar). -app.post('/api/agenda/pendencias/:id/resolver', authGuard, async (req, res) => { +app.post('/api/agenda/pendencias/:id/resolver', authGuard, requireCapabilityRow('agenda', 'agenda_pendencias'), async (req, res) => { try { const actor = req.authUser?.userId || null; const actorNm = await actorNome(actor); @@ -3135,7 +3135,7 @@ app.post('/api/agenda/pendencias/:id/resolver', authGuard, async (req, res) => { }); // Contagem de faltas de um paciente. -app.get('/api/pacientes/:id/faltas', authGuard, async (req, res) => { +app.get('/api/pacientes/:id/faltas', authGuard, requireCapabilityRow('agenda', 'pacientes'), async (req, res) => { try { if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM pacientes WHERE id = $1', [req.params.id])) return; const { rows } = await pool.query(`SELECT count(*)::int AS faltas FROM agendamentos WHERE pacienteid=$1 AND lower(status)='falta'`, [req.params.id]); @@ -3145,7 +3145,7 @@ app.get('/api/pacientes/:id/faltas', authGuard, async (req, res) => { // Score do paciente: faltas (no-show sem aviso = ponto negativo) + remarcações (não pontua, // mas em grande número = atenção). Remarcações vêm do HISTÓRICO (audit_log 'remarcou'), durável. -app.get('/api/pacientes/:id/score', authGuard, async (req, res) => { +app.get('/api/pacientes/:id/score', authGuard, requireCapabilityRow('agenda', 'pacientes'), async (req, res) => { try { if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM pacientes WHERE id = $1', [req.params.id])) return; const pid = req.params.id; @@ -3164,7 +3164,7 @@ app.get('/api/pacientes/:id/score', authGuard, async (req, res) => { // --- PRESENÇA NA AGENDA (Fase 2) --- // Heartbeat: registra a última visita (DB, persistente) + presença AO VIVO (Dragonfly, TTL). -app.post('/api/agenda/presenca', authGuard, async (req, res) => { +app.post('/api/agenda/presenca', authGuard, requireCapability('agenda'), async (req, res) => { try { const actor = req.authUser?.userId || null; const { clinicaId } = req.body || {}; @@ -3185,7 +3185,7 @@ app.post('/api/agenda/presenca', authGuard, async (req, res) => { }); // Quem está ONLINE agora (Dragonfly, janela de 45s) + últimas visitas (DB). -app.get('/api/agenda/presenca', authGuard, async (req, res) => { +app.get('/api/agenda/presenca', authGuard, requireCapability('agenda'), async (req, res) => { try { const { clinicaId } = req.query; if (!clinicaId) return res.json({ success: true, online: [], ultimas: [] }); @@ -3210,7 +3210,7 @@ app.get('/api/agenda/presenca', authGuard, async (req, res) => { }); // Feed de ATIVIDADE da equipe (auditoria da clínica) — default: hoje. -app.get('/api/agenda/atividade', authGuard, async (req, res) => { +app.get('/api/agenda/atividade', authGuard, requireCapability('agenda'), async (req, res) => { try { const { clinicaId, desde } = req.query; if (!clinicaId) return res.json({ success: true, atividade: [] }); @@ -3228,7 +3228,7 @@ app.get('/api/agenda/atividade', authGuard, async (req, res) => { // Importa agendamentos do Google Calendar p/ DENTRO do sistema (viram agendamentos editáveis). // Janela: últimos 7 dias + próximos N dias (default 60). Idempotente (dedup por google_event_id). -app.post('/api/agenda/importar-google', authGuard, async (req, res) => { +app.post('/api/agenda/importar-google', authGuard, requireCapability('agenda'), async (req, res) => { try { const { clinicaId, dias } = req.body || {}; if (!clinicaId) return res.status(400).json({ success: false, message: 'clinicaId obrigatório.' }); @@ -3277,7 +3277,7 @@ app.post('/api/agenda/importar-google', authGuard, async (req, res) => { }); // Grupo familiar do paciente (para chips compactos no modal de agenda). -app.get('/api/pacientes/:id/familia', authGuard, async (req, res) => { +app.get('/api/pacientes/:id/familia', authGuard, requireCapabilityRow('agenda', 'pacientes'), async (req, res) => { try { if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM pacientes WHERE id = $1', [req.params.id])) return; const { rows: pr } = await pool.query('SELECT grupofamiliarid FROM pacientes WHERE id = $1', [req.params.id]); @@ -3292,7 +3292,7 @@ app.get('/api/pacientes/:id/familia', authGuard, async (req, res) => { // --- INTERESSES DE AGENDA (Fase 4) --- // Fila de interesse quando o horário do profissional está ocupado (não trava o slot). -app.post('/api/interesses', authGuard, async (req, res) => { +app.post('/api/interesses', authGuard, requireCapability('agenda'), async (req, res) => { try { const { dentistaId, profissionalUsuarioId, clinicaInteressadaId, solicitanteUsuarioId, start, end, tempoEsperaMin, observacoes } = req.body; if (!clinicaInteressadaId || !start) return res.status(400).json({ success: false, message: 'clinicaInteressadaId e start são obrigatórios.' }); @@ -3329,7 +3329,7 @@ app.post('/api/interesses', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); -app.get('/api/interesses', authGuard, async (req, res) => { +app.get('/api/interesses', authGuard, requireCapability('agenda'), async (req, res) => { try { const { profissionalId, clinicaId } = req.query; const uid = req.authUser?.userId;