738 lines
27 KiB
Python
738 lines
27 KiB
Python
"""
|
|
Utilidades de seguridad y autenticación con Keycloak
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any, Dict, Optional, Set
|
|
|
|
from fastapi import Depends, HTTPException, Request, Security
|
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
from jose import JWTError, jwt
|
|
import httpx
|
|
from cachetools import TTLCache
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from .config import settings
|
|
from .database import get_core_db
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Cache para tokens verificados (1 minuto de TTL, máximo 1000 tokens)
|
|
token_cache = TTLCache(maxsize=1000, ttl=60)
|
|
|
|
# IDs de tenants ya sincronizados en este proceso (evita consultas repetidas)
|
|
_synced_tenant_ids: Set[int] = set()
|
|
|
|
# Alias Hub tenant_id -> tenant_id local cuando existe drift histórico de IDs
|
|
# (mismo slug, diferente id).
|
|
_tenant_id_aliases: Dict[int, int] = {}
|
|
# Inverso: id local core.tenants -> id tenant en Hub (JWT / client_tenants) para llamadas al Hub.
|
|
_tenant_id_hub_by_local: Dict[int, int] = {}
|
|
|
|
# Security scheme
|
|
security = HTTPBearer()
|
|
|
|
def get_active_system(request: Request) -> Optional[str]:
|
|
"""Sistema activo: header ``X-Active-System`` o cookie ``active_system``."""
|
|
return request.headers.get("x-active-system") or request.cookies.get("active_system") or None
|
|
|
|
|
|
async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str, Any]:
|
|
"""
|
|
Verifica un token JWT llamando al Hub central.
|
|
Si DEV_LOCAL_AUTH=True y el token es HS256 local, lo verifica sin Hub.
|
|
"""
|
|
cache_key = (token, tenant_id_override)
|
|
if cache_key in token_cache:
|
|
return token_cache[cache_key]
|
|
|
|
# Shortcut para tokens de desarrollo local
|
|
if settings.DEV_LOCAL_AUTH:
|
|
try:
|
|
header = jwt.get_unverified_header(token)
|
|
if header.get("alg") == "HS256":
|
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
|
if payload.get("dev_local"):
|
|
token_cache[cache_key] = payload
|
|
return payload
|
|
except JWTError as e:
|
|
raise HTTPException(status_code=401, detail=f"Dev token inválido: {e}")
|
|
|
|
try:
|
|
headers: Dict[str, str] = {"Authorization": f"Bearer {token}"}
|
|
if tenant_id_override:
|
|
headers["X-Tenant-Override"] = tenant_id_override
|
|
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
response = await client.get(
|
|
f"{settings.HUB_URL}api/v1/auth/me",
|
|
headers=headers
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
user_info = response.json()
|
|
token_cache[cache_key] = user_info
|
|
return user_info
|
|
|
|
logger.error(f"Hub token verification failed with status {response.status_code}")
|
|
raise HTTPException(status_code=401, detail="Could not validate credentials")
|
|
|
|
except httpx.HTTPError as e:
|
|
logger.error(f"Hub unreachable or error during token verification: {str(e)}")
|
|
raise HTTPException(status_code=503, detail="Authentication service unavailable")
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error during token verification: {str(e)}")
|
|
raise HTTPException(status_code=401, detail="Authentication error")
|
|
|
|
|
|
def _ensure_user_tenant_for_company(
|
|
db: Session, keycloak_user_id: str, tenant_id: int, company_id: int
|
|
) -> None:
|
|
"""Garantiza fila core.user_tenants (usuario ↔ compañía ↔ tenant)."""
|
|
from api.v1.modules.core.user_tenant.models import UserTenant
|
|
|
|
existing = (
|
|
db.query(UserTenant)
|
|
.filter(
|
|
UserTenant.keycloak_user_id == keycloak_user_id,
|
|
UserTenant.tenant_id == tenant_id,
|
|
UserTenant.company_id == company_id,
|
|
)
|
|
.first()
|
|
)
|
|
if existing:
|
|
if not existing.is_active:
|
|
existing.is_active = True
|
|
db.commit()
|
|
return
|
|
db.add(
|
|
UserTenant(
|
|
keycloak_user_id=keycloak_user_id,
|
|
tenant_id=tenant_id,
|
|
company_id=company_id,
|
|
is_active=True,
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
|
|
def _ensure_company_exists(
|
|
db: Session,
|
|
tenant_id: int,
|
|
tenant_name: str,
|
|
hub_user: Optional[Dict[str, Any]] = None,
|
|
) -> None:
|
|
"""
|
|
STUB — implementa este método con el modelo de compañía de tu proyecto.
|
|
|
|
Debe garantizar que exista al menos una empresa para el tenant y que el
|
|
usuario del token (hub_user["sub"]) tenga un registro en user_tenants.
|
|
"""
|
|
logger.debug(
|
|
"_ensure_company_exists: no implementado en la plantilla (tenant_id=%s)", tenant_id
|
|
)
|
|
|
|
|
|
def _repair_user_company_link_if_needed(
|
|
db: Session,
|
|
tenant_id_effective: int,
|
|
hub_user: Optional[Dict[str, Any]],
|
|
) -> None:
|
|
"""STUB — implementa con el modelo de compañía de tu proyecto."""
|
|
if not hub_user or not hub_user.get("sub"):
|
|
return
|
|
from api.v1.modules.core.permissions.service import PermissionService
|
|
from api.v1.modules.core.user_tenant.models import UserTenant
|
|
|
|
# Sin modelo de compañía en la plantilla, no hay empresa que verificar.
|
|
# Implementa esta función cuando definas tu tabla de compañías.
|
|
|
|
|
|
def _ensure_tenant_synced(
|
|
db: Session,
|
|
tenant_id: int,
|
|
tenant_slug: str,
|
|
hub_user: Optional[Dict[str, Any]] = None,
|
|
) -> int:
|
|
"""
|
|
Garantiza que el tenant del Hub exista en core.tenants local.
|
|
Se ejecuta una sola vez por tenant_id por ciclo de vida del proceso.
|
|
El Hub es la fuente de verdad — este método solo sincroniza en una dirección.
|
|
"""
|
|
if tenant_id in _synced_tenant_ids:
|
|
effective = int(_tenant_id_aliases.get(tenant_id, tenant_id))
|
|
_repair_user_company_link_if_needed(db, effective, hub_user)
|
|
return effective
|
|
|
|
try:
|
|
# Importación local para evitar imports circulares
|
|
from api.v1.modules.core.tenants.models import Tenant, TenantType
|
|
|
|
name = " ".join(word.capitalize() for word in tenant_slug.replace("-", " ").split())
|
|
|
|
existing = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
|
if existing:
|
|
# Update name/slug/keycloak_realm if they differ (Hub is source of truth)
|
|
if existing.slug != tenant_slug or existing.name != name or existing.keycloak_realm != tenant_slug:
|
|
existing.slug = tenant_slug
|
|
existing.name = name
|
|
existing.keycloak_realm = tenant_slug
|
|
db.commit()
|
|
logger.info(f"Tenant id={tenant_id} actualizado: slug='{tenant_slug}'")
|
|
_synced_tenant_ids.add(tenant_id)
|
|
# Garantizar empresa aunque el tenant ya existiera
|
|
_ensure_company_exists(db, tenant_id, name, hub_user)
|
|
return tenant_id
|
|
|
|
# Crear el tenant local con los datos disponibles del token.
|
|
# El Hub siempre crea el realm de Keycloak con el mismo nombre que el slug.
|
|
tenant = Tenant(
|
|
id=tenant_id,
|
|
name=name,
|
|
slug=tenant_slug,
|
|
type=TenantType.SHARED,
|
|
keycloak_realm=tenant_slug,
|
|
is_active=True,
|
|
)
|
|
db.add(tenant)
|
|
db.commit()
|
|
_synced_tenant_ids.add(tenant_id)
|
|
logger.info(f"Tenant '{tenant_slug}' (id={tenant_id}) sincronizado desde Hub a core.tenants")
|
|
# Crear la empresa correspondiente al tenant recién sincronizado
|
|
_ensure_company_exists(db, tenant_id, name, hub_user)
|
|
return tenant_id
|
|
|
|
except IntegrityError:
|
|
# Puede ser concurrencia o colisión de slug (id diferente, mismo slug)
|
|
db.rollback()
|
|
from api.v1.modules.core.tenants.models import Tenant
|
|
# Si el slug ya existe con diferente id, el tenant real del Hub no está registrado aún.
|
|
# Logueamos el conflicto para depuración; el sistema continuará con tenant_id vacío.
|
|
stale = db.query(Tenant).filter(Tenant.slug == tenant_slug).first()
|
|
if stale and stale.id != tenant_id:
|
|
logger.error(
|
|
f"Conflicto de tenant: JWT dice id={tenant_id} slug='{tenant_slug}', "
|
|
f"pero core.tenants tiene id={stale.id} mismo slug. "
|
|
f"Elimine el registro obsoleto con: "
|
|
f"DELETE FROM core.tenants WHERE id={stale.id};"
|
|
)
|
|
# Auto-heal en runtime: mapear temporalmente al tenant local existente por slug
|
|
# para evitar dejar al usuario sin compañías y evitar este conflicto en cada request.
|
|
_tenant_id_aliases[tenant_id] = int(stale.id)
|
|
_tenant_id_hub_by_local[int(stale.id)] = int(tenant_id)
|
|
_synced_tenant_ids.add(tenant_id)
|
|
_ensure_company_exists(db, int(stale.id), stale.name or tenant_slug, hub_user)
|
|
return int(stale.id)
|
|
else:
|
|
_synced_tenant_ids.add(tenant_id)
|
|
return tenant_id
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.warning(f"No se pudo sincronizar tenant {tenant_id} ({tenant_slug}): {e}")
|
|
return _tenant_id_aliases.get(tenant_id, tenant_id)
|
|
|
|
|
|
async def get_current_user(
|
|
credentials: HTTPAuthorizationCredentials = Security(security),
|
|
db: Session = Depends(get_core_db),
|
|
request: Request = None,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Dependency para obtener el usuario actual desde el token JWT.
|
|
Auto-sincroniza el tenant en core.tenants si fue creado en el Hub
|
|
pero aún no existe en la BD local.
|
|
|
|
Uso en FastAPI:
|
|
current_user: dict = Depends(get_current_user)
|
|
"""
|
|
token = credentials.credentials
|
|
|
|
# Leer tenant override del header X-Tenant-Override (pasado por el SvelteKit server
|
|
# desde la cookie sso_tenant_id, flujo SSO relay multi-tenant)
|
|
tenant_override = request.headers.get('X-Tenant-Override') if request else None
|
|
|
|
logger.info(f"[get_current_user] X-Tenant-Override={tenant_override!r}")
|
|
|
|
user_info = await verify_token(token, tenant_id_override=tenant_override)
|
|
# Copia local para poder normalizar tenant_id sin mutar el objeto cacheado
|
|
user_info = dict(user_info)
|
|
|
|
# Modo local: el token ya trae todo. Saltar sincronización con el Hub.
|
|
if settings.DEV_LOCAL_AUTH and user_info.get("dev_local"):
|
|
return user_info
|
|
|
|
# Sincronizar tenant desde Hub a BD local (solo la primera vez por tenant)
|
|
tenant_id = user_info.get("tenant_id")
|
|
tenant_slug = user_info.get("tenant_slug")
|
|
if tenant_id and tenant_slug:
|
|
effective_tenant_id = _ensure_tenant_synced(
|
|
db, int(tenant_id), str(tenant_slug), hub_user=user_info
|
|
)
|
|
if effective_tenant_id != int(tenant_id):
|
|
logger.warning(
|
|
f"[get_current_user] tenant_id ajustado por alias: hub={tenant_id} local={effective_tenant_id} slug={tenant_slug}"
|
|
)
|
|
user_info["tenant_id"] = effective_tenant_id
|
|
|
|
# Rehidratación de sesión: sincronización no bloqueante de avatar/perfil
|
|
# con cache corto para evitar llamadas excesivas al Hub.
|
|
try:
|
|
from core.workspace_profile_sync import sync_workspace_profile_for_user
|
|
|
|
await sync_workspace_profile_for_user(
|
|
db,
|
|
access_token=token,
|
|
keycloak_user_id=user_info.get("sub"),
|
|
tenant_id=user_info.get("tenant_id"),
|
|
workspace_profile=user_info,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("workspace_profile_sync_failed_on_get_current_user: %s", exc)
|
|
|
|
return user_info
|
|
|
|
|
|
async def get_current_active_user(
|
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Dependency para obtener usuario activo (puede incluir validaciones adicionales)
|
|
"""
|
|
# Aquí se pueden agregar validaciones adicionales
|
|
# Por ejemplo, verificar si el usuario está activo en la BD
|
|
return current_user
|
|
|
|
|
|
def has_role(required_role: str):
|
|
"""
|
|
Decorator/Dependency para verificar roles de usuario
|
|
|
|
Uso:
|
|
@router.get("/admin")
|
|
async def admin_endpoint(user = Depends(has_role("admin"))):
|
|
...
|
|
"""
|
|
|
|
async def role_checker(
|
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
|
) -> Dict[str, Any]:
|
|
user_roles = collect_user_role_names(current_user)
|
|
|
|
if required_role not in user_roles:
|
|
logger.warning(
|
|
"Role denied. Required: %s. User has: %s",
|
|
required_role,
|
|
sorted(user_roles),
|
|
)
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"User does not have required role: {required_role}",
|
|
)
|
|
|
|
return current_user
|
|
|
|
return role_checker
|
|
|
|
|
|
def get_tenant_from_token(user_info: Dict[str, Any]) -> Optional[int]:
|
|
"""
|
|
Extrae el tenant_id del token JWT
|
|
|
|
El tenant_id puede estar en diferentes lugares según configuración de Keycloak:
|
|
- En claims personalizados
|
|
- En el realm
|
|
- En atributos del usuario
|
|
"""
|
|
# Intentar obtener de claims personalizados
|
|
tenant_id = user_info.get("tenant_id")
|
|
if not tenant_id:
|
|
# Intentar obtener de atributos
|
|
tenant_id = user_info.get("attributes", {}).get("tenant_id")
|
|
|
|
if tenant_id:
|
|
return int(tenant_id)
|
|
|
|
return None
|
|
|
|
|
|
def resolve_hub_tenant_id_for_api(
|
|
local_tenant_id: Optional[int], x_tenant_override: Optional[str]
|
|
) -> int:
|
|
"""
|
|
ID de tenant en Hub (client_tenants) para llamadas a la API del Hub.
|
|
|
|
Prioriza X-Tenant-Override (cookie SSO). Si hubo drift id Hub↔local,
|
|
usa el mapeo inverso registrado en _ensure_tenant_synced.
|
|
"""
|
|
if x_tenant_override and str(x_tenant_override).strip().isdigit():
|
|
return int(str(x_tenant_override).strip())
|
|
if local_tenant_id is None:
|
|
return 0
|
|
lid = int(local_tenant_id)
|
|
return int(_tenant_id_hub_by_local.get(lid, lid))
|
|
|
|
|
|
def resolve_effective_tenant_id_from_user(current_user: Dict[str, Any]) -> Optional[int]:
|
|
"""
|
|
tenant_id efectivo del usuario: claims del token vía get_tenant_from_token,
|
|
luego fallback a ``tenant_id`` plano del Hub (puede venir como lista).
|
|
|
|
Contrato Hub: no es obligatorio que todo usuario tenga ``tenant_id`` en /auth/me;
|
|
el acceso por compañía puede basarse solo en RBAC local (ver ``user_has_app_company_membership``).
|
|
"""
|
|
tid = get_tenant_from_token(current_user)
|
|
if tid is not None:
|
|
return int(tid)
|
|
raw = current_user.get("tenant_id")
|
|
if raw is None:
|
|
return None
|
|
if isinstance(raw, list) and raw:
|
|
raw = raw[0]
|
|
try:
|
|
return int(raw)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def is_hub_admin(current_user: Dict[str, Any]) -> bool:
|
|
"""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,
|
|
company_id: Optional[int] = None,
|
|
) -> Optional[int]:
|
|
"""
|
|
Retorna el tenant_id efectivo o lanza 400.
|
|
Hub admin sin tenant_id en token: resuelve desde la empresa si company_id está disponible,
|
|
o retorna None como sentinel de acceso global (sin filtro de tenant).
|
|
"""
|
|
tid = get_tenant_from_token(current_user)
|
|
if tid is not None:
|
|
return int(tid)
|
|
raw = current_user.get("tenant_id")
|
|
if isinstance(raw, list) and raw:
|
|
raw = raw[0]
|
|
if raw is not None:
|
|
try:
|
|
return int(raw)
|
|
except (TypeError, ValueError):
|
|
raise HTTPException(status_code=400, detail="Invalid tenant ID in token")
|
|
|
|
if is_hub_admin(current_user):
|
|
# Sin modelo de compañía en la plantilla → acceso global sin filtro de tenant.
|
|
# Implementa la consulta a tu tabla de compañías si necesitas resolución exacta.
|
|
return None
|
|
|
|
raise HTTPException(status_code=400, detail="Tenant ID not found in user data")
|
|
|
|
|
|
def user_has_app_company_membership(
|
|
db: Session, user_id: str, company_id: int
|
|
) -> bool:
|
|
"""
|
|
True si el usuario tiene fila activa en RBAC de la app o en core.user_tenants
|
|
para esa compañía (independiente del tenant en el JWT).
|
|
"""
|
|
if not user_id:
|
|
return False
|
|
try:
|
|
from api.v1.modules.core.permissions.models import (
|
|
UserCompanyPermission,
|
|
UserCompanyRole,
|
|
)
|
|
from api.v1.modules.core.user_tenant.models import UserTenant
|
|
|
|
if (
|
|
db.query(UserCompanyRole)
|
|
.filter(
|
|
UserCompanyRole.user_id == user_id,
|
|
UserCompanyRole.company_id == company_id,
|
|
UserCompanyRole.is_active == True, # noqa: E712
|
|
)
|
|
.first()
|
|
):
|
|
return True
|
|
if (
|
|
db.query(UserCompanyPermission)
|
|
.filter(
|
|
UserCompanyPermission.user_id == user_id,
|
|
UserCompanyPermission.company_id == company_id,
|
|
UserCompanyPermission.is_active == True, # noqa: E712
|
|
)
|
|
.first()
|
|
):
|
|
return True
|
|
if (
|
|
db.query(UserTenant)
|
|
.filter(
|
|
UserTenant.keycloak_user_id == user_id,
|
|
UserTenant.company_id == company_id,
|
|
UserTenant.is_active == True, # noqa: E712
|
|
)
|
|
.first()
|
|
):
|
|
return True
|
|
except Exception as e:
|
|
logger.error("Error checking app company membership: %s", e)
|
|
return False
|
|
return False
|
|
|
|
|
|
def collect_company_ids_from_app_membership(
|
|
db: Session, user_id: str
|
|
) -> Set[int]:
|
|
"""IDs de compañía donde el usuario tiene rol, permiso directo o user_tenants."""
|
|
ids: Set[int] = set()
|
|
if not user_id:
|
|
return ids
|
|
try:
|
|
from api.v1.modules.core.permissions.models import (
|
|
UserCompanyPermission,
|
|
UserCompanyRole,
|
|
)
|
|
from api.v1.modules.core.user_tenant.models import UserTenant
|
|
|
|
for (cid,) in (
|
|
db.query(UserCompanyRole.company_id)
|
|
.filter(
|
|
UserCompanyRole.user_id == user_id,
|
|
UserCompanyRole.is_active == True, # noqa: E712
|
|
)
|
|
.distinct()
|
|
.all()
|
|
):
|
|
ids.add(int(cid))
|
|
for (cid,) in (
|
|
db.query(UserCompanyPermission.company_id)
|
|
.filter(
|
|
UserCompanyPermission.user_id == user_id,
|
|
UserCompanyPermission.is_active == True, # noqa: E712
|
|
)
|
|
.distinct()
|
|
.all()
|
|
):
|
|
ids.add(int(cid))
|
|
for (cid,) in (
|
|
db.query(UserTenant.company_id)
|
|
.filter(
|
|
UserTenant.keycloak_user_id == user_id,
|
|
UserTenant.is_active == True, # noqa: E712
|
|
)
|
|
.distinct()
|
|
.all()
|
|
):
|
|
ids.add(int(cid))
|
|
except Exception as e:
|
|
logger.error("Error collecting company ids from membership: %s", e)
|
|
return ids
|
|
|
|
|
|
def collect_user_role_names(current_user: Dict[str, Any]) -> Set[str]:
|
|
"""
|
|
Roles del usuario: primero la lista ``roles`` del Hub (GET /api/v1/auth/me
|
|
vía verify_token). Si no hay lista no vacía, se unen realm_access y
|
|
resource_access del JWT Keycloak clásico.
|
|
"""
|
|
names: Set[str] = set()
|
|
hub_roles = current_user.get("roles")
|
|
if isinstance(hub_roles, list):
|
|
names.update(str(r) for r in hub_roles if r is not None)
|
|
|
|
if names:
|
|
return names
|
|
|
|
realm = current_user.get("realm_access")
|
|
if isinstance(realm, dict):
|
|
names.update(str(r) for r in (realm.get("roles") or []) if r is not None)
|
|
for client in (current_user.get("resource_access") or {}).values():
|
|
if isinstance(client, dict):
|
|
names.update(str(r) for r in (client.get("roles") or []) if r is not None)
|
|
return names
|
|
|
|
|
|
def validate_company_access(
|
|
db: Session, company_id: int, current_user: Dict[str, Any]
|
|
) -> bool:
|
|
"""
|
|
Valida acceso a la compañía: (1) tenant del token/Hub alineado con la empresa, o
|
|
(2) membership en la app (RBAC / user_tenants) para ese ``company_id``.
|
|
|
|
El contrato con el Hub puede no incluir ``tenant_id`` para todos los usuarios;
|
|
en ese caso el acceso se basa en asignaciones en PostgreSQL.
|
|
"""
|
|
user_id = current_user.get("sub") or current_user.get("id")
|
|
if user_id and user_has_app_company_membership(db, str(user_id), company_id):
|
|
return True
|
|
|
|
tenant_id = resolve_effective_tenant_id_from_user(current_user)
|
|
if not tenant_id:
|
|
return False
|
|
|
|
# Sin modelo de compañía en la plantilla → delega solo en user_has_app_company_membership.
|
|
# Implementa la consulta a tu tabla de compañías para validación estricta.
|
|
return True
|
|
|
|
|
|
def validate_access_to_resource(
|
|
db: Session,
|
|
company_id: int,
|
|
current_user: Dict[str, Any],
|
|
required_permissions: Optional[list[str]] = None,
|
|
require_all: bool = True,
|
|
) -> Optional[int]:
|
|
"""
|
|
Valida que el usuario tenga acceso a un recurso específico basado en company_id
|
|
y regresa el tenant_id. Opcionalmente verifica permisos.
|
|
|
|
Args:
|
|
db: Sesión de base de datos
|
|
company_id: company_id asociado al recurso
|
|
current_user: Información del usuario actual desde el token
|
|
required_permissions: Lista opcional de permisos requeridos. Si es None, no verifica permisos.
|
|
require_all: Si True, requiere TODOS los permisos. Si False, requiere AL MENOS UNO.
|
|
|
|
Returns:
|
|
tenant_id si el usuario tiene acceso
|
|
|
|
Raises:
|
|
HTTPException: Si no hay tenant_id, no tiene acceso o no tiene los permisos requeridos
|
|
"""
|
|
|
|
tenant_id = resolve_effective_tenant_id_from_user(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_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")
|
|
|
|
# Sin modelo de compañía en la plantilla no se puede resolver tenant_id desde company.
|
|
# Implementa esta lógica cuando definas tu tabla de compañías.
|
|
|
|
# Si aún no hay tenant_id y no es admin, error 400
|
|
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_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,
|
|
detail="No se pudo resolver el tenant_id para la empresa especificada",
|
|
)
|
|
return int(tenant_id)
|
|
|
|
from api.v1.modules.core.permissions.service import PermissionService
|
|
user_id = current_user.get("sub") or current_user.get("id")
|
|
permission_service = PermissionService(db)
|
|
|
|
has_access = False
|
|
if require_all:
|
|
has_access = permission_service.has_all_permissions(user_id, company_id, required_permissions)
|
|
else:
|
|
has_access = permission_service.has_any_permission(user_id, company_id, required_permissions)
|
|
|
|
# 🛡️ MEJORA DEV: Auto-bootstrap si falla el acceso en desarrollo
|
|
if not has_access and settings.ENVIRONMENT == "development":
|
|
try:
|
|
# Si el usuario no tiene roles asignados, intentamos el bootstrap
|
|
# bootstrap_super_admin solo asigna el rol si no tiene ninguno (o es admin)
|
|
permission_service.bootstrap_super_admin(user_id, company_id)
|
|
# Re-validar
|
|
if require_all:
|
|
has_access = permission_service.has_all_permissions(user_id, company_id, required_permissions)
|
|
else:
|
|
has_access = permission_service.has_any_permission(user_id, company_id, required_permissions)
|
|
|
|
if has_access:
|
|
logger.info(
|
|
"Auto-bootstrap exitoso para user_id=%s company_id=%s", user_id, company_id
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Error en auto-bootstrap de seguridad user_id=%s company_id=%s: %s",
|
|
user_id, company_id, e,
|
|
)
|
|
|
|
if not has_access:
|
|
raise HTTPException(status_code=403, detail="Permission denied")
|
|
|
|
# Nunca sustituir tenant_id=None/0 silenciosamente — un valor inválido aquí
|
|
# 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_global_admin:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="No se pudo determinar el tenant_id para la empresa especificada",
|
|
)
|
|
return tenant_id # puede ser None solo para hub_admin sin required_permissions (acceso global)
|