# Conflicts: # backend/.env.example # backend/api/v1/modules/core/auth/service.py # backend/api/v1/modules/core/users/service.py # backend/core/middleware.py # docker-compose.yml # frontend/src/lib/auth.ts # frontend/src/lib/components/help/HelpDrawer.svelte # scripts/backend-entrypoint.sh
416 lines
16 KiB
Python
416 lines
16 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] = {}
|
|
|
|
# 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_company_exists(db: Session, tenant_id: int, tenant_name: str) -> None:
|
|
"""
|
|
Garantiza que exista al menos una empresa en a76.company para el tenant.
|
|
El tenant IS la empresa — se crea automáticamente al primer login.
|
|
"""
|
|
try:
|
|
from api.v1.modules.a76.general_catalogs.company.models import Company
|
|
exists = db.query(Company).filter(Company.tenant_id == tenant_id).first()
|
|
if not exists:
|
|
company = Company(tenant_id=tenant_id, name=tenant_name)
|
|
db.add(company)
|
|
db.commit()
|
|
logger.info(f"Empresa creada automáticamente para tenant id={tenant_id}: '{tenant_name}'")
|
|
elif exists.name != tenant_name:
|
|
exists.name = tenant_name
|
|
db.commit()
|
|
logger.info(f"Empresa actualizada para tenant id={tenant_id}: '{tenant_name}'")
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.warning(f"No se pudo crear empresa automática para tenant {tenant_id}: {e}")
|
|
|
|
|
|
def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> 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:
|
|
return _tenant_id_aliases.get(tenant_id, tenant_id)
|
|
|
|
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)
|
|
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)
|
|
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)
|
|
_synced_tenant_ids.add(tenant_id)
|
|
_ensure_company_exists(db, int(stale.id), stale.name or tenant_slug)
|
|
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))
|
|
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 = current_user.get("realm_access", {}).get("roles", [])
|
|
|
|
if required_role not in user_roles:
|
|
logger.warning(f"Role denied. Required: {required_role}. User actually has: {user_roles}")
|
|
# Also check client roles as a debug fallback
|
|
client_roles = current_user.get("resource_access", {})
|
|
logger.warning(f"User client roles: {client_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 validate_company_access(
|
|
db: Session, company_id: int, current_user: Dict[str, Any]
|
|
) -> bool:
|
|
"""
|
|
Valida que el usuario tenga acceso a la compañía solicitada
|
|
|
|
Args:
|
|
company_id: ID de la compañía a la que se quiere acceder
|
|
current_user: Información del usuario actual desde el token
|
|
|
|
Returns:
|
|
True si el usuario tiene acceso, False en caso contrario
|
|
|
|
Nota:
|
|
Verifica que la compañía pertenezca al tenant del usuario consultando la BD.
|
|
"""
|
|
|
|
tenant_id = get_tenant_from_token(current_user)
|
|
|
|
# Si no hay tenant_id en el token, denegar acceso
|
|
if not tenant_id:
|
|
return False
|
|
|
|
# Consultar si la compañía pertenece al tenant
|
|
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(f"Error validating company access: {str(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 = get_tenant_from_token(current_user)
|
|
if not tenant_id:
|
|
tenant_id = current_user.get("tenant_id")
|
|
|
|
# 🕵️ DEBUG ULTRA-DETALLADO (Ver en consola del backend)
|
|
print("--- TOKEN DEBUG START ---")
|
|
print(f"Usuario: {current_user.get('preferred_username')}")
|
|
print(f"Sub: {current_user.get('sub')}")
|
|
print(f"Realm Roles: {current_user.get('realm_access', {}).get('roles', [])}")
|
|
print(f"Resource Access: {current_user.get('resource_access', {})}")
|
|
print("--- TOKEN DEBUG END ---")
|
|
|
|
# 🛡️ DETERMINAR SI ES ADMIN DE KEYCLOAK
|
|
realm_roles = current_user.get("realm_access", {}).get("roles", [])
|
|
# Buscamos en todos los clientes posibles por si acaso
|
|
all_client_roles = []
|
|
for client in current_user.get("resource_access", {}).values():
|
|
all_client_roles.extend(client.get("roles", []))
|
|
|
|
all_user_roles = set(realm_roles + all_client_roles)
|
|
is_keycloak_admin = "admin" in 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
|
|
|
|
if not is_keycloak_admin and not is_me_endpoint:
|
|
if not validate_company_access(db, company_id, current_user):
|
|
print(f"DEBUG: Acceso denegado a compañía {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: Permiso denegado. Faltan: {required_permissions}")
|
|
raise HTTPException(status_code=403, detail="Permission denied")
|
|
|
|
return tenant_id or 1
|