diff --git a/.env.example b/.env.example index 921d708c..2a99235c 100644 --- a/.env.example +++ b/.env.example @@ -63,6 +63,8 @@ VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend #------ Celery / Valkey ---------- VALKEY_URL=redis://valkey:6379/0 +PERMISSION_CACHE_ENABLED=true +PERMISSION_CACHE_TTL_SECONDS=300 # ----- MinIO (S3-compatible) ----- MINIO_ROOT_USER=minioadmin diff --git a/backend/api/v1/modules/a76/general_catalogs/company/routes.py b/backend/api/v1/modules/a76/general_catalogs/company/routes.py index 012e7306..021aa717 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/routes.py @@ -20,7 +20,6 @@ from core.s3_keys import company_certificate_key, company_logo_key from core.storage_s3 import delete_object_if_exists, get_object_bytes, put_object_bytes from core.security import ( collect_company_ids_from_app_membership, - collect_user_role_names, get_current_user, get_tenant_from_token, is_hub_admin, @@ -42,7 +41,13 @@ logger = logging.getLogger(__name__) def _user_is_admin(current_user: dict) -> bool: - return "admin" in collect_user_role_names(current_user) or is_hub_admin(current_user) + """ + Bypass cross-tenant para endpoints sin company_id en la firma. La única + fuente válida es hub_admin atestado por el Hub; el antiguo bypass por rol + "admin" del realm Keycloak se eliminó para no depender de claims del JWT. + Los super_admin locales operan a nivel de compañía (validate_access_to_resource). + """ + return is_hub_admin(current_user) def _assert_permission_any_company( @@ -213,12 +218,10 @@ async def get_my_companies( (``user_company_roles``, ``user_company_permissions``, ``user_tenants``). Hub admin: si hay X-Tenant-Override muestra solo ese tenant; si no, muestra todos. """ - from core.security import collect_user_role_names - user_roles = collect_user_role_names(current_user) - # Hub admin: respeta X-Tenant-Override para filtrar por tenant seleccionado; - # sin override devuelve todo (visibilidad global). - if "hub_admin" in user_roles: + # sin override devuelve todo (visibilidad global). Se consulta el booleano + # normalizado por el Hub (no se lee del JWT) para no acoplar a claims. + if is_hub_admin(current_user): service = CompanyService(db) override = request.headers.get("X-Tenant-Override") query = db.query(Company).filter(Company.deleted_at.is_(None)) diff --git a/backend/api/v1/modules/a76/invoice_settings/routes.py b/backend/api/v1/modules/a76/invoice_settings/routes.py index 06d2a9ba..91379008 100644 --- a/backend/api/v1/modules/a76/invoice_settings/routes.py +++ b/backend/api/v1/modules/a76/invoice_settings/routes.py @@ -2,7 +2,12 @@ from typing import List, Dict, Any from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from core.database import get_core_db -from core.security import get_current_user, validate_access_to_resource +from core.security import ( + get_current_user, + validate_access_to_resource, + is_hub_admin, + _has_local_super_admin_role, +) from api.v1.modules.a76.invoice_settings import services from api.v1.modules.a76.invoice_settings.dto import InvoiceSettingsRequest, InvoiceSettingsResponse, OperationType from api.v1.modules.core.permissions.service import PermissionService @@ -30,10 +35,11 @@ def _can_read_invoice_settings_row( Ver configuración por tipo/op: settings_general.view O ver facturas de ese mismo contexto (para cargar defaults en alta/edición sin abrir la pantalla de parámetros). """ - user_roles = current_user.get("realm_access", {}).get("roles", []) - if "admin" in user_roles: - return True + # Bypass de checks: hub_admin (atestado por el Hub) o super_admin local en + # la compañía. La autorización NO se decide por claims del JWT. user_id = current_user.get("sub") or current_user.get("id") + if is_hub_admin(current_user) or _has_local_super_admin_role(db, user_id, company_id): + return True if not user_id: return False ps = PermissionService(db) diff --git a/backend/api/v1/modules/a76/invoices/routes.py b/backend/api/v1/modules/a76/invoices/routes.py index 5ec8d56c..fd2c8d62 100644 --- a/backend/api/v1/modules/a76/invoices/routes.py +++ b/backend/api/v1/modules/a76/invoices/routes.py @@ -4,7 +4,13 @@ from typing import Dict, Any, Literal, Optional from core.config import settings from core.database import get_core_db from core.exceptions import BaseAPIException -from core.security import collect_user_role_names, get_current_user, validate_access_to_resource, get_active_system +from core.security import ( + _has_local_super_admin_role, + get_active_system, + get_current_user, + is_hub_admin, + validate_access_to_resource, +) from fastapi import APIRouter, Depends, HTTPException, Query, Path, Request from sqlalchemy import func, or_, and_ from sqlalchemy.orm import Session @@ -534,12 +540,15 @@ def list_invoices( List invoices with optional filters and granular permission enforcement. """ tenant_id = validate_access_to_resource(db, company_id, current_user) - # Roles vienen del Hub (/auth/me), no de realm_access del JWT crudo. - is_hub_admin = "admin" in collect_user_role_names(current_user) from api.v1.modules.core.permissions.service import PermissionService user_id = current_user.get("sub") or current_user.get("id") + # Bypass para "ver todo": hub_admin atestado por el Hub o super_admin local + # en la compañía. Se evita depender de claims del JWT crudo. + is_global_admin = is_hub_admin(current_user) or _has_local_super_admin_role( + db, user_id, company_id + ) perm_service = PermissionService(db) perm_codes = perm_service.get_user_permissions(user_id, company_id) @@ -558,9 +567,9 @@ def list_invoices( allowed_filters = build_allowed_types_from_view_permissions(perm_codes) if not allowed_filters: - if is_hub_admin: + if is_global_admin: logger.debug( - "[invoices.list] hub admin without invoice view perms — unfiltered types fallback" + "[invoices.list] global admin without invoice view perms — unfiltered types fallback" ) allowed_filters = None else: diff --git a/backend/api/v1/modules/core/permissions/cache.py b/backend/api/v1/modules/core/permissions/cache.py new file mode 100644 index 00000000..c709b5d3 --- /dev/null +++ b/backend/api/v1/modules/core/permissions/cache.py @@ -0,0 +1,205 @@ +import logging +import os +from typing import Optional, Set, Iterable + +from core.config import settings + +try: + import redis # type: ignore +except Exception: # pragma: no cover - redis is optional in some envs + redis = None # type: ignore + + +logger = logging.getLogger(__name__) + + +class PermissionCache: + """ + Caché de permisos basada en Valkey/Redis. + + - Clave por combinación (tenant_id, company_id, user_id) + - Guarda el set de códigos de permiso como string CSV + - TTL controlado por configuración (`PERMISSION_CACHE_TTL_SECONDS`) + + Todas las operaciones fallan en modo silencioso para no afectar el flujo + principal de la aplicación si Redis/Valkey no está disponible. + """ + + KEY_PREFIX = "permissions:v1" + + def __init__(self, client: "redis.Redis | None" = None) -> None: # type: ignore[name-defined] + self.ttl_seconds = int(getattr(settings, "PERMISSION_CACHE_TTL_SECONDS", 300) or 300) + enabled_flag = bool(getattr(settings, "PERMISSION_CACHE_ENABLED", True)) + + # Si redis no está instalado, deshabilitar caché + if redis is None: + self._client = None + self.enabled = False + return + + if client is not None: + self._client = client + self.enabled = enabled_flag + return + + url = ( + os.getenv("VALKEY_URL") + or os.getenv("REDIS_URL") + or getattr(settings, "VALKEY_URL", "redis://valkey:6379/0") + ) + + try: + # decode_responses=True para trabajar con str en lugar de bytes + self._client = redis.Redis.from_url(url, decode_responses=True) + # Probar conexión rápida (no crítico si falla) + if enabled_flag: + try: + self._client.ping() + self.enabled = True + except Exception: + logger.warning( + "permission_cache_ping_failed", + extra={"url": url}, + ) + self.enabled = False + else: + self.enabled = False + except Exception as exc: + logger.warning( + "permission_cache_init_failed", + extra={"url": url, "error": str(exc)}, + ) + self._client = None + self.enabled = False + + # ------------------------------------------------------------------ + # Helpers de clave + # ------------------------------------------------------------------ + def build_permissions_key( + self, + tenant_id: Optional[int], + company_id: int, + user_id: str, + ) -> str: + """ + Construye la clave única de caché para un usuario en una compañía. + """ + tenant_part = str(tenant_id) if tenant_id is not None else "global" + return f"{self.KEY_PREFIX}:tenant:{tenant_part}:company:{company_id}:user:{user_id}" + + # ------------------------------------------------------------------ + # Operaciones de lectura/escritura + # ------------------------------------------------------------------ + def get_permissions( + self, + cache_key: str, + **context: object, + ) -> Optional[Set[str]]: + """ + Obtiene el set de permisos desde caché. + + Devuelve: + - set[str] si hay caché válido + - None si no hay entrada o si la caché está deshabilitada + """ + if not self.enabled or not self._client: + return None + + try: + raw = self._client.get(cache_key) + if raw is None: + return None + if not raw: + return set() + return set(raw.split(",")) + except Exception as exc: + logger.warning( + "permission_cache_get_failed", + extra={"cache_key": cache_key, "error": str(exc), **context}, + ) + return None + + def set_permissions( + self, + cache_key: str, + permissions: Iterable[str], + **context: object, + ) -> None: + """ + Escribe el set de permisos en caché con TTL. + """ + if not self.enabled or not self._client: + return + + try: + value = ",".join(sorted(set(permissions))) + self._client.setex(cache_key, self.ttl_seconds, value) + except Exception as exc: + logger.warning( + "permission_cache_set_failed", + extra={"cache_key": cache_key, "error": str(exc), **context}, + ) + + # ------------------------------------------------------------------ + # Invalidaciones + # ------------------------------------------------------------------ + def _delete_pattern(self, pattern: str) -> None: + """ + Elimina todas las llaves que coincidan con un patrón. + """ + if not self.enabled or not self._client: + return + + try: + # scan_iter evita bloquear Redis en grandes keyspaces + keys = list(self._client.scan_iter(match=pattern)) + if keys: + self._client.delete(*keys) + except Exception as exc: + logger.warning( + "permission_cache_delete_pattern_failed", + extra={"pattern": pattern, "error": str(exc)}, + ) + + def invalidate_user( + self, + tenant_id: Optional[int], + company_id: int, + user_id: str, + ) -> None: + """ + Invalida el caché de permisos para un usuario específico. + """ + if not self.enabled or not self._client: + return + + cache_key = self.build_permissions_key(tenant_id, company_id, user_id) + try: + self._client.delete(cache_key) + except Exception as exc: + logger.warning( + "permission_cache_invalidate_user_failed", + extra={ + "cache_key": cache_key, + "tenant_id": tenant_id, + "company_id": company_id, + "user_id": user_id, + "error": str(exc), + }, + ) + + def invalidate_company(self, company_id: int) -> None: + """ + Invalida el caché de permisos para todos los usuarios de una compañía. + """ + pattern = f"{self.KEY_PREFIX}:tenant:*:company:{company_id}:user:*" + self._delete_pattern(pattern) + + def invalidate_all(self) -> None: + """ + Elimina TODAS las entradas del caché de permisos. + Úsese con precaución (ej. cleanup_cli). + """ + pattern = f"{self.KEY_PREFIX}:*" + self._delete_pattern(pattern) + diff --git a/backend/api/v1/modules/core/permissions/cleanup_cli.py b/backend/api/v1/modules/core/permissions/cleanup_cli.py index d8710abb..958464ba 100644 --- a/backend/api/v1/modules/core/permissions/cleanup_cli.py +++ b/backend/api/v1/modules/core/permissions/cleanup_cli.py @@ -21,6 +21,7 @@ sys.path.append(os.path.abspath(".")) sys.path.append(os.path.abspath("backend")) from core.database import CoreSessionLocal +from api.v1.modules.core.permissions.cache import PermissionCache def run_cleanup(): """Ejecuta el borrado de tablas en orden de dependencias.""" @@ -49,6 +50,8 @@ def run_cleanup(): db.execute(text("DELETE FROM core.permissions")) db.commit() + # Limpiar también el caché de permisos en Valkey + PermissionCache().invalidate_all() logger.info("=" * 40) logger.info("LIMPIEZA COMPLETADA CON ÉXITO") logger.info("El sistema de permisos está ahora en blanco.") diff --git a/backend/api/v1/modules/core/permissions/dependencies.py b/backend/api/v1/modules/core/permissions/dependencies.py index 83eea516..ecd9c196 100644 --- a/backend/api/v1/modules/core/permissions/dependencies.py +++ b/backend/api/v1/modules/core/permissions/dependencies.py @@ -44,7 +44,7 @@ class PermissionChecker: async def __call__( self, - client_id: int, + company_id: int, current_user: dict = Depends(get_current_user), permission_service: PermissionService = Depends(get_permission_service), ): @@ -61,17 +61,17 @@ class PermissionChecker: detail="User ID not found in token", ) - # Verificar permisos + # Verificar permisos sobre la compañía if self.require_all: has_access = permission_service.has_all_permissions( user_id=user_id, - client_id=client_id, + company_id=company_id, permission_codes=self.required_permissions, ) else: has_access = permission_service.has_any_permission( user_id=user_id, - client_id=client_id, + company_id=company_id, permission_codes=self.required_permissions, ) @@ -102,7 +102,7 @@ class RequirePermission: async def __call__( self, - client_id: int, + company_id: int, current_user: dict = Depends(get_current_user), permission_service: PermissionService = Depends(get_permission_service), ): @@ -115,7 +115,9 @@ class RequirePermission: ) has_permission = permission_service.has_permission( - user_id=user_id, client_id=client_id, permission_code=self.permission_code + user_id=user_id, + company_id=company_id, + permission_code=self.permission_code, ) if not has_permission: @@ -135,7 +137,7 @@ def require_permissions(*permissions: str, require_all: bool = True): Ejemplo: @require_permissions("invoice.edit", "invoice.view") - def update_invoice_logic(invoice_id: int, user_id: str, client_id: int, db: Session): + def update_invoice_logic(invoice_id: int, user_id: str, company_id: int, db: Session): # Lógica de actualización pass """ @@ -143,14 +145,14 @@ def require_permissions(*permissions: str, require_all: bool = True): def decorator(func: Callable): @wraps(func) def wrapper(*args, **kwargs): - # Extraer user_id, client_id y db de los argumentos + # Extraer user_id, company_id y db de los argumentos user_id = kwargs.get("user_id") - client_id = kwargs.get("client_id") + company_id = kwargs.get("company_id") db = kwargs.get("db") - if not all([user_id, client_id, db]): + if not all([user_id, company_id, db]): raise ValueError( - "Function must receive 'user_id', 'client_id', and 'db' as keyword arguments" + "Function must receive 'user_id', 'company_id', and 'db' as keyword arguments" ) # Verificar permisos @@ -159,13 +161,13 @@ def require_permissions(*permissions: str, require_all: bool = True): if require_all: has_access = permission_service.has_all_permissions( user_id=user_id, - client_id=client_id, + company_id=company_id, permission_codes=list(permissions), ) else: has_access = permission_service.has_any_permission( user_id=user_id, - client_id=client_id, + company_id=company_id, permission_codes=list(permissions), ) @@ -184,12 +186,12 @@ def require_permissions(*permissions: str, require_all: bool = True): # Función helper para obtener permisos del usuario actual async def get_current_user_permissions( - client_id: int, + company_id: int, current_user: dict = Depends(get_current_user), permission_service: PermissionService = Depends(get_permission_service), ) -> set: """ - Devuelve todos los permisos del usuario actual en el cliente. + Devuelve todos los permisos del usuario actual en la compañía. Útil para endpoints que necesitan conocer los permisos disponibles. """ user_id = current_user.get("sub") or current_user.get("id") @@ -200,4 +202,4 @@ async def get_current_user_permissions( detail="User ID not found in token", ) - return permission_service.get_user_permissions(user_id, client_id) + return permission_service.get_user_permissions(user_id, company_id, use_cache=True) diff --git a/backend/api/v1/modules/core/permissions/routes.py b/backend/api/v1/modules/core/permissions/routes.py index c0f68866..6246dd6a 100644 --- a/backend/api/v1/modules/core/permissions/routes.py +++ b/backend/api/v1/modules/core/permissions/routes.py @@ -8,13 +8,19 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session from core.database import get_core_db -from core.security import collect_user_role_names, get_current_user, validate_access_to_resource +from core.security import ( + collect_user_role_names, + get_current_user, + validate_access_to_resource, + is_hub_admin, +) from .dependencies import ( PermissionChecker, RequirePermission, get_permission_service, get_current_user_permissions, ) +from .cache import PermissionCache from .service import PermissionService from .models import Permission, CompanyRole from .schemas import ( @@ -83,8 +89,8 @@ async def get_my_permissions( user_id = current_user.get("sub") or current_user.get("id") # 2. Determinar si es un admin de Keycloak para forzar bootstrap si es necesario - # Roles vienen del Hub (/auth/me vía verify_token); ver collect_user_role_names. - is_keycloak_admin = "admin" in collect_user_role_names(current_user) + # Admin global: rol "admin" en Keycloak o hub_admin del Hub. + is_keycloak_admin = "admin" in collect_user_role_names(current_user) or is_hub_admin(current_user) # 3. Bootstrap: si la compañía no tiene roles, o si el usuario es admin, o si estamos en desarrollo y el usuario no tiene roles from .models import CompanyRole, UserCompanyRole @@ -98,38 +104,22 @@ async def get_my_permissions( auto_bootstrap = (not has_roles) or \ (is_keycloak_admin and not user_has_roles) or \ - (settings.ENVIRONMENT == "development" and not user_has_roles) + (settings.ENVIRONMENT == "development" and (not user_has_roles or is_keycloak_admin)) if auto_bootstrap: # Nota: bootstrap_super_admin ya hace commit e intenta no duplicar si el rol ya existe permission_service.bootstrap_super_admin(user_id, company_id) - # 4. Sincronizar permisos de sistema desde claim de Keycloak (si viene en el token) - # El claim 'allowed_systems' puede contener ["fixed_asset"], ["inventory"], o ambos. - # Solo sincroniza si el claim está presente; si no, los permisos de sistema se asignan - # manualmente desde el panel de administración. - _VALID_SYSTEMS = frozenset(("fixed_asset", "inventory")) - keycloak_systems = current_user.get("allowed_systems") or [] - if keycloak_systems: - for sys_code in keycloak_systems: - if sys_code in _VALID_SYSTEMS: - try: - permission_service.grant_direct_permission( - user_id=user_id, - company_id=company_id, - permission_code=f"system.{sys_code}.access", - assigned_by="keycloak_sync", - ) - except ValueError: - # El permiso de sistema aún no existe en BD (sync pendiente); se ignora. - pass + # 4. Obtener permisos finales (caché habilitado en PermissionService) + permissions = permission_service.get_user_permissions( + user_id, company_id, use_cache=True + ) - # 5. Obtener permisos finales - permissions = permission_service.get_user_permissions(user_id, company_id) - - # 5. Obtener roles + # 5. Obtener roles locales. Devolvemos el `code` (estable, kebab/snake) + # porque el frontend lo usa para checks (p.ej. bypass de "super_admin"). + # Los `name` legibles se exponen en otros endpoints de gestión de roles. roles = permission_service.get_user_roles(user_id, company_id) - role_names = [role.name for role in roles] + role_codes = [role.code for role in roles] # 6. Derivar sistemas permitidos desde los permisos de sistema allowed_systems = [ @@ -140,8 +130,9 @@ async def get_my_permissions( return UserPermissionsResponse( user_id=user_id, company_id=company_id, + tenant_id=tenant_id, permissions=list(permissions), - roles=role_names, + roles=role_codes, allowed_systems=allowed_systems, ) @@ -208,16 +199,19 @@ async def get_user_permissions( Requiere roles.view o permisos de gestión de usuarios en la compañía. """ tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user) - - permissions = permission_service.get_user_permissions(user_id, company_id) + + permissions = permission_service.get_user_permissions( + user_id, company_id, use_cache=True + ) roles = permission_service.get_user_roles(user_id, company_id) - role_names = [role.name for role in roles] + role_codes = [role.code for role in roles] return UserPermissionsResponse( user_id=user_id, company_id=company_id, + tenant_id=tenant_id, permissions=list(permissions), - roles=role_names, + roles=role_codes, ) @@ -379,7 +373,13 @@ async def assign_user_role( db.add(user_role) db.commit() db.refresh(user_role) - + + # Invalidar caché de permisos del usuario afectado + try: + PermissionCache().invalidate_user(tenant_id, company_id, request.user_id) + except Exception: + pass + # Recargar con la relación company_role user_role = db.query(UserCompanyRole).options( joinedload(UserCompanyRole.company_role) @@ -422,7 +422,13 @@ async def remove_user_role( db.delete(user_role) db.commit() - + + # Invalidar caché de permisos del usuario afectado + try: + PermissionCache().invalidate_user(tenant_id, company_id, user_role.user_id) + except Exception: + pass + return {"success": True, "message": "User role assignment removed"} @@ -693,6 +699,12 @@ async def delete_role( db.delete(role) db.commit() + # Cambiar la composición de permisos de rol invalida el caché de la compañía + try: + PermissionCache().invalidate_company(company_id) + except Exception: + pass + # RUTAS DE GESTIÓN DE PERMISOS POR ROL @@ -816,7 +828,13 @@ async def assign_permission_to_role( db.add(role_permission) db.commit() db.refresh(role_permission) - + + # Cualquier cambio en permisos por rol invalida el caché de la compañía + try: + PermissionCache().invalidate_company(company_id) + except Exception: + pass + return {"success": True, "message": "Permission assigned to role"} @@ -881,7 +899,13 @@ async def assign_multiple_permissions_to_role( added_count += 1 db.commit() - + + # Invalida caché para todos los usuarios de la compañía + try: + PermissionCache().invalidate_company(company_id) + except Exception: + pass + return { "success": True, "message": f"{added_count} permissions assigned to role", @@ -922,7 +946,12 @@ async def remove_permission_from_role( db.delete(role_permission) db.commit() - + + try: + PermissionCache().invalidate_company(company_id) + except Exception: + pass + return {"success": True, "message": "Permission removed from role"} @@ -1262,6 +1291,11 @@ async def assign_user_permission( existing.expires_at = request.expires_at db.commit() db.refresh(existing) + # Invalidar caché del usuario afectado + try: + PermissionCache().invalidate_user(tenant_id, company_id, user_id) + except Exception: + pass return UserPermissionResponse.model_validate(existing) # Crear nuevo @@ -1278,7 +1312,13 @@ async def assign_user_permission( db.add(user_permission) db.commit() db.refresh(user_permission) - + + # Invalidar caché del usuario afectado + try: + PermissionCache().invalidate_user(tenant_id, company_id, user_id) + except Exception: + pass + return UserPermissionResponse.model_validate(user_permission) @@ -1316,3 +1356,9 @@ async def remove_user_permission( db.delete(user_permission) db.commit() + + # Invalidar caché del usuario afectado + try: + PermissionCache().invalidate_user(tenant_id, company_id, user_id) + except Exception: + pass diff --git a/backend/api/v1/modules/core/permissions/schemas.py b/backend/api/v1/modules/core/permissions/schemas.py index 1f259fcb..054af990 100644 --- a/backend/api/v1/modules/core/permissions/schemas.py +++ b/backend/api/v1/modules/core/permissions/schemas.py @@ -54,6 +54,13 @@ class UserPermissionsResponse(BaseModel): user_id: str = Field(..., description="ID del usuario") company_id: int = Field(..., description="ID del companye") + tenant_id: Optional[int] = Field( + default=None, + description=( + "ID del tenant resuelto en backend para esta compañía. Permite " + "al frontend dejar de leer tenant_id desde claims del JWT." + ), + ) permissions: List[str] = Field( default_factory=list, description="Lista de códigos de permisos del usuario" ) diff --git a/backend/api/v1/modules/core/permissions/service.py b/backend/api/v1/modules/core/permissions/service.py index d55fa25a..646c93d0 100644 --- a/backend/api/v1/modules/core/permissions/service.py +++ b/backend/api/v1/modules/core/permissions/service.py @@ -6,8 +6,12 @@ Proporciona funciones para verificar y obtener permisos de usuarios por companye import logging from datetime import datetime from typing import Set, Optional, List + from sqlalchemy.orm import Session from sqlalchemy import and_, or_ + +from core.database import RLS_TENANT_KEY +from .cache import PermissionCache from .models import ( Permission, CompanyRole, @@ -27,6 +31,7 @@ class PermissionService: def __init__(self, db: Session): self.db = db + self._cache = PermissionCache() def _ensure_user_tenant_row_for_company( self, user_id: str, company_id: int @@ -46,40 +51,87 @@ class PermissionService: except Exception as e: logger.warning("user_tenants sync skipped: %s", e) - def get_user_permissions( - self, user_id: str, company_id: int, use_cache: bool = False + def _resolve_tenant_id_for_company(self, company_id: int) -> Optional[int]: + """ + Resuelve tenant_id efectivo para una compañía usando primero el contexto RLS + de la sesión y, como fallback, la tabla de compañías. + """ + tenant_id = self.db.info.get(RLS_TENANT_KEY) + if tenant_id is not None: + try: + return int(tenant_id) + except (TypeError, ValueError): + return None + + try: + from api.v1.modules.a76.general_catalogs.company.models import Company + + company = ( + self.db.query(Company).filter(Company.id == company_id).first() + ) + if company and company.tenant_id: + return int(company.tenant_id) + except Exception as exc: + logger.warning( + "resolve_tenant_id_for_company_failed", + extra={ + "company_id": company_id, + "error": str(exc), + }, + ) + return None + + def _get_user_permissions_uncached( + self, user_id: str, company_id: int ) -> Set[str]: """ - Obtiene todos los permisos de un usuario para un companye específico. - Combina permisos de roles asignados y permisos directos. - - Args: - user_id: ID del usuario - company_id: ID del companye/tenant - use_cache: Si True, intenta usar caché (útil en producción) - - Returns: - Set de códigos de permisos (ej: {"invoice.view", "invoice.edit"}) + Lógica de cálculo de permisos sin caché. """ - # Obtener permisos de roles asignados al usuario role_permissions = self._get_permissions_from_roles(user_id, company_id) - - # Obtener permisos directos del usuario direct_permissions = self._get_direct_permissions(user_id, company_id) - # Combinar ambos conjuntos - # Los permisos directos revocados (is_granted=False) anulan los de roles all_permissions = role_permissions.copy() for perm_code, is_granted in direct_permissions.items(): if is_granted: all_permissions.add(perm_code) else: - # Permiso explícitamente revocado all_permissions.discard(perm_code) return all_permissions + def get_user_permissions( + self, user_id: str, company_id: int, use_cache: bool = False + ) -> Set[str]: + """ + Obtiene todos los permisos de un usuario para un companye específico. + Puede usar caché de Valkey cuando use_cache=True. + """ + if not use_cache: + return self._get_user_permissions_uncached(user_id, company_id) + + tenant_id = self._resolve_tenant_id_for_company(company_id) + cache_key = self._cache.build_permissions_key(tenant_id, company_id, user_id) + + cached = self._cache.get_permissions( + cache_key, + user_id=user_id, + company_id=company_id, + tenant_id=tenant_id, + ) + if cached is not None: + return cached + + permissions = self._get_user_permissions_uncached(user_id, company_id) + self._cache.set_permissions( + cache_key, + permissions, + user_id=user_id, + company_id=company_id, + tenant_id=tenant_id, + ) + return permissions + def _get_permissions_from_roles(self, user_id: str, company_id: int) -> Set[str]: """ Obtiene permisos derivados de los roles del usuario en el companye. @@ -155,7 +207,7 @@ class PermissionService: Returns: True si el usuario tiene el permiso, False en caso contrario """ - permissions = self.get_user_permissions(user_id, company_id) + permissions = self.get_user_permissions(user_id, company_id, use_cache=True) return permission_code in permissions def has_any_permission( @@ -164,7 +216,7 @@ class PermissionService: """ Verifica si el usuario tiene al menos uno de los permisos especificados. """ - permissions = self.get_user_permissions(user_id, company_id) + permissions = self.get_user_permissions(user_id, company_id, use_cache=True) return any(perm in permissions for perm in permission_codes) def has_all_permissions( @@ -173,7 +225,7 @@ class PermissionService: """ Verifica si el usuario tiene todos los permisos especificados. """ - permissions = self.get_user_permissions(user_id, company_id) + permissions = self.get_user_permissions(user_id, company_id, use_cache=True) return all(perm in permissions for perm in permission_codes) def get_user_roles(self, user_id: str, company_id: int) -> List[CompanyRole]: @@ -241,6 +293,15 @@ class PermissionService: existing.assigned_by = assigned_by self.db.commit() self._ensure_user_tenant_row_for_company(user_id, company_id) + # Invalidar caché del usuario tras reactivar el rol + try: + tenant_id = self._resolve_tenant_id_for_company(company_id) + self._cache.invalidate_user(tenant_id, company_id, user_id) + except Exception: + logger.warning( + "permission_cache_invalidate_user_after_assign_role_failed", + extra={"user_id": user_id, "company_id": company_id}, + ) return existing self._ensure_user_tenant_row_for_company(user_id, company_id) return existing @@ -259,6 +320,16 @@ class PermissionService: self._ensure_user_tenant_row_for_company(user_id, company_id) + # Invalidar caché del usuario tras nueva asignación de rol + try: + tenant_id = self._resolve_tenant_id_for_company(company_id) + self._cache.invalidate_user(tenant_id, company_id, user_id) + except Exception: + logger.warning( + "permission_cache_invalidate_user_after_assign_role_failed", + extra={"user_id": user_id, "company_id": company_id}, + ) + return user_role def grant_direct_permission( @@ -305,6 +376,16 @@ class PermissionService: existing.expires_at = expires_at self.db.commit() self._ensure_user_tenant_row_for_company(user_id, company_id) + # Invalidar caché del usuario tras mutación + try: + tenant_id = self._resolve_tenant_id_for_company(company_id) + self._cache.invalidate_user(tenant_id, company_id, user_id) + except Exception: + # La invalidación de caché nunca debe romper la operación principal + logger.warning( + "permission_cache_invalidate_user_after_grant_failed", + extra={"user_id": user_id, "company_id": company_id}, + ) return existing # Crear nuevo permiso directo @@ -323,6 +404,16 @@ class PermissionService: self._ensure_user_tenant_row_for_company(user_id, company_id) + # Invalidar caché del usuario tras mutación + try: + tenant_id = self._resolve_tenant_id_for_company(company_id) + self._cache.invalidate_user(tenant_id, company_id, user_id) + except Exception: + logger.warning( + "permission_cache_invalidate_user_after_grant_failed", + extra={"user_id": user_id, "company_id": company_id}, + ) + return user_permission def sync_permissions(self) -> dict: @@ -441,7 +532,33 @@ class PermissionService: ) self.db.add(role_perm) else: - logger.info(f"Bootstrap: El rol super_admin ya existe para la compañía {company_id}.") + logger.info(f"Bootstrap: El rol super_admin ya existe para la compañía {company_id}. Sincronizando permisos nuevos si es necesario.") + # Asegurar que el rol tenga TODOS los permisos activos (incluidos los agregados después) + all_perms = self.db.query(Permission).filter(Permission.is_active == True).all() + existing_perm_ids = { + pid + for (pid,) in self.db.query(RolePermission.permission_id).filter( + RolePermission.company_role_id == admin_role.id + ) + } + added = 0 + for perm in all_perms: + if perm.id in existing_perm_ids: + continue + role_perm = RolePermission( + company_role_id=admin_role.id, + permission_id=perm.id, + tenant_id=tenant_id, + company_id=company_id, + ) + self.db.add(role_perm) + added += 1 + if added: + logger.info( + "Bootstrap: sincronicé %s permisos nuevos en super_admin company_id=%s", + added, + company_id, + ) # 5. Asegurar que el usuario tenga el rol asignado user_has_role = self.db.query(UserCompanyRole).filter( @@ -462,10 +579,27 @@ class PermissionService: ) self.db.add(user_role) self.db.commit() + # Invalida caché de permisos de la compañía y del usuario afectado + try: + self._cache.invalidate_company(company_id) + self._cache.invalidate_user(tenant_id, company_id, user_id) + except Exception: + logger.warning( + "permission_cache_invalidate_after_bootstrap_failed", + extra={"user_id": user_id, "company_id": company_id}, + ) return True else: logger.info(f"Bootstrap: El usuario {user_id} ya tiene el rol asignado.") self.db.commit() + # Invalida caché de permisos de la compañía para reflejar cambios de catálogo + try: + self._cache.invalidate_company(company_id) + except Exception: + logger.warning( + "permission_cache_invalidate_after_bootstrap_failed", + extra={"user_id": user_id, "company_id": company_id}, + ) return False except Exception as e: diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index a1e46db5..338b5cac 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -8,7 +8,7 @@ from core.config import settings logger = logging.getLogger(__name__) -valkey_url = os.getenv("VALKEY_URL", "redis://valkey:6379/0") +valkey_url = settings.VALKEY_URL print(f"DEBUG: Celery Broker URL: {valkey_url}") logger.info( "Initializing Celery app app_version=%s environment=%s broker=%s", diff --git a/backend/core/config.py b/backend/core/config.py index fddf81c3..0bcb52a9 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -24,13 +24,16 @@ class Settings(BaseSettings): CORE_DB_USER: str = "postgres" CORE_DB_PASSWORD: str = "postgres" - - # Security SECRET_KEY: str = "change-this-secret-key-in-production" ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + # Valkey / Redis + VALKEY_URL: str = "redis://valkey:6379/0" + PERMISSION_CACHE_ENABLED: bool = True + PERMISSION_CACHE_TTL_SECONDS: int = 300 + # Synchronization SYNC_SECRET_TOKEN: str = "change-this-sync-token-in-production" CENTRAL_SERVER_URL: str = "http://localhost:8000/api/v1/core/help-center/sync/" diff --git a/backend/core/security.py b/backend/core/security.py index 85fca6c0..215c5ac7 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -476,13 +476,59 @@ def resolve_effective_tenant_id_from_user(current_user: Dict[str, Any]) -> Optio def is_hub_admin(current_user: Dict[str, Any]) -> bool: - """True si el usuario tiene el rol hub_admin (super-admin del Hub con acceso global).""" - roles = current_user.get("roles") - if isinstance(roles, list) and "hub_admin" in roles: - return True + """True si el Hub atestigua que el usuario es hub_admin (super-admin global).""" return bool(current_user.get("is_hub_admin")) +def _has_local_super_admin_role( + db: "Session", user_id: Optional[str], company_id: Optional[int] +) -> bool: + """ + True si el usuario tiene el rol local ``super_admin`` activo en la compañía. + + Sustituye al antiguo bypass por rol ``admin`` del realm Keycloak para + autorización: la fuente de verdad es la BD local (``core.user_company_roles`` + + ``core.company_roles``), no claims del JWT. La promoción automática de + admins de Keycloak a ``super_admin`` local sigue ocurriendo en el endpoint + ``/permissions/me`` (bootstrap), por lo que un admin del realm que entre + al sistema sigue obteniendo el bypass sin coordinación manual. + """ + if not user_id or not company_id: + return False + try: + from api.v1.modules.core.permissions.models import ( + CompanyRole, + UserCompanyRole, + ) + + return ( + db.query(UserCompanyRole) + .join(CompanyRole, CompanyRole.id == UserCompanyRole.company_role_id) + .filter( + UserCompanyRole.user_id == user_id, + UserCompanyRole.company_id == company_id, + UserCompanyRole.is_active == True, + CompanyRole.code == "super_admin", + CompanyRole.is_active == True, + ) + .first() + is not None + ) + except Exception as exc: + # Un fallo de BD aquí no debe escalar a acceso silenciosamente: + # se loguea y se trata como "no es super_admin" (deniega bypass). + logger.warning( + "has_local_super_admin_role_failed", + extra={ + "op": "has_local_super_admin_role", + "user_id": user_id, + "company_id": company_id, + "error": str(exc), + }, + ) + return False + + def resolve_tenant_id_required( current_user: Dict[str, Any], db: Optional["Session"] = None, @@ -720,15 +766,20 @@ def validate_access_to_resource( tenant_id = resolve_effective_tenant_id_from_user(current_user) - # Admin global Keycloak / master, o hub_admin del Hub. - all_user_roles = collect_user_role_names(current_user) - is_keycloak_admin = "admin" in all_user_roles or is_hub_admin(current_user) + # Bypass de checks de permisos: hub_admin (atestado por el Hub en /auth/me) + # o rol local "super_admin" en la compañía (fuente de verdad: BD de a76). + # Se reemplazó el antiguo "admin" in realm_access.roles para que la + # autorización deje de depender de claims del JWT. + user_id = current_user.get("sub") or current_user.get("id") + is_global_admin = is_hub_admin(current_user) or _has_local_super_admin_role( + db, user_id, company_id + ) # 🚪 EXCEPCIÓN ESPECIAL: Si es el endpoint /me, permitimos el paso para el Bootstrap # Detectamos si no se requieren permisos (típico de /me) is_me_endpoint = required_permissions is None - - if not is_keycloak_admin and not is_me_endpoint: + + if not is_global_admin and not is_me_endpoint: if not validate_company_access(db, company_id, current_user): raise HTTPException(status_code=403, detail="Access denied to this company") @@ -747,14 +798,15 @@ def validate_access_to_resource( raise HTTPException(status_code=500, detail="Error al resolver el tenant_id") # Si aún no hay tenant_id y no es admin, error 400 - if not tenant_id and not is_keycloak_admin and not is_me_endpoint: + if not tenant_id and not is_global_admin and not is_me_endpoint: raise HTTPException(status_code=400, detail="Tenant ID not found in token") # Verificar permisos locales if required_permissions: - if is_keycloak_admin: - # hub_admin siempre debe tener tenant_id resuelto cuando se exigen permisos; - # retornar 1 silenciosamente sería acceso al tenant equivocado + if is_global_admin: + # hub_admin / super_admin local: siempre debe tener tenant_id resuelto + # cuando se exigen permisos; retornar 1 silenciosamente sería acceso + # al tenant equivocado. if tenant_id is None: raise HTTPException( status_code=400, @@ -801,7 +853,7 @@ def validate_access_to_resource( # significaría acceso al tenant equivocado. Si llegamos aquí sin tenant_id # válido para un usuario no-admin, es un estado inconsistente que debe fallar. if not isinstance(tenant_id, int) or tenant_id <= 0: - if not is_keycloak_admin: + if not is_global_admin: raise HTTPException( status_code=400, detail="No se pudo determinar el tenant_id para la empresa especificada", diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 4c6b28fc..0a6db22a 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -164,9 +164,45 @@ const createAuthStore = () => { setLoading: (loading: boolean) => update((s) => ({ ...s, isLoading: loading })), setUser: (user: User | null) => { + if (user === null) { + persistUserInSession(null); + update((s) => ({ ...s, user: null })); + return; + } + update((s) => { + const prev = s.user; + const permissions = preserveNonEmptyArray(user.permissions, prev?.permissions); + const roles = preserveNonEmptyArray(user.roles, prev?.roles); + const allowedSystems = preserveNonEmptyArray(user.allowedSystems, prev?.allowedSystems); + + if ( + prev && + Array.isArray(user.permissions) && + user.permissions.length === 0 && + (prev.permissions?.length ?? 0) > 0 + ) { + console.debug( + '[auth] setUser: previene downgrade de permissions', + prev.permissions.length, + '→ 0' + ); + } + + const merged: User = { ...user, permissions, roles, allowedSystems }; + persistUserInSession(merged); + return { ...s, user: merged }; + }); + }, + /** Asignación directa sin guard. Usar solo cuando el backend confirma el estado (p. ej. syncCompanyPermissions). */ + setUserUnsafe: (user: User | null) => { persistUserInSession(user); update((s) => ({ ...s, user })); }, + /** Limpia solo el usuario del store (logout parcial). Para logout completo usar reset(). */ + clearUser: () => { + persistUserInSession(null); + update((s) => ({ ...s, user: null })); + }, setToken: (token: string | null) => update((s) => ({ ...s, token })), /** ⚠️ Los tokens ya NO se guardan en localStorage; solo en memoria. */ setTokens: (accessToken: string, _refreshToken?: string) => { @@ -201,18 +237,144 @@ export const currentUser = derived(authStore, ($a) => $a.user); */ export const permissionsHydrated = writable(false); +/** true mientras `refreshPermissions` revalida permisos en segundo plano (sidebar). */ +export const permissionsRefreshing = writable(false); + export function markPermissionsHydrated(): void { permissionsHydrated.set(true); } +/** No sobrescribir permisos RBAC con [] de /v1/auth/me (Hub no es fuente de verdad). + * Defensa en profundidad: el guard de authStore.setUser ya cubre esto. */ +function mergePermissionsFromHub(incoming: unknown, previous: string[] | undefined): string[] { + if (Array.isArray(incoming) && incoming.length > 0) return incoming; + if (previous && previous.length > 0) return previous; + return Array.isArray(incoming) ? (incoming as string[]) : []; +} + +/** Preserva roles previos (p. ej. admin del Hub) al fusionar con /v1/auth/me. + * Defensa en profundidad: el guard de authStore.setUser ya cubre esto. */ +function mergeRolesFromHub(incoming: unknown, previous: string[] | undefined): string[] { + const prev = previous ?? []; + if (Array.isArray(incoming) && incoming.length > 0) { + return Array.from(new Set([...prev, ...incoming])); + } + return prev; +} + +/** + * Merge defensivo: preserva el array previo si el incoming es undefined, + * null o [] (cuando previo no era vacío). Un incoming con items siempre + * se respeta — la revocación parcial sí es válida. + * + * Para una revocación TOTAL legítima usa `authStore.setUserUnsafe()`. + */ +export function preserveNonEmptyArray( + incoming: T[] | null | undefined, + previous: T[] | undefined +): T[] { + const prev = previous ?? []; + if (incoming == null) return prev; + if (Array.isArray(incoming) && incoming.length === 0 && prev.length > 0) { + return prev; + } + return Array.isArray(incoming) ? incoming : prev; +} + +// ───────────────────────────────────────────────────────── +// Cache local de permisos del sidebar (localStorage, TTL 5 min) +// ───────────────────────────────────────────────────────── + +const SIDEBAR_PERMS_CACHE_PREFIX = 'a76:perms:v1:'; +const SIDEBAR_PERMS_CACHE_TTL_MS = 5 * 60 * 1000; + +interface SidebarPermsCacheEntry { + permissions: string[]; + roles: string[]; + allowedSystems: string[]; + tenantId?: number; + cachedAt: number; +} + +function buildSidebarPermsCacheKey(userId: string, companyId: number): string { + return `${SIDEBAR_PERMS_CACHE_PREFIX}u${userId}:c${companyId}`; +} + +function saveSidebarPermsCache( + userId: string, + companyId: number, + data: Omit +): void { + if (!browser) return; + try { + const entry: SidebarPermsCacheEntry = { ...data, cachedAt: Date.now() }; + localStorage.setItem(buildSidebarPermsCacheKey(userId, companyId), JSON.stringify(entry)); + } catch { + // localStorage puede estar restringido — no romper la app + } +} + +export function loadSidebarPermsCache( + userId: string, + companyId: number +): SidebarPermsCacheEntry | null { + if (!browser) return null; + try { + const raw = localStorage.getItem(buildSidebarPermsCacheKey(userId, companyId)); + if (!raw) return null; + const entry = JSON.parse(raw) as SidebarPermsCacheEntry; + if (!entry || typeof entry !== 'object') return null; + if (!Array.isArray(entry.permissions) || !Array.isArray(entry.roles)) return null; + if (Date.now() - (entry.cachedAt ?? 0) > SIDEBAR_PERMS_CACHE_TTL_MS) return null; + return entry; + } catch { + return null; + } +} + +function clearSidebarPermsCache(userId?: string): void { + if (!browser) return; + try { + const keys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key || !key.startsWith(SIDEBAR_PERMS_CACHE_PREFIX)) continue; + if (userId === undefined || key.includes(`u${userId}:`)) { + keys.push(key); + } + } + for (const key of keys) { + localStorage.removeItem(key); + } + } catch { + // silencioso + } +} + +/** + * Códigos de rol que dan bypass total a checks de permiso: + * - `super_admin`: rol local de la compañía (fuente de verdad post-desacoplamiento). + * - `admin`: rol del realm Keycloak (preservado por compat con `/v1/auth/me` del Hub). + * - `hub_admin`: super-admin atestado por el Hub. + */ +const ADMIN_ROLE_CODES: ReadonlySet = new Set(['super_admin', 'admin', 'hub_admin']); + +export function userIsAdmin(user: User | null): boolean { + if (!user) return false; + return user.roles.some((role) => ADMIN_ROLE_CODES.has(role)); +} + /** * Verifica si el usuario tiene un permiso específico. - * `user.permissions` debe incluir códigos de la app (p. ej. `user.view`); suelen - * cargarse vía {@link syncCompanyPermissions}, no solo desde /auth/me del Hub. + * `user.permissions` debe incluir códigos de la app (p. ej. `user.view`); se + * cargan vía {@link syncCompanyPermissions} desde `/v1/core/permissions/me`. + * + * Bypass para super-admins: los códigos en {@link ADMIN_ROLE_CODES} dan acceso + * total sin requerir un permiso granular específico. */ export function userHasPermission(user: User | null, permission: string): boolean { if (!user) return false; - return user.roles.includes('admin') || user.permissions.includes(permission); + return userIsAdmin(user) || user.permissions.includes(permission); } // ───────────────────────────────────────────────────────── @@ -296,10 +458,11 @@ const updateAuthState = async () => { const token = keycloakInstance.token ?? null; const parsed = keycloakInstance.tokenParsed as any; - const roles: string[] = parsed?.realm_access?.roles ?? []; - const tenantId: number | undefined = parsed?.tenant_id - ? parseInt(parsed.tenant_id) - : undefined; + // Roles y tenant_id se obtienen desde el backend (/v1/auth/me y /permissions/me), + // no desde claims del JWT de Keycloak. + const previousUser = get(authStore).user; + const tenantId: number | undefined = previousUser?.tenantId; + const roles: string[] = previousUser?.roles ?? []; const tenantChanged = previousTenantId !== undefined && previousTenantId !== tenantId; @@ -311,7 +474,6 @@ const updateAuthState = async () => { currentPerms = currentState.user?.permissions || []; } catch { } - const previousUser = get(authStore).user; const firstName = pickText(profile.firstName, parsed?.given_name, previousUser?.firstName); const lastName = pickText(profile.lastName, parsed?.family_name, previousUser?.lastName); const fullNameFromParts = pickText( @@ -346,7 +508,8 @@ const updateAuthState = async () => { legacyAvatarUrl: pickAvatar(previousUser?.legacyAvatarUrl), tenantId, roles, - permissions: parsed?.permissions?.length ? parsed.permissions : currentPerms, + // Los permisos efectivos vienen del backend (/permissions/me); el JWT no decide autorización. + permissions: currentPerms, allowedSystems: previousUser?.allowedSystems ?? [], profileSyncedAt: previousUser?.profileSyncedAt }; @@ -481,11 +644,38 @@ export const login = async (credentials: { */ export async function syncCompanyPermissions(companyId: number): Promise { if (!browser || !Number.isFinite(companyId)) return; + + // Pre-popular desde localStorage para que el sidebar renderice sin parpadeo + const preUserId = get(authStore).user?.id; + if (preUserId) { + const cached = loadSidebarPermsCache(preUserId, companyId); + if (cached) { + const s = get(authStore); + if (s.user) { + const mergedRoles = Array.from(new Set([...(s.user.roles ?? []), ...cached.roles])); + authStore.setUser({ + ...s.user, + permissions: cached.permissions, + roles: mergedRoles, + allowedSystems: + cached.allowedSystems.length > 0 + ? (cached.allowedSystems as import('./stores/system.svelte').SystemType[]) + : s.user.allowedSystems, + tenantId: cached.tenantId ?? s.user.tenantId + }); + permissionsHydrated.set(true); + } + } + } + try { const { api } = await import('./api'); - const res = await api.get<{ permissions: string[]; allowed_systems?: string[] }>( - `/v1/core/permissions/me?company_id=${companyId}` - ); + const res = await api.get<{ + permissions: string[]; + roles?: string[]; + allowed_systems?: string[]; + tenant_id?: number | null; + }>(`/v1/core/permissions/me?company_id=${companyId}`); if (res.error || res.data === undefined) return; const perms = res.data.permissions; if (!Array.isArray(perms)) return; @@ -493,11 +683,32 @@ export async function syncCompanyPermissions(companyId: number): Promise { if (!state.user) return; const { systemStore } = await import('./stores/system.svelte'); const allowedSystems = (res.data.allowed_systems ?? []) as import('./stores/system.svelte').SystemType[]; + const rolesFromBackend = Array.isArray(res.data.roles) ? res.data.roles : null; + const tenantFromBackend = + typeof res.data.tenant_id === 'number' && Number.isFinite(res.data.tenant_id) + ? res.data.tenant_id + : null; - authStore.setUser({ + // Merge no destructivo de roles: preservamos los roles atestados por el + // Hub (p. ej. `admin` del realm Keycloak en /v1/auth/me) y agregamos los + // roles locales devueltos por el backend (`super_admin`, etc.). Si los + // roles locales sobrescribieran a los del Hub, el bypass de admin se + // rompería entre la primera hidratación y la sincronización por compañía. + const previousRoles = state.user.roles ?? []; + const mergedRoles = + rolesFromBackend === null + ? previousRoles + : Array.from(new Set([...previousRoles, ...rolesFromBackend])); + + // El backend es fuente de verdad para permisos de la compañía activa. + // Si retornó [] es porque el usuario realmente no tiene permisos aquí; + // el guard de setUser preservaría los viejos (incorrectos). Por eso unsafe. + authStore.setUserUnsafe({ ...state.user, permissions: perms, - // Preservar allowedSystems del SSR si el API no los retorna (JWT sin claim, seed pendiente) + roles: mergedRoles, + tenantId: tenantFromBackend ?? state.user.tenantId, + // Preservar allowedSystems del SSR si el API no los retorna (seed pendiente) allowedSystems: allowedSystems.length > 0 ? allowedSystems : (state.user.allowedSystems ?? []) }); @@ -509,6 +720,19 @@ export async function syncCompanyPermissions(companyId: number): Promise { : null; systemStore.initialize(allowedSystems, cookieSystem); } + + // Persistir en localStorage para que el sidebar pre-popule sin parpadeo en la próxima sesión + const freshUserId = get(authStore).user?.id; + if (freshUserId) { + saveSidebarPermsCache(freshUserId, companyId, { + permissions: perms, + roles: mergedRoles, + allowedSystems: allowedSystems.length > 0 + ? (allowedSystems as string[]) + : ((get(authStore).user?.allowedSystems ?? []) as string[]), + tenantId: tenantFromBackend ?? get(authStore).user?.tenantId + }); + } } catch (e) { console.warn('[auth] syncCompanyPermissions:', e); } finally { @@ -518,10 +742,13 @@ export async function syncCompanyPermissions(companyId: number): Promise { } } -export const refreshPermissions = async () => { +export const refreshPermissions = async (): Promise => { const token = getToken(); - if (token) { - await loadUserInfo(token); + if (!token) return false; + + permissionsRefreshing.set(true); + try { + // RBAC de compañía primero: evita que /auth/me vacíe permisos antes del sync real. try { const { companyStore } = await import('./stores/company.svelte'); const cid = companyStore.activeCompany?.id; @@ -529,9 +756,11 @@ export const refreshPermissions = async () => { } catch { // ignore } + await loadUserInfo(token); return true; + } finally { + permissionsRefreshing.set(false); } - return false; }; const loadUserInfo = async (token: string) => { @@ -605,8 +834,8 @@ const loadUserInfo = async (token: string) => { workspaceAvatarUrl, legacyAvatarUrl, tenantId: d.tenant_id ?? previousUser?.tenantId, - roles: d.roles ?? previousUser?.roles ?? [], - permissions: d.permissions ?? previousUser?.permissions ?? [], + roles: mergeRolesFromHub(d.roles, previousUser?.roles), + permissions: mergePermissionsFromHub(d.permissions, previousUser?.permissions), allowedSystems: previousUser?.allowedSystems ?? [], profileSyncedAt: Date.now() }); @@ -715,6 +944,19 @@ export const logout = async () => { companyStore.clear(); } catch { } + // Limpiar cache de permisos del sidebar en localStorage + try { + const userId = get(authStore).user?.id; + clearSidebarPermsCache(userId); + } catch { } + + // Limpiar snapshot visual del sidebar almacenado en sessionStorage + try { + sessionStorage.removeItem('a76:sidebar:nav-main:v1'); + } catch { + // ignore + } + // Limpiar estado en memoria authStore.reset(); persistUserInSession(null); diff --git a/frontend/src/lib/components/dashboard/invoices/data-table-actions.svelte b/frontend/src/lib/components/dashboard/invoices/data-table-actions.svelte index 6144ee75..40abe2bd 100644 --- a/frontend/src/lib/components/dashboard/invoices/data-table-actions.svelte +++ b/frontend/src/lib/components/dashboard/invoices/data-table-actions.svelte @@ -4,6 +4,7 @@ import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; // 1. Agregamos FileDown a los imports import { Ellipsis, Eye, Pencil, Trash2 } from 'lucide-svelte'; + import { goto } from '$app/navigation'; import DetailsDialog from './details-dialog.svelte'; import DeleteDialog from './delete-dialog.svelte'; @@ -19,7 +20,7 @@ let showDelete = $state(false); function handleEdit() { - window.location.href = `/dashboard/invoices/edit/${invoice.id}`; + void goto(`/dashboard/invoices/edit/${invoice.id}`); } diff --git a/frontend/src/lib/components/dashboard/invoices/data-table.svelte b/frontend/src/lib/components/dashboard/invoices/data-table.svelte index c47fa68c..fb4126b9 100644 --- a/frontend/src/lib/components/dashboard/invoices/data-table.svelte +++ b/frontend/src/lib/components/dashboard/invoices/data-table.svelte @@ -1,5 +1,6 @@ diff --git a/frontend/src/lib/components/dashboard/pedimentos/data-table.svelte b/frontend/src/lib/components/dashboard/pedimentos/data-table.svelte index 1ce39545..259c711e 100644 --- a/frontend/src/lib/components/dashboard/pedimentos/data-table.svelte +++ b/frontend/src/lib/components/dashboard/pedimentos/data-table.svelte @@ -1,5 +1,6 @@ - Anexo-76 - - {#each filteredItems as item (item.title)} + + Anexo-76 + {#if $permissionsRefreshing} + + {/if} + + + {#each visibleItems as item (item.title)} {#if item.items && item.items.length > 0} {#if sidebar.state === 'collapsed'} diff --git a/frontend/src/lib/stores/company.svelte.ts b/frontend/src/lib/stores/company.svelte.ts index da0cedf3..b25d8713 100644 --- a/frontend/src/lib/stores/company.svelte.ts +++ b/frontend/src/lib/stores/company.svelte.ts @@ -49,13 +49,16 @@ class CompanyStore { this._currentTenantId = newTenantId; this._companies = preloadedCompanies; - // Si hay compañías y no hay una activa, seleccionar la correcta + // Si hay compañías y no hay una activa, seleccionar la correcta. + // IMPORTANTE: `setActiveCompany` es async (dispara syncCompanyPermissions); + // hay que `await` para que el caller (initialize → markPermissionsHydrated) + // no marque la hidratación antes de que los permisos del backend lleguen. if (this._companies.length > 0 && !this._activeCompany) { // Prioridad 1: ID pasado por parámetro (desde SSR/Cookie) if (activeCompanyId) { const company = this._companies.find(c => c.id === activeCompanyId); if (company) { - this.setActiveCompany(company, true); + await this.setActiveCompany(company, true); return; } } @@ -66,13 +69,13 @@ class CompanyStore { if (savedId) { const company = this._companies.find(c => c.id === parseInt(savedId)); if (company) { - this.setActiveCompany(company, true); // silent=true para inicialización + await this.setActiveCompany(company, true); // silent=true para inicialización return; } } } // Prioridad 3: Si no hay nada, seleccionar la primera - this.setActiveCompany(this._companies[0], true); // silent=true para inicialización + await this.setActiveCompany(this._companies[0], true); // silent=true para inicialización } return; } @@ -106,9 +109,11 @@ class CompanyStore { this._companies = newCompanies; - // Si hay compañías y no hay una activa, seleccionar la primera + // Si hay compañías y no hay una activa, seleccionar la primera. + // `await` necesario para esperar a syncCompanyPermissions antes de + // que la inicialización del layout marque permissionsHydrated. if (this._companies.length > 0 && !this._activeCompany) { - this.setActiveCompany(this._companies[0], true); // silent=true para inicialización + await this.setActiveCompany(this._companies[0], true); // silent=true para inicialización } } else { console.error('Error loading companies:', response.error); diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index aa667b23..4ed1d2ba 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -19,8 +19,8 @@ SESSION_EXPIRED_EVENT } from '$lib/session-manager'; import type { SessionExpiredDetail } from '$lib/session-manager'; - import { authStore, markPermissionsHydrated } from '$lib/auth'; - import { logout, getKeycloakInstance } from '$lib/auth'; + import { authStore, loadSidebarPermsCache, markPermissionsHydrated, logout, getKeycloakInstance } from '$lib/auth'; + import { get } from 'svelte/store'; import LicenseErrorScreen from '$lib/components/license-error-screen.svelte'; import { systemStore, type SystemType } from '$lib/stores/system.svelte'; import { workspaceAppsStore } from '$lib/stores/workspace-apps.svelte'; @@ -105,17 +105,44 @@ ); workspaceAppsStore.initialize(data.workspaceApps ?? [], data.appRouting ?? null); - // Re-sincronizar si data.user cambia (por ejemplo, tras invalidateAll o cambio de compañía) - $effect(() => { - // Dependencia explícita para que el effect reaccione al swap de data.user - data.user; - syncAuthStoreFromData(); - systemStore.initialize( - (data.allowedSystems ?? []) as SystemType[], - (data.activeSystem ?? null) as string | null - ); - workspaceAppsStore.initialize(data.workspaceApps ?? [], data.appRouting ?? null); - }); + // Pre-popular permisos desde localStorage en el frame síncrono, antes del + // primer render, para que el sidebar muestre los ítems completos sin parpadeo. + // Esto cubre la segunda visita y tabs nuevas; la primera visita no tiene cache. + if (browser) { + const _preUserId = data.user?.sub ?? (data.user as any)?.id ?? null; + const _preCidRaw = (() => { try { return localStorage.getItem('activeCompanyId'); } catch { return null; } })(); + const _preCid = _preCidRaw !== null ? parseInt(_preCidRaw) : NaN; + if (_preUserId && Number.isFinite(_preCid)) { + const _cached = loadSidebarPermsCache(_preUserId, _preCid); + if (_cached) { + const _u = get(authStore).user; + if (_u) { + authStore.setUser({ + ..._u, + permissions: _cached.permissions, + roles: Array.from(new Set([...(_u.roles ?? []), ..._cached.roles])), + allowedSystems: _cached.allowedSystems.length > 0 + ? (_cached.allowedSystems as SystemType[]) + : _u.allowedSystems, + tenantId: _cached.tenantId ?? _u.tenantId + }); + markPermissionsHydrated(); + } + } + } + } + + // NOTA: el $effect que re-corría syncAuthStoreFromData() en cada cambio de + // data.user fue eliminado porque destruía los permisos del store en cada + // navegación. El SSR no es fuente de verdad para `permissions`/`roles` + // (los carga el cliente vía syncCompanyPermissions en companyStore), así + // que reaplicar `data.user.permissions ?? []` los reseteaba a `[]`. + // + // Ahora: la sincronización síncrona de arriba cubre la hidratación inicial + // (SSR + refresh). El refresco ante un cambio de compañía se hace de forma + // explícita en `handleCompanyChange` más abajo, donde sí cambian sistemas + // y apps. La identidad del usuario no cambia entre rutas ni entre + // compañías, así que no requiere refresco reactivo. // ── Manejar expiración de sesión ──────────────────────────────────────── function handleSessionExpired(e: Event) { @@ -165,6 +192,17 @@ // ── Escuchar cambios de compañía y recargar datos ───────────────────── const handleCompanyChange = async () => { await invalidateAll(); + // Re-poblar sistemas y apps con la nueva data tras invalidateAll. + // Antes lo hacía el $effect que reaccionaba a data.user, ahora se + // hace de forma explícita solo en el cambio de compañía (única vez + // que esos datos realmente cambian). No tocamos authStore aquí: + // identidad no cambia entre compañías, y los permisos los maneja + // syncCompanyPermissions que se dispara dentro de setActiveCompany. + systemStore.initialize( + (data.allowedSystems ?? []) as SystemType[], + (data.activeSystem ?? null) as string | null + ); + workspaceAppsStore.initialize(data.workspaceApps ?? [], data.appRouting ?? null); await goto(`${page.url.pathname}${page.url.search}${page.url.hash}`, { invalidateAll: true }); }; window.addEventListener('companyChanged', handleCompanyChange); diff --git a/frontend/src/routes/dashboard/help-center/+page.svelte b/frontend/src/routes/dashboard/help-center/+page.svelte index 9141a24f..cb72049b 100644 --- a/frontend/src/routes/dashboard/help-center/+page.svelte +++ b/frontend/src/routes/dashboard/help-center/+page.svelte @@ -28,12 +28,12 @@ const HUB_MODE = import.meta.env.VITE_HUB_MODE === 'true'; // 🛡️ Seguridad y Permisos - import { currentUser, userHasPermission } from '$lib/auth'; + import { currentUser, userHasPermission, userIsAdmin } from '$lib/auth'; import ErrorState from '$lib/components/dashboard/common/error-state.svelte'; const canView = $derived(userHasPermission($currentUser, 'help_center.view')); - // Solo los ADMIN de Keycloak pueden crear/editar/borrar en el Hub - const isAdmin = $derived($currentUser?.roles?.includes('admin') || false); + // Acceso de administración (Hub admin, admin de realm o super_admin local) + const isAdmin = $derived(userIsAdmin($currentUser)); const canCreate = $derived(HUB_MODE && isAdmin); const canEdit = $derived(HUB_MODE && isAdmin); const canDelete = $derived(HUB_MODE && isAdmin); diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index fa5ca177..ebb59f68 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -24,7 +24,7 @@ import type { PageData } from './$types'; import { browser } from '$app/environment'; import { getAccessTokenFromDocument } from '$lib/access-token-cookie-browser'; - import { currentUser } from '$lib/auth'; + import { currentUser, permissionsHydrated } from '$lib/auth'; import ErrorState from '$lib/components/dashboard/common/error-state.svelte'; import { userCanViewInvoiceListPage, @@ -62,7 +62,8 @@ Truck, Route, FileSpreadsheet, - Building2 + Building2, + LoaderCircle } from 'lucide-svelte'; import DetailsDialog from '$lib/components/dashboard/invoices/details-dialog.svelte'; import DeleteDialog from '$lib/components/dashboard/invoices/delete-dialog.svelte'; @@ -1116,7 +1117,7 @@ ? `/dashboard/invoices/edit/new?${queryString}` : '/dashboard/invoices/edit/new'; - window.location.href = url; + void goto(url); } function handleEditSelected() { @@ -1126,7 +1127,7 @@ const url = queryString ? `/dashboard/invoices/edit/${selectedInvoice.id}?${queryString}` : `/dashboard/invoices/edit/${selectedInvoice.id}`; - window.location.href = url; + void goto(url); } else { toast.info(m.invoice_list_toasts_select_invoice_to_edit()); } @@ -1601,7 +1602,14 @@ } -{#if !canViewInvoiceSection} +{#if !$permissionsHydrated} +
+ +

Verificando permisos…

+
+{:else if !canViewInvoiceSection}
diff --git a/frontend/src/routes/dashboard/pedimentos/+page.svelte b/frontend/src/routes/dashboard/pedimentos/+page.svelte index ef34e2f0..cd6d9b19 100644 --- a/frontend/src/routes/dashboard/pedimentos/+page.svelte +++ b/frontend/src/routes/dashboard/pedimentos/+page.svelte @@ -136,7 +136,7 @@ let filters = $state({ return; } if (selectedId) { - window.location.href = `/dashboard/pedimentos/edit/${selectedId}`; + void goto(`/dashboard/pedimentos/edit/${selectedId}`); } } @@ -463,7 +463,7 @@ let filters = $state({ toast.error('No tiene permiso para crear pedimentos'); return; } - window.location.href = '/dashboard/pedimentos/edit/new'; + void goto('/dashboard/pedimentos/edit/new'); } function handleSuccess() {