Files
plantillas-proyectos/backend/core/security.py

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