705 lines
25 KiB
Python
705 lines
25 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()
|
|
|
|
|
|
async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str, Any]:
|
|
"""
|
|
Verifica un token JWT llamando al Hub central.
|
|
"""
|
|
# Cache key incluye el override para que distintos tenants no se mezclen
|
|
cache_key = (token, tenant_id_override)
|
|
if cache_key in token_cache:
|
|
return token_cache[cache_key]
|
|
|
|
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:
|
|
"""
|
|
Garantiza al menos una empresa en a76.company para el tenant.
|
|
|
|
Primera vez: CompanyService.create_company_manually (seed catálogos por compañía),
|
|
relación user_tenants y bootstrap_super_admin para el usuario del token.
|
|
"""
|
|
from api.v1.modules.a76.general_catalogs.company.models import Company
|
|
from api.v1.modules.a76.general_catalogs.company.dto import CompanyCreateDTO
|
|
from api.v1.modules.a76.general_catalogs.company.service import CompanyService
|
|
from api.v1.modules.core.permissions.service import PermissionService
|
|
from api.v1.modules.core.user_tenant.models import UserTenant
|
|
|
|
kc = hub_user.get("sub") if hub_user else None
|
|
|
|
try:
|
|
company = db.query(Company).filter(Company.tenant_id == tenant_id).first()
|
|
|
|
if not company:
|
|
svc = CompanyService(db)
|
|
username = "System"
|
|
if hub_user:
|
|
username = (
|
|
hub_user.get("preferred_username")
|
|
or hub_user.get("email")
|
|
or hub_user.get("name")
|
|
or "System"
|
|
)
|
|
company = svc.create_company_manually(
|
|
CompanyCreateDTO(name=tenant_name),
|
|
tenant_id=tenant_id,
|
|
username=username,
|
|
)
|
|
logger.info(
|
|
"Empresa creada vía CompanyService para tenant id=%s name=%r company_id=%s",
|
|
tenant_id,
|
|
tenant_name,
|
|
company.id,
|
|
)
|
|
|
|
if not company:
|
|
logger.warning(
|
|
"No hay empresa para tenant %s tras intento de creación automática", tenant_id
|
|
)
|
|
return
|
|
|
|
if company.name != tenant_name:
|
|
company.name = tenant_name
|
|
db.commit()
|
|
logger.info("Empresa actualizada para tenant id=%s: '%s'", tenant_id, tenant_name)
|
|
|
|
if kc:
|
|
ut = (
|
|
db.query(UserTenant)
|
|
.filter(
|
|
UserTenant.keycloak_user_id == kc,
|
|
UserTenant.tenant_id == tenant_id,
|
|
UserTenant.company_id == company.id,
|
|
)
|
|
.first()
|
|
)
|
|
if not ut:
|
|
_ensure_user_tenant_for_company(db, kc, tenant_id, company.id)
|
|
PermissionService(db).bootstrap_super_admin(kc, company.id)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.warning(
|
|
"No se pudo asegurar empresa/usuario para tenant %s: %s", tenant_id, e
|
|
)
|
|
|
|
|
|
def _repair_user_company_link_if_needed(
|
|
db: Session,
|
|
tenant_id_effective: int,
|
|
hub_user: Optional[Dict[str, Any]],
|
|
) -> None:
|
|
"""Si ya hay empresa pero el usuario no tiene user_tenants, enlaza y hace bootstrap."""
|
|
if not hub_user or not hub_user.get("sub"):
|
|
return
|
|
from api.v1.modules.a76.general_catalogs.company.models import Company
|
|
from api.v1.modules.core.permissions.service import PermissionService
|
|
from api.v1.modules.core.user_tenant.models import UserTenant
|
|
|
|
kc = hub_user["sub"]
|
|
company = (
|
|
db.query(Company)
|
|
.filter(Company.tenant_id == tenant_id_effective)
|
|
.first()
|
|
)
|
|
if not company:
|
|
return
|
|
ut = (
|
|
db.query(UserTenant)
|
|
.filter(
|
|
UserTenant.keycloak_user_id == kc,
|
|
UserTenant.tenant_id == tenant_id_effective,
|
|
UserTenant.company_id == company.id,
|
|
)
|
|
.first()
|
|
)
|
|
if ut:
|
|
return
|
|
try:
|
|
_ensure_user_tenant_for_company(db, kc, tenant_id_effective, company.id)
|
|
PermissionService(db).bootstrap_super_admin(kc, company.id)
|
|
except Exception as e:
|
|
logger.warning(
|
|
"No se pudo reparar enlace usuario-compañía tenant=%s: %s",
|
|
tenant_id_effective,
|
|
e,
|
|
)
|
|
|
|
|
|
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)
|
|
|
|
# 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
|
|
|
|
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 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
|
|
|
|
try:
|
|
from api.v1.modules.a76.general_catalogs.company.models import Company
|
|
|
|
company = (
|
|
db.query(Company)
|
|
.filter(Company.id == company_id, Company.tenant_id == tenant_id)
|
|
.first()
|
|
)
|
|
|
|
return company is not None
|
|
except Exception as e:
|
|
logger.error("Error validating company access: %s", e)
|
|
return False
|
|
|
|
|
|
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,
|
|
) -> 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)
|
|
print(f"DEBUG: validate_access_to_resource: tid={tenant_id} user={current_user.get('preferred_username')}")
|
|
|
|
# Admin global Keycloak / master: lista ``roles`` del Hub (/auth/me), con fallback JWT.
|
|
all_user_roles = collect_user_role_names(current_user)
|
|
is_keycloak_admin = "admin" in all_user_roles
|
|
print(f"DEBUG: validate_access_to_resource: is_admin={is_keycloak_admin} roles={all_user_roles}")
|
|
|
|
# 🚪 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
|
|
print(f"DEBUG: validate_access_to_resource: is_me={is_me_endpoint} required={required_permissions}")
|
|
|
|
if not is_keycloak_admin and not is_me_endpoint:
|
|
if not validate_company_access(db, company_id, current_user):
|
|
print(f"DEBUG: validate_access_to_resource: ACCESO DENEGADO a cia {company_id}")
|
|
raise HTTPException(status_code=403, detail="Access denied to this company")
|
|
|
|
# Si no hay tenant_id, intentamos recuperarlo de la empresa
|
|
if not tenant_id:
|
|
try:
|
|
from api.v1.modules.a76.general_catalogs.company.models import Company
|
|
company = db.query(Company).filter(Company.id == company_id).first()
|
|
if company:
|
|
tenant_id = company.tenant_id
|
|
except:
|
|
pass
|
|
|
|
# Si aún no hay tenant_id y no es admin, error 400
|
|
if not tenant_id and not is_keycloak_admin and not is_me_endpoint:
|
|
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
|
|
|
|
# Verificar permisos locales
|
|
if required_permissions:
|
|
if is_keycloak_admin:
|
|
return tenant_id or 1
|
|
|
|
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:
|
|
print(f"DEBUG: Auto-bootstrap exitoso para {user_id} en empresa {company_id}")
|
|
except Exception as e:
|
|
print(f"DEBUG: Error en auto-bootstrap de seguridad: {e}")
|
|
|
|
if not has_access:
|
|
print(f"DEBUG: validate_access_to_resource: PERMISO DENEGADO. Faltan: {required_permissions}")
|
|
raise HTTPException(status_code=403, detail="Permission denied")
|
|
|
|
print(f"DEBUG: validate_access_to_resource: ACCESO CONCEDIDO")
|
|
|
|
return tenant_id or 1
|