Auditoria de plataforma: rutas rotas y errores por rol

Recorrido dinamico de las 26 rutas con los 10 roles (260 visitas con
navegador real) mas revision estatica de endpoints FE vs BE. Hallazgos
y correcciones:

- frontend.py: expedientes_recientes no tenia @http.route (el endpoint
  /expedientes-recientes respondia 404). Se expone con GET + lectura.
- auth.py: MEDICO_BLOCKED_PREFIXES hacia match por startswith, asi que
  /sales_goal quedaba bloqueado para medicos (403 en consola en todas
  las pantallas por el widget de meta del mes). Ahora el match exige
  frontera de path (/sales bloquea /sales/x pero no /sales_goal).
- Reportes: getCommissions (endpoint admin-only) iba dentro de un
  Promise.all; el 403 tumbaba todo el tab General para recepcion,
  gerencia, enfermeria, psicologa, farmacia y lectura. Ahora va en
  peticion aislada y solo se llama para admin; la seccion de comisiones
  del tab General solo se renderiza para admin.
- Matriz de menus guardada: medico habia quedado fuera de /pacientes
  y /visitas (los defaults del codigo lo incluyen y los medicos lo
  necesitan para ver clientes e historial). Restaurado via PUT
  /roles/permissions (queda en skeen_config_audit).

Verificado con navegador: medico entra a /pacientes y /visitas sin
403; recepcion y gerencia cargan /reportes sin error; admin sigue
viendo la seccion de comisiones. Usuarios temporales de auditoria
eliminados.
This commit is contained in:
2026-09-28 21:13:38 +00:00
parent a4358ada15
commit 5385fc3a53
3 changed files with 28 additions and 7 deletions

View File

@@ -36,6 +36,7 @@ import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '..
import { odooApi } from '../services/odoo'; import { odooApi } from '../services/odoo';
import type { CommissionRow } from '../services/odoo'; import type { CommissionRow } from '../services/odoo';
import { exportToExcel } from '../lib/exporter'; import { exportToExcel } from '../lib/exporter';
import { useAuth } from '../lib/auth';
import { import {
REPORT_PRESETS, REPORT_PRESETS,
RangeOverrideHint, RangeOverrideHint,
@@ -144,6 +145,8 @@ const ReportesContent: FC = () => {
const familiaKey = familiaDe(tab); const familiaKey = familiaDe(tab);
const familia = FAMILIAS.find((f) => f.key === familiaKey) || FAMILIAS[0]; const familia = FAMILIAS.find((f) => f.key === familiaKey) || FAMILIAS[0];
const { favs, toggle, isFav } = useReportFavorites(); const { favs, toggle, isFav } = useReportFavorites();
const { hasRole } = useAuth();
const esAdmin = hasRole('admin');
// Rango global del módulo (persistente, compartido por todas las pestañas) // Rango global del módulo (persistente, compartido por todas las pestañas)
const { start, end, preset, setRange } = useGlobalReportRange(); const { start, end, preset, setRange } = useGlobalReportRange();
const [today, setToday] = useState(() => new Date().toISOString().split('T')[0]); const [today, setToday] = useState(() => new Date().toISOString().split('T')[0]);
@@ -164,11 +167,10 @@ const ReportesContent: FC = () => {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const [salesRes, aptRes, cashRes, commRes] = await Promise.all([ const [salesRes, aptRes, cashRes] = await Promise.all([
odooApi.getSalesReport(start, end), odooApi.getSalesReport(start, end),
odooApi.getAppointmentsReport(start, end), odooApi.getAppointmentsReport(start, end),
odooApi.getCashReport(today), odooApi.getCashReport(today),
odooApi.getCommissions(start, end),
]); ]);
if (salesRes.status === 'success') if (salesRes.status === 'success')
setSalesReport({ setSalesReport({
@@ -181,10 +183,22 @@ const ReportesContent: FC = () => {
setAppointmentsReport({ total: aptRes.total, by_state: aptRes.by_state }); setAppointmentsReport({ total: aptRes.total, by_state: aptRes.by_state });
if (cashRes.status === 'success') if (cashRes.status === 'success')
setCashReport({ total: cashRes.total, by_method: cashRes.by_method }); setCashReport({ total: cashRes.total, by_method: cashRes.by_method });
// Comisiones viajan en petición aparte y solo para admin: el endpoint
// exige rol admin y un 403 no debe tumbar (ni ensuciar la consola del)
// resto del tab General para los demás roles.
if (esAdmin) {
odooApi.getCommissions(start, end)
.then((commRes) => {
if (commRes.status === 'success') { if (commRes.status === 'success') {
setCommissions(commRes.commissions || []); setCommissions(commRes.commissions || []);
setCommTotals({ base: commRes.total_base || 0, commission: commRes.total_commission || 0 }); setCommTotals({ base: commRes.total_base || 0, commission: commRes.total_commission || 0 });
} }
})
.catch(() => { /* sin permiso: sección de comisiones queda vacía */ });
} else {
setCommissions([]);
setCommTotals({ base: 0, commission: 0 });
}
} catch (err) { } catch (err) {
setError('Error al cargar reportes'); setError('Error al cargar reportes');
toast.error('Error al cargar reportes'); toast.error('Error al cargar reportes');
@@ -194,7 +208,7 @@ const ReportesContent: FC = () => {
} }
}; };
load(); load();
}, [start, end, today, tab]); }, [start, end, today, tab, esAdmin]);
const aptData = appointmentsReport const aptData = appointmentsReport
? Object.entries(appointmentsReport.by_state).map(([name, value]) => ({ name, value })) ? Object.entries(appointmentsReport.by_state).map(([name, value]) => ({ name, value }))
@@ -523,6 +537,7 @@ const ReportesContent: FC = () => {
</Card> </Card>
</div> </div>
{esAdmin && (
<Card className="mt-4 sm:mt-6"> <Card className="mt-4 sm:mt-6">
<Card.Body> <Card.Body>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
@@ -572,6 +587,7 @@ const ReportesContent: FC = () => {
)} )}
</Card.Body> </Card.Body>
</Card> </Card>
)}
</> </>
)} )}
</Layout> </Layout>

View File

@@ -152,7 +152,11 @@ def require_role(min_role='lectura'):
return _json({'status': 'error', 'message': 'No autenticado'}, 401) return _json({'status': 'error', 'message': 'No autenticado'}, 401)
if user.role_rank() < required: if user.role_rank() < required:
return _json({'status': 'error', 'message': 'Sin permiso'}, 403) return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
if user.role == 'medico' and request.httprequest.path.startswith(MEDICO_BLOCKED_PREFIXES): if user.role == 'medico' and any(
request.httprequest.path == p or request.httprequest.path.startswith(
p if p.endswith('/') else p + '/')
for p in MEDICO_BLOCKED_PREFIXES
):
return _json({'status': 'error', 'message': 'Sin permiso'}, 403) return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
request.frontend_user = user request.frontend_user = user
return func(self, *args, **kw) return func(self, *args, **kw)

View File

@@ -2797,6 +2797,7 @@ class SkeenFrontendController(http.Controller):
'remaining_count': max(0, count - pagados) if count else 0, 'remaining_count': max(0, count - pagados) if count else 0,
} }
@http.route('/skeen/frontend/v1/expedientes-recientes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura') @require_role('lectura')
def expedientes_recientes(self, **kw): def expedientes_recientes(self, **kw):
"""Pacientes con visitas recientes (últimos 30 días por default)""" """Pacientes con visitas recientes (últimos 30 días por default)"""