Helper require_perm: gate de permisos por scope/level
Descripción
core.utils.require_perm(request, scope, level) es un helper de seguridad reutilizable para validar permisos en endpoints Django Ninja. Lanza HttpError(403) si el usuario no tiene el permiso requerido.
Introducido en: Auditoría Suprema s105 (fix Broken Access Control sistemático).
Firma y uso
from core.utils import require_perm
@router.post("/api/blueprints", response={201: BlueprintSchema})
def create_blueprint(request, payload: BlueprintInputSchema):
"""Create a new blueprint."""
require_perm(request, "blueprints", "edit") # Lanza 403 si user no tiene blueprints.edit
...
Parámetros:
request: objeto request de Django (contienerequest.usery datos de org/permisos).scope: string del módulo/alcance ("blueprints","racks","monitoring", etc.).level: string del nivel de permiso ("view"para lectura,"edit"para mutación/operaciones costosas).
Comportamiento:
- ✅ Si el usuario tiene el permiso → continúa ejecución.
- ❌ Si no lo tiene → lanza
HttpError(403, "Permission denied").
Implementación
Archivo: core/utils/permissions.py (nueva en s105)
Pseudocódigo:
from django.http import HttpError
def require_perm(request, scope, level):
"""
Valida que el usuario tenga permisos para scope.level.
Lanza HttpError(403) si no.
Consulta ModulePermission.objects.filter(user=request.user, module=scope, ...)
según el level requerido.
"""
user = getattr(request, 'user', None)
if not user or not user.is_authenticated:
raise HttpError(403, "Permission denied")
# Lógica de consulta de BD (ModulePermission)
# Si el usuario es readonly para el scope → solo "view" es permitido
# Si es operator/admin → "view" + "edit" son permitidos
has_perm = _check_user_perm(user, scope, level)
if not has_perm:
raise HttpError(403, "Permission denied")
Aplicaciones (s105)
Usado en ~25 endpoints del módulo blueprints:
- blueprints/api/blueprints.py:
list_blueprints,create_blueprint,get_blueprint,update_blueprint_settings,delete_blueprint,clone_blueprint,create_empty_blueprint,update_blueprint_bg,delete_blueprint_bg. - blueprints/api/racks.py:
list_placements,update_placement,delete_placement,update_rack_positions,get_blueprint_racks,create_rack_in_blueprint,delete_rack_total,duplicate_element. - blueprints/api/annotations.py:
get_annotations,create_annotation,update_annotation,delete_annotation,delete_connection. - blueprints/api/autoplan.py:
magic_import_blueprint(crítico: costo IA). - blueprints/api/prompt.py:
get_prompt_content,save_prompt_content,reset_prompt_content,list_saved_prompts,save_prompt_as,load_saved_prompt,delete_saved_prompt. - blueprints/api/trash.py:
list_trash_blueprints,restore_blueprint,permanent_delete_blueprint,empty_blueprint_trash.
Aplicaciones (23-08-2026, tasks #241/#251)
Ronda de seguridad extiende el helper al CRUD nuclear de racks (racks/api/racks.py, distinto de blueprints/api/racks.py de arriba) y a 3 escrituras de /api/settings — detalle completo en [[decision—20260823—racks-settings-broken-access-control]]:
- racks/api/racks.py:
create_group,bulk_assign_groups,delete_group,create_rack,update_rack,rename_rack_legacy,delete_rack,clone_rack,update_rack_devices,move_rack_device,copy_from(racks:edit);create_template,apply_template(racks:admin). - racks/api/trash.py:
restore_rack(racks:edit);permanent_delete_rack,empty_rack_trash(racks:admin). - core/api/settings.py:
complete_onboarding,reset_onboarding,update_session_timeout(users:admin).
Estos 17 puntos suman un test de contrato registry-driven (tests/api/test_racks_permissions.py, tests/api/test_settings_permissions.py) que recorre las operaciones REGISTRADAS en cada router y falla si alguna escritura no llama a require_perm — un endpoint nuevo sin gate rompe el CI solo.
Test de contrato universal (06-09-2026, task #279, v1.118.0)
tests/api/test_write_endpoints_permission_contract.py generaliza el patrón anterior (registry-driven, pero por dominio) a todos los routers Ninja montados en config.urls.api: recorre cada operación POST/PUT/PATCH/DELETE de cada router y exige un marcador de gate en el código fuente del handler — require_perm(, has_permission(, is_superuser, un return 403 explícito en el cuerpo, o delegación a una función del mismo módulo que sí gatea (alias legacy). Lista blanca razonada endpoint a endpoint para las exenciones reales (p. ej. los endpoints del Agente instalado, que se autentican con su propio JWT y no tienen “usuario” al que pedir nivel de permiso).
Este test fue el que encontró y motivó el fix de las 3 zonas de este commit, cada una un handler sin gate o con el scope equivocado:
core/htmx_device_groups.py— scope equivocado (racksen vez denetwork), ver [[entity—core—service—has-permission]].network/api/scripts.py::get_scripts— sin gate; ahoranetwork:view.monitoring/api/insight_execution.py::apply/rollback— subidos decns:editacns:admin(despachan comandos SSH reales a equipos, decisión de Edu);dry_runse queda enedit.
Deuda declarada sin cerrar: explain_insight no gatea, mientras su hermano tutor_ask exige cns:view — no se tocó porque explain_insight también acepta el JWT del Agente.
Límite: solo cubre escrituras (POST/PUT/PATCH/DELETE); una lectura que filtre datos sensibles (como el propio get_scripts, que es GET y devolvía script_content a cualquier autenticado) no la ve este test y necesita el suyo propio.
Pattern para nuevas features
Cualquier endpoint nuevo en módulos auditados (o nuevos módulos) debe usar este helper:
@router.post("/{resource_id}", response={201: dict})
def create_resource(request, resource_id: int, payload: PayloadSchema):
"""Create a resource."""
require_perm(request, "module_name", "edit") # <-- primero, antes de lógica
# ... resto del endpoint
Regla de oro: insertar require_perm(...) como primera línea funcional del endpoint (antes de get_current_org, queries, etc.).
Exportación
El helper está exportado en core/utils/__init__.py:
from .permissions import require_perm
__all__ = ["require_perm", "get_current_org", ...]
Para importar en cualquier módulo:
from core.utils import require_perm
Véase también
- [[feature—blueprints—auditoria-s105]]
- [[concept—saas—multi-tenancy]]
- [[entity—core—model—modulepermission]]
- [[concept—security—broken-access-control]]
- [[decision—20260823—racks-settings-broken-access-control]]
- [[entity—core—service—has-permission]]