CreaRack-SL

Helper require_perm: gate de permisos por scope/level

Entidadactivecreado Wed Jun 03#core#security#rls#permissions#django#saas

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 (contiene request.user y 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 (racks en vez de network), ver [[entity—core—service—has-permission]].
  • network/api/scripts.py::get_scripts — sin gate; ahora network:view.
  • monitoring/api/insight_execution.py::apply/rollback — subidos de cns:edit a cns:admin (despachan comandos SSH reales a equipos, decisión de Edu); dry_run se queda en edit.

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]]