Merge pull request 'feature/optimizacion-de-permisos' (#460) from feature/optimizacion-de-permisos into development

Reviewed-on: ADUANASOFT/anexo76#460
This commit is contained in:
2026-05-29 15:53:44 +00:00
25 changed files with 1003 additions and 170 deletions

View File

@@ -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))

View File

@@ -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)

View File

@@ -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:

View File

@@ -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)

View File

@@ -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.")

View File

@@ -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)

View File

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

View File

@@ -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"
)

View File

@@ -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: