security(rbac): sub-pass clínico — fecha o RBAC intra-clínica

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 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-19 18:15:10 +02:00
parent 16c13ad35d
commit 0fc69bbcd3
+16 -16
View File
@@ -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;