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:
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
205
backend/api/v1/modules/core/permissions/cache.py
Normal file
205
backend/api/v1/modules/core/permissions/cache.py
Normal 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)
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user