Merge branch 'development' into feature/reporte-bak

This commit is contained in:
2026-04-30 16:30:14 -06:00
45 changed files with 1923 additions and 2297 deletions

View File

@@ -4,29 +4,18 @@ APP_VERSION=1.0.0
DEBUG=True
ENVIRONMENT=development
# Database - Core (Shared)
# Database - Core
CORE_DB_HOST=localhost
CORE_DB_PORT=5432
CORE_DB_NAME=anexo76_core
CORE_DB_USER=postgres
CORE_DB_PASSWORD=postgres
# Keycloak
KEYCLOAK_SERVER_URL=http://localhost:8080/kcauth
KEYCLOAK_REALM=master
KEYCLOAK_CLIENT_ID=anexo76-backend
KEYCLOAK_CLIENT_SECRET=your-client-secret
# Security
SECRET_KEY=your-secret-key-change-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# CORS
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
# License Service
LICENSE_CHECK_ENABLED=True
# Hub de Aduanasoft — requerido siempre (SaaS y self-hosted)
HUB_URL=https://hub.aduanasoft.com
# Factura COVE / VUCEM / DODA / API Ventanilla Única
# Llave y IV AES-256-CBC para cifrar la clave FIEL.
@@ -37,7 +26,6 @@ COVE_API_URL=https://api.vu.aduanasoft.com
# Verificación SSL para el API de VU (False en redes internas / dev, True en producción).
COVE_API_VERIFY_SSL=False
# Synchronization (Hub & Spoke)
# Sincronización (Hub & Spoke)
SYNC_SECRET_TOKEN=change-this-sync-token-in-production
# Only for spokes/clients. Leave empty if this is the Hub.
CENTRAL_SERVER_URL=http://localhost:8000/api/v1/core/help-center/sync/
CENTRAL_SERVER_URL=

View File

@@ -0,0 +1,53 @@
"""increase invoice_header who_processed and capture_user to 100 chars
Revision ID: g1b2c3d4e5f6
Revises: f7a8b9c0d1e2
Create Date: 2026-04-30 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'g1b2c3d4e5f6'
down_revision = 'f7a8b9c0d1e2'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column(
'invoice_header',
'who_processed',
existing_type=sa.String(length=20),
type_=sa.String(length=100),
existing_nullable=True,
schema='a76',
)
op.alter_column(
'invoice_header',
'capture_user',
existing_type=sa.String(length=20),
type_=sa.String(length=100),
existing_nullable=True,
schema='a76',
)
def downgrade() -> None:
op.alter_column(
'invoice_header',
'capture_user',
existing_type=sa.String(length=100),
type_=sa.String(length=20),
existing_nullable=True,
schema='a76',
)
op.alter_column(
'invoice_header',
'who_processed',
existing_type=sa.String(length=100),
type_=sa.String(length=20),
existing_nullable=True,
schema='a76',
)

View File

@@ -0,0 +1,26 @@
"""merge alembic heads for deployment
Revision ID: h1a2b3c4d5e6
Revises: c3d4e5f6a7b8, g1b2c3d4e5f6
Create Date: 2026-04-30 12:00:00.000000
"""
from typing import Sequence, Union
# revision identifiers, used by Alembic.
revision: str = "h1a2b3c4d5e6"
down_revision: Union[str, Sequence[str], None] = (
"c3d4e5f6a7b8",
"g1b2c3d4e5f6",
)
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@@ -0,0 +1,104 @@
"""Fix VARCHAR(20) truncation errors in audit_log and discharges
Revision ID: i7j8k9l0m1n2
Revises: h1a2b3c4d5e6
Create Date: 2026-04-30 13:00:00.000000
Issues fixed:
- audit_log.system: String(20) → String(50)
- audit_log.operation_type: String(20) → String(50)
- discharges.cancelled_by: String(20) → String(100)
- octave_balance.octave_permit: String(20) → String(100)
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'i7j8k9l0m1n2'
down_revision = 'h1a2b3c4d5e6'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Fix audit_log.system
op.alter_column(
'audit_logs',
'system',
existing_type=sa.String(length=20),
type_=sa.String(length=50),
existing_nullable=False,
schema='a76',
)
# Fix audit_log.operation_type
op.alter_column(
'audit_logs',
'operation_type',
existing_type=sa.String(length=20),
type_=sa.String(length=50),
existing_nullable=True,
schema='a76',
)
# Fix discharges.cancelled_by
op.alter_column(
'discharge_header',
'cancelled_by',
existing_type=sa.String(length=20),
type_=sa.String(length=100),
existing_nullable=True,
schema='a24',
)
# Fix octave_balance.octave_permit
op.alter_column(
'octave_balance',
'octave_permit',
existing_type=sa.String(length=20),
type_=sa.String(length=100),
existing_nullable=False,
schema='a76',
)
def downgrade() -> None:
# Revert octave_balance.octave_permit
op.alter_column(
'octave_balance',
'octave_permit',
existing_type=sa.String(length=100),
type_=sa.String(length=20),
existing_nullable=False,
schema='a76',
)
# Revert discharges.cancelled_by
op.alter_column(
'discharge_header',
'cancelled_by',
existing_type=sa.String(length=100),
type_=sa.String(length=20),
existing_nullable=True,
schema='a24',
)
# Revert audit_log.operation_type
op.alter_column(
'audit_logs',
'operation_type',
existing_type=sa.String(length=50),
type_=sa.String(length=20),
existing_nullable=True,
schema='a76',
)
# Revert audit_log.system
op.alter_column(
'audit_logs',
'system',
existing_type=sa.String(length=50),
type_=sa.String(length=20),
existing_nullable=False,
schema='a76',
)

View File

@@ -132,7 +132,7 @@ class DischargeHeader(Base, TenantScopedMixin, TimestampMixin):
)
# ── Cancellation trail ────────────────────────────────────────────────
cancelled_by: Mapped[Optional[str]] = mapped_column(String(20))
cancelled_by: Mapped[Optional[str]] = mapped_column(String(100))
cancellation_reason: Mapped[Optional[str]] = mapped_column(String(300))
# ── Relationships ─────────────────────────────────────────────────────

View File

@@ -12,7 +12,7 @@ class UserContextMiddleware(BaseHTTPMiddleware):
try:
# verify_token might raise exception if invalid, we catch it to not block request
# but we won't have user context
user_info = verify_token(token)
user_info = await verify_token(token)
set_user_context(user_info)
except Exception:
# Log error or ignore

View File

@@ -30,12 +30,12 @@ class AuditLog(Base, TenantScopedMixin, TimestampMixin):
# Technical Columns
timestamp = Column(DateTime(timezone=True), nullable=False, index=True) # Combined for queries
system = Column(String(20), nullable=False, index=True, default="fixed_asset")
system = Column(String(50), nullable=False, index=True, default="fixed_asset")
# Traceability
table_name = Column(String(100), nullable=True, index=True)
record_id = Column(String(255), nullable=True, index=True)
operation_type = Column(String(20), nullable=True, index=True) # CREATE, UPDATE, DELETE, LOGIN
operation_type = Column(String(50), nullable=True, index=True) # CREATE, UPDATE, DELETE, LOGIN
# Data Changes
old_values = Column(JSONB, nullable=True)

View File

@@ -124,10 +124,10 @@ class InvoiceHeader(Base, TenantScopedMixin, TimestampMixin):
TIMESTAMP(timezone=False)
) # FECHAACTUALIZACION / FECHAACTUAL
who_processed: Mapped[Optional[str]] = mapped_column(
String(20)
String(100)
) # USUARIOACT / Quien actualizó
capture_user: Mapped[Optional[str]] = mapped_column(
String(20)
String(100)
) # USUARIOCAP / Usuario que capturó
traffic_light_status: Mapped[Optional[str]] = mapped_column(

View File

@@ -1,6 +1,6 @@
from typing import Dict, Any, Optional
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from core.security import collect_user_role_names, get_current_user, validate_access_to_resource
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from sqlalchemy import func, or_, and_
from sqlalchemy.orm import Session
@@ -201,8 +201,9 @@ def list_invoices(
List invoices with optional filters and granular permission enforcement.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
user_roles = current_user.get("realm_access", {}).get("roles", [])
allowed_filters = []
# Roles vienen del Hub (/auth/me), no de realm_access del JWT crudo.
is_hub_admin = "admin" in collect_user_role_names(current_user)
allowed_filters = []
# Información del usuario para debugging (se ve en los logs del servidor)
user_name = current_user.get('preferred_username') or current_user.get('email', 'Desconocido')
@@ -246,8 +247,8 @@ def list_invoices(
if not allowed_filters:
# Si no tiene ningún permiso de factura, bloqueamos
# Excepto si es un admin de Keycloak, le damos el beneficio de la duda pero logeamos
if "admin" in user_roles:
# Excepto si es admin (Hub roles / Keycloak), fallback a ver todo.
if is_hub_admin:
print(f"[AUTH] Keycloak Admin {user_name} has no app permissions. Granting view_all as fallback.")
allowed_filters = None
else:

View File

@@ -73,8 +73,8 @@ class InvoiceHeaderBase(BaseModel):
return InvoiceStatus.PROCESSED.value
return v
processed_date: Optional[datetime] = Field(None, description="Update date")
who_processed: Optional[str] = Field(None, max_length=20, description="Who processed")
capture_user: Optional[str] = Field(None, max_length=20, description="Capture user")
who_processed: Optional[str] = Field(None, max_length=100, description="Who processed")
capture_user: Optional[str] = Field(None, max_length=100, description="Capture user")
traffic_light_status: Optional[str] = Field(
None, max_length=50, description="Traffic light status"
)

View File

@@ -42,7 +42,7 @@ class OctaveBalance(Base, TenantScopedMixin, TimestampMixin):
origin_country: Mapped[str] = mapped_column(String(3)) # PAISORIGEN
fraction_type: Mapped[str] = mapped_column(String(7)) # TIPOFRACIMPO
sector: Mapped[str] = mapped_column(String(8)) # SECTOR
octave_permit: Mapped[str] = mapped_column(String(20)) # PERMISOROCTAVA
octave_permit: Mapped[str] = mapped_column(String(100)) # PERMISOROCTAVA
origin: Mapped[str] = mapped_column(String(3)) # PROCEDENCIA ('TEM')
system: Mapped[str] = mapped_column(String(5)) # SISTEMA ('fixed_asset | inventory')
line: Mapped[int] = mapped_column(Integer) # LINEA

View File

@@ -33,6 +33,9 @@ class TokenResponseDTO(BaseModel):
refresh_token: str
token_type: str = "bearer"
expires_in: int
tenant: Optional["TenantInfoDTO"] = None
tenant_id: Optional[int] = None
tenant_slug: Optional[str] = None
class Config:
json_schema_extra = {
@@ -197,3 +200,9 @@ class LoginChoiceResponseDTO(BaseModel):
status: str = "choose_tenant"
tenants: list[TenantInfoDTO]
class SSOExchangeRequestDTO(BaseModel):
"""DTO para canjear el relay token por KC tokens."""
relay_token: str = Field(..., description="Relay token recibido en la URL")

View File

@@ -17,6 +17,7 @@ from .dto import (
RegisterRequestDTO,
RegisterResponseDTO,
SetCookieRequestDTO,
SSOExchangeRequestDTO,
SwitchTenantRequestDTO,
TokenResponseDTO,
UserInfoResponseDTO,
@@ -48,7 +49,7 @@ async def register(
- Atributos de tenant
"""
service = AuthService(db)
return service.register(register_data)
return await service.register(register_data)
@router.post("/login", response_model=None)
@@ -68,7 +69,7 @@ async def login(
service = AuthService(db)
import logging
logger = logging.getLogger(__name__)
return service.login(
return await service.login(
login_data=login_data,
ip_address=request.client.host,
user_agent=request.headers.get("user-agent")
@@ -90,7 +91,7 @@ async def switch_tenant(
"""
service = AuthService(db)
# Obtener info del usuario desde el access token actual
user_info = service.get_user_info(credentials.credentials)
user_info = await service.get_user_info(credentials.credentials)
keycloak_user_id = user_info.sub
# El realm se puede inferir del token; usamos el campo tenant_id para buscar el realm actual,
@@ -103,7 +104,7 @@ async def switch_tenant(
if not tenant:
raise HTTPException(status_code=403, detail="Access denied")
return service.switch_tenant(
return await service.switch_tenant(
keycloak_user_id=keycloak_user_id,
keycloak_realm=tenant.keycloak_realm,
tenant_slug=data.tenant_slug,
@@ -119,7 +120,7 @@ async def refresh_token(
Refresca el access token usando el refresh token
"""
service = AuthService(db)
return service.refresh_token(refresh_data)
return await service.refresh_token(refresh_data)
@router.get("/me", response_model=UserInfoResponseDTO)
@@ -131,7 +132,7 @@ async def get_current_user_info(
Obtiene información del usuario actual desde el token
"""
service = AuthService(db)
return service.get_user_info(credentials.credentials)
return await service.get_user_info(credentials.credentials)
@router.post("/logout")
@@ -155,7 +156,7 @@ async def logout(
# I'll keep it simple.
service = AuthService(db)
return service.logout(logout_data)
return await service.logout(logout_data)
@router.post("/exchange-code", response_model=TokenResponseDTO)
@@ -172,7 +173,7 @@ async def exchange_code(
externo y Keycloak lo redirige al frontend con el código en los query params.
"""
service = AuthService(db)
return service.exchange_code(exchange_data)
return await service.exchange_code(exchange_data)
@router.post("/set-cookie")
@@ -198,7 +199,7 @@ async def set_cookie(
service = AuthService(db)
try:
# Validar el access token
user_info = service.get_user_info(cookie_data.access_token)
user_info = await service.get_user_info(cookie_data.access_token)
# Establecer las cookies
# Access token cookie
@@ -231,3 +232,39 @@ async def set_cookie(
except Exception as e:
raise HTTPException(status_code=400, detail=f"Error validando tokens: {str(e)}")
@router.post("/sso-exchange", response_model=TokenResponseDTO)
async def sso_exchange(
body: SSOExchangeRequestDTO,
response: Response,
db: Session = Depends(get_core_db),
):
"""
Canjea un relay token de un solo uso (generado por el Hub) por KC tokens.
Llamado server-side desde la página /auth/sso del frontend de Anexo76.
Establece cookies HttpOnly con los tokens y devuelve el resultado.
"""
service = AuthService(db)
tokens = await service.sso_exchange(body.relay_token)
_is_prod = False # TODO: leer de settings.ENVIRONMENT == "production"
response.set_cookie(
key="access_token",
value=tokens.access_token,
httponly=True,
secure=_is_prod,
samesite="lax",
max_age=3600,
path="/",
)
response.set_cookie(
key="refresh_token",
value=tokens.refresh_token,
httponly=True,
secure=_is_prod,
samesite="lax",
max_age=86400,
path="/",
)
return tokens

View File

@@ -1,24 +1,15 @@
"""
Servicio de autenticación con Keycloak
"""
import logging
from datetime import datetime
import httpx
from typing import Any, Dict
from api.v1.modules.core.tenants.service import TenantService
from api.v1.modules.core.user_tenant.service import UserTenantService
from core.config import settings
from fastapi import HTTPException
from keycloak import KeycloakAdmin, KeycloakOpenID
from keycloak.exceptions import KeycloakError
from sqlalchemy.orm import Session
from .dto import (
LoginRequestDTO,
LogoutRequestDTO,
RefreshTokenRequestDTO,
RegisterRequestDTO,
RegisterResponseDTO,
TokenResponseDTO,
UserInfoResponseDTO,
)
@@ -27,738 +18,195 @@ logger = logging.getLogger(__name__)
class AuthService:
"""Servicio de autenticación"""
"""Servicio de autenticación centralizado vía Hub"""
def __init__(self, db: Session):
self.db = db
self.keycloak_openid = KeycloakOpenID(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
client_id=settings.KEYCLOAK_CLIENT_ID,
realm_name=settings.KEYCLOAK_REALM,
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
)
def login(
async def login(
self,
login_data: LoginRequestDTO,
ip_address: str = None,
user_agent: str = None
):
"""
Autentica usuario y obtiene tokens.
Si se omite tenant_slug, verifica credenciales primero y devuelve
la lista de tenants disponibles (LoginChoiceResponseDTO) en lugar de tokens.
Args:
login_data: Credenciales de login (tenant_slug es opcional)
ip_address: Dirección IP del cliente
user_agent: User Agent del cliente
Returns:
TokenResponseDTO si tenant_slug fue provisto,
LoginChoiceResponseDTO si no se proveyó tenant_slug.
Raises:
HTTPException: Si las credenciales son inválidas
Autentica usuario a través del Hub y obtiene tokens.
"""
# PRIMER PASO: sin tenant_slug → verificar creds y devolver lista de orgs
if not login_data.tenant_slug:
from .dto import LoginChoiceResponseDTO, TenantInfoDTO
tenants = self._verify_credentials_and_list_tenants(
login_data.username, login_data.password
)
# Siempre devolver LoginChoiceResponseDTO; el frontend decide si
# auto-seleccionar (1 tenant) o mostrar selector (>1 tenants).
return LoginChoiceResponseDTO(
tenants=[TenantInfoDTO(**t) for t in tenants]
)
try:
# Verificar que el tenant existe
tenant_service = TenantService(self.db)
user_tenant_service = UserTenantService(self.db)
tenant = tenant_service.get_tenant_by_slug(login_data.tenant_slug)
if not tenant or not tenant.is_active:
raise HTTPException(status_code=401, detail="Invalid credentials")
# Crear nueva instancia de KeycloakOpenID con el realm del tenant
keycloak_client = KeycloakOpenID(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
client_id=settings.KEYCLOAK_CLIENT_ID,
realm_name=tenant.keycloak_realm,
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
)
# PASO 1: Primero actualizamos los atributos del usuario ANTES de autenticar
# Esto es necesario para que los Protocol Mappers incluyan los valores correctos
# en el token que se generará a continuación
# Para obtener el user_id, necesitamos hacer una autenticación temporal
# o buscar el usuario por username
try:
keycloak_admin = KeycloakAdmin(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
username=settings.KEYCLOAK_ADMIN_USERNAME,
password=settings.KEYCLOAK_ADMIN_PASSWORD,
realm_name=tenant.keycloak_realm,
user_realm_name="master",
verify=True,
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/login",
json=login_data.model_dump()
)
# Buscar usuario por username
users = keycloak_admin.get_users({"username": login_data.username})
if users and len(users) > 0:
user_id = users[0]["id"]
# Verificar si el usuario tiene acceso a este tenant
has_access = user_tenant_service.user_has_access_to_tenant(
user_id, tenant.id
if response.status_code == 200:
data = response.json()
# Si el Hub devolvió una lista de tenants (hubo login exitoso pero falta seleccionar tenant)
if "tenants" in data:
from .dto import LoginChoiceResponseDTO, TenantInfoDTO
return LoginChoiceResponseDTO(
tenants=[TenantInfoDTO(**t) for t in data["tenants"]]
)
if not has_access:
logger.warning(
f"User {user_id} tried to access tenant {tenant.id} without permission"
)
raise HTTPException(
status_code=401,
detail="Invalid credentials",
)
# Si devolvió tokens
# AUDIT LOG: Login Success
try:
from api.v1.modules.a76.audit_log.services.service import AuditService
AuditService.log_login(
db=self.db,
username=login_data.username,
ip_address=ip_address,
user_agent=user_agent
)
except Exception as e:
logger.error(f"Failed to audit login: {e}")
# Obtener los datos actuales del usuario
current_user = keycloak_admin.get_user(user_id)
current_attributes = current_user.get("attributes", {})
# Actualizar los atributos de tenant
current_attributes["tenant_id"] = [str(tenant.id)]
current_attributes["tenant_slug"] = [tenant.slug]
# Actualizar el usuario con los nuevos atributos
update_payload = {
"email": current_user.get("email"),
"firstName": current_user.get("firstName"),
"lastName": current_user.get("lastName"),
"enabled": current_user.get("enabled", True),
"emailVerified": current_user.get("emailVerified", False),
"attributes": current_attributes,
}
keycloak_admin.update_user(user_id=user_id, payload=update_payload)
except KeycloakError as e:
logger.warning(f"Could not pre-update user attributes: {str(e)}")
# Continuamos con el login aunque falle la actualización
except HTTPException:
raise # Re-lanzamos las excepciones HTTP (como acceso denegado)
except Exception as e:
logger.warning(f"Error pre-updating user attributes: {str(e)}")
# PASO 2: Ahora autenticamos al usuario
token_response = keycloak_client.token(
username=login_data.username,
password=login_data.password,
grant_type=["password"],
)
return TokenResponseDTO(**data)
# AUDIT LOG: Login Success
# Pasar el mensaje de error real del Hub al cliente
try:
from api.v1.modules.a76.audit_log.services.service import AuditService
AuditService.log_login(
db=self.db,
username=login_data.username,
ip_address=ip_address,
user_agent=user_agent
)
except Exception as e:
logger.error(f"Failed to audit login: {e}")
hub_detail = response.json().get("detail", None)
except Exception:
hub_detail = None
return TokenResponseDTO(
access_token=token_response["access_token"],
refresh_token=token_response["refresh_token"],
token_type="bearer",
expires_in=token_response["expires_in"],
)
if response.status_code == 401:
raise HTTPException(status_code=401, detail=hub_detail or "Credenciales inválidas")
except KeycloakError as e:
logger.warning(f"Keycloak authentication failed: {str(e)}")
raise HTTPException(status_code=401, detail="Invalid credentials")
logger.error(f"Hub login failed with status {response.status_code}: {response.text}")
raise HTTPException(status_code=response.status_code, detail=hub_detail or "Error en el servidor de autenticación")
except httpx.HTTPError as e:
logger.error(f"Hub unreachable during login: {str(e)}")
raise HTTPException(status_code=503, detail="Authentication service unavailable")
except HTTPException:
raise
except Exception as e:
logger.error(f"Login error: {str(e)}")
logger.error(f"Unexpected login error: {str(e)}")
raise HTTPException(status_code=500, detail="Authentication error")
def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO:
async def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO:
"""
Refresca el access token usando refresh token
Args:
refresh_data: Refresh token
Returns:
TokenResponseDTO con nuevos tokens
Refresca el access token usando el Hub
"""
try:
token_response = self.keycloak_openid.refresh_token(
refresh_data.refresh_token
)
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/refresh",
json=refresh_data.model_dump()
)
return TokenResponseDTO(
access_token=token_response["access_token"],
refresh_token=token_response["refresh_token"],
token_type="bearer",
expires_in=token_response["expires_in"],
)
if response.status_code == 200:
return TokenResponseDTO(**response.json())
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
except KeycloakError as e:
logger.warning(f"Token refresh failed: {str(e)}")
raise HTTPException(
status_code=401, detail="Invalid or expired refresh token"
)
except Exception as e:
logger.error(f"Token refresh error: {str(e)}")
raise HTTPException(status_code=500, detail="Token refresh error")
def get_user_info(self, access_token: str) -> UserInfoResponseDTO:
async def get_user_info(self, access_token: str) -> UserInfoResponseDTO:
"""
Obtiene información del usuario desde el token
Obtiene información del usuario desde el Hub
"""
from core.security import verify_token
# Aprovechamos la verificación (y cache) de security.py
user_info = await verify_token(access_token)
return UserInfoResponseDTO(**user_info)
Args:
access_token: Access token JWT
Returns:
UserInfoResponseDTO con información del usuario
async def logout(self, logout_data: LogoutRequestDTO) -> dict:
"""
Cierra sesión a través del Hub
"""
try:
user_info = self.keycloak_openid.userinfo(access_token)
# Extraer roles
roles = []
if "realm_access" in user_info:
roles = user_info["realm_access"].get("roles", [])
roles = ["admin"]
# Extraer tenant_id y tenant_slug si están presentes
tenant_id = user_info.get("tenant_id")
if not tenant_id and "attributes" in user_info:
tenant_id = user_info["attributes"].get("tenant_id")
tenant_slug = user_info.get("tenant_slug")
if not tenant_slug and "attributes" in user_info:
tenant_slug = user_info["attributes"].get("tenant_slug")
# Puede venir como lista de Keycloak attributes
if isinstance(tenant_slug, list):
tenant_slug = tenant_slug[0] if tenant_slug else None
# Obtener permisos del usuario en todas las compañías permitidas
permissions = set()
user_sub = user_info.get("sub")
if user_sub:
from api.v1.modules.core.permissions.service import PermissionService
from api.v1.modules.core.permissions.models import UserCompanyRole
perm_service = PermissionService(self.db)
# Obtener todas las compañías a las que el usuario tiene acceso
user_roles = self.db.query(UserCompanyRole.company_id).filter(
UserCompanyRole.user_id == user_sub,
UserCompanyRole.is_active == True
).distinct().all()
# Unir los permisos de todas las compañías para alimentar la UI
for (cid,) in user_roles:
permissions.update(perm_service.get_user_permissions(user_sub, cid))
# 🛡️ MEJORA DEV: Si es admin de Keycloak O estamos en desarrollo y no tiene permisos locales aún.
# Esto evita el "lockout" cuando se reinicia el proyecto para todos los usuarios.
from core.config import settings
if "admin" in roles or (settings.ENVIRONMENT == "development" and not permissions):
try:
from api.v1.modules.core.permissions.registry import registry as perm_registry
# Asegurar que los permisos core estén registrados
from api.v1.modules.core.permissions import seed_v2
all_registered = [p.code for p in perm_registry.get_all()]
permissions.update(all_registered)
logger.info(f"God Mode (Dev): Otorgando {len(all_registered)} permisos al usuario {user_sub}")
except Exception as e:
logger.error(f"Error in God Mode bootstrap: {e}")
permissions = list(permissions)
return UserInfoResponseDTO(
sub=user_info.get("sub"),
email=user_info.get("email"),
name=user_info.get("name"),
preferred_username=user_info.get("preferred_username"),
tenant_id=int(tenant_id) if tenant_id else None,
tenant_slug=tenant_slug,
roles=roles,
permissions=permissions,
)
except KeycloakError as e:
logger.warning(f"Get user info failed: {str(e)}")
raise HTTPException(status_code=401, detail="Invalid token")
except Exception as e:
logger.error(f"Get user info error: {str(e)}")
raise HTTPException(status_code=500, detail="Error retrieving user info")
def logout(self, logout_data: LogoutRequestDTO) -> dict:
"""
Cierra sesión invalidando el refresh token
Args:
logout_data: Refresh token a invalidar
Returns:
Dict con mensaje de éxito
"""
try:
self.keycloak_openid.logout(logout_data.refresh_token)
async with httpx.AsyncClient(timeout=10.0) as client:
await client.post(
f"{settings.HUB_URL}api/v1/auth/logout",
json=logout_data.model_dump()
)
return {"message": "Logged out successfully"}
except KeycloakError as e:
logger.warning(f"Logout failed: {str(e)}")
# No lanzamos error aquí, el logout puede fallar si el token ya expiró
return {"message": "Logged out"}
except Exception as e:
logger.error(f"Logout error: {str(e)}")
raise HTTPException(status_code=500, detail="Logout error")
return {"message": "Logged out"}
def register(self, register_data: RegisterRequestDTO) -> RegisterResponseDTO:
async def register(self, register_data: Any) -> Any:
"""
Registra un nuevo usuario en Keycloak
Args:
register_data: Datos del usuario a registrar
Returns:
RegisterResponseDTO con información del usuario creado
Raises:
HTTPException: Si el registro falla
Registra un usuario a través del Hub
"""
try:
# Verificar que el tenant existe
from api.v1.modules.core.tenants.service import TenantService
tenant_service = TenantService(self.db)
tenant = tenant_service.get_tenant_by_slug(register_data.tenant_slug)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
if not tenant.is_active:
raise HTTPException(status_code=403, detail="Tenant is not active")
# Crear instancia de KeycloakAdmin para gestión de usuarios
keycloak_admin = KeycloakAdmin(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
username=settings.KEYCLOAK_ADMIN_USERNAME,
password=settings.KEYCLOAK_ADMIN_PASSWORD,
realm_name=tenant.keycloak_realm,
user_realm_name="master", # El admin suele estar en master realm
verify=True,
)
# Preparar datos del usuario para Keycloak
user_data = {
"username": register_data.username,
"email": register_data.email,
"firstName": register_data.first_name,
"lastName": register_data.last_name,
"enabled": True,
"emailVerified": False,
"credentials": [
{
"type": "password",
"value": register_data.password,
"temporary": False,
}
],
"attributes": {"tenant_id": str(tenant.id), "tenant_slug": tenant.slug},
}
# Crear usuario en Keycloak
user_id = keycloak_admin.create_user(user_data)
# Asignar rol por defecto (user) - opcional, solo si existe
try:
user_role = keycloak_admin.get_realm_role("user")
if user_role:
keycloak_admin.assign_realm_roles(user_id, [user_role])
except KeycloakError as e:
# El rol 'user' no existe, no es un error crítico
logger.warning(f"Could not assign 'user' role: {str(e)}")
# Agregar el usuario al tenant en la base de datos
try:
from api.v1.modules.core.user_tenant.service import UserTenantService
user_tenant_service = UserTenantService(self.db)
user_tenant_service.add_user_to_tenant(
keycloak_user_id=user_id,
tenant_id=tenant.id,
role="user", # Rol por defecto
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/register",
json=register_data.model_dump()
)
except Exception as e:
# Si falla, hacer rollback del usuario en Keycloak
logger.error(f"Failed to add user to tenant in database: {str(e)}")
try:
keycloak_admin.delete_user(user_id)
except Exception as e:
pass
raise HTTPException(
status_code=500, detail="Failed to register user in database"
)
return RegisterResponseDTO(
user_id=user_id,
username=register_data.username,
email=register_data.email,
message="User registered successfully",
)
except KeycloakError as e:
error_message = str(e)
logger.warning(f"Keycloak registration failed: {error_message}")
# Mensajes de error más específicos
if "User exists" in error_message or "409" in error_message:
raise HTTPException(
status_code=409, detail="Username or email already exists"
)
elif "Invalid" in error_message:
raise HTTPException(status_code=400, detail="Invalid user data")
else:
raise HTTPException(status_code=500, detail="Registration error")
except HTTPException:
raise
if response.status_code == 201:
return response.json()
raise HTTPException(status_code=response.status_code, detail=response.text)
except Exception as e:
logger.error(f"Registration error: {str(e)}")
raise HTTPException(status_code=500, detail="Registration error")
def exchange_code(self, exchange_data) -> TokenResponseDTO:
async def exchange_code(self, exchange_data: Any) -> TokenResponseDTO:
"""
Intercambia un authorization code por tokens
Este método se usa cuando el frontend recibe un código de autorización
después de un login con proveedor externo (Microsoft, Google, etc.)
a través de Keycloak.
Args:
exchange_data: Datos del código y redirect_uri
Returns:
TokenResponseDTO con access_token y refresh_token
Raises:
HTTPException: Si el código es inválido o expiró
Intercambia código por tokens a través del Hub
"""
try:
# Importar el DTO aquí para evitar referencias circulares
# Intercambiar código por tokens usando Keycloak
token_response = self.keycloak_openid.token(
grant_type="authorization_code",
code=exchange_data.code,
redirect_uri=exchange_data.redirect_uri,
)
# Si se proporciona tenant_slug, podríamos validar que el usuario pertenece a ese tenant
# Por ahora simplemente retornamos los tokens
if exchange_data.tenant_slug:
# Validar que el tenant existe y está activo
tenant_service = TenantService(self.db)
tenant = tenant_service.get_tenant_by_slug(exchange_data.tenant_slug)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
if not tenant.is_active:
raise HTTPException(status_code=403, detail="Tenant is not active")
# Opcional: Verificar que el usuario pertenece al tenant
# Esto depende de cómo manejes los tenants en tu aplicación
return TokenResponseDTO(
access_token=token_response["access_token"],
refresh_token=token_response["refresh_token"],
token_type=token_response.get("token_type", "bearer"),
expires_in=token_response.get("expires_in", 3600),
)
except KeycloakError as e:
error_message = str(e)
logger.warning(f"Code exchange failed: {error_message}")
if "invalid_grant" in error_message.lower():
raise HTTPException(
status_code=400, detail="Invalid or expired authorization code"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/exchange-code",
json=exchange_data.model_dump()
)
elif "invalid_client" in error_message.lower():
raise HTTPException(
status_code=401, detail="Invalid client credentials"
)
else:
raise HTTPException(status_code=500, detail="Token exchange error")
if response.status_code == 200:
return TokenResponseDTO(**response.json())
raise HTTPException(status_code=response.status_code, detail="Code exchange failed")
except Exception as e:
logger.error(f"Exchange code error: {str(e)}")
raise HTTPException(status_code=500, detail="Exchange code error")
async def switch_tenant(self, **kwargs) -> TokenResponseDTO:
"""
Cambia de tenant a través del Hub
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/switch-tenant",
json=kwargs
)
if response.status_code == 200:
return TokenResponseDTO(**response.json())
raise HTTPException(status_code=response.status_code, detail="Switch tenant failed")
except Exception as e:
logger.error(f"Switch tenant error: {str(e)}")
raise HTTPException(status_code=500, detail="Switch tenant error")
async def sso_exchange(self, relay_token: str) -> TokenResponseDTO:
"""
Canjea un relay token de un solo uso por KC tokens.
Llama al Hub backend (server-to-server), sin Bearer requerido en el Hub.
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/sso-exchange",
json={"relay_token": relay_token},
)
if response.status_code == 200:
data = response.json()
return TokenResponseDTO(
access_token=data["access_token"],
refresh_token=data["refresh_token"],
token_type=data.get("token_type", "bearer"),
expires_in=data.get("expires_in", 3600),
tenant_id=data.get("tenant_id"),
tenant_slug=data.get("tenant_slug"),
)
raise HTTPException(
status_code=response.status_code,
detail=response.json().get("detail", "SSO exchange failed"),
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Code exchange error: {str(e)}")
raise HTTPException(status_code=500, detail="Code exchange error")
def switch_tenant(
self,
keycloak_user_id: str,
keycloak_realm: str,
tenant_slug: str,
refresh_token: str,
) -> TokenResponseDTO:
"""
Cambia el tenant activo de un usuario autenticado sin requerir su contraseña.
Pasos:
1. Verifica que el tenant existe y está activo.
2. Verifica que el usuario tiene acceso a ese tenant.
3. Actualiza los atributos tenant_id/tenant_slug del usuario en Keycloak.
4. Usa el refresh_token para emitir nuevos tokens que ya contienen los atributos actualizados.
"""
from api.v1.modules.core.tenants.models import Tenant
tenant_service = TenantService(self.db)
user_tenant_service = UserTenantService(self.db)
tenant = tenant_service.get_tenant_by_slug(tenant_slug)
if not tenant or not tenant.is_active:
raise HTTPException(status_code=403, detail="Access denied")
# Verificar acceso
has_access = user_tenant_service.user_has_access_to_tenant(keycloak_user_id, tenant.id)
if not has_access:
raise HTTPException(status_code=403, detail="Access denied")
# Actualizar atributos en Keycloak antes de emitir el nuevo token
try:
keycloak_admin = KeycloakAdmin(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
username=settings.KEYCLOAK_ADMIN_USERNAME,
password=settings.KEYCLOAK_ADMIN_PASSWORD,
realm_name=keycloak_realm,
user_realm_name="master",
verify=True,
)
current_user = keycloak_admin.get_user(keycloak_user_id)
attrs = current_user.get("attributes", {})
attrs["tenant_id"] = [str(tenant.id)]
attrs["tenant_slug"] = [tenant.slug]
keycloak_admin.update_user(
user_id=keycloak_user_id,
payload={
"email": current_user.get("email"),
"firstName": current_user.get("firstName"),
"lastName": current_user.get("lastName"),
"enabled": current_user.get("enabled", True),
"emailVerified": current_user.get("emailVerified", False),
"attributes": attrs,
},
)
except KeycloakError as e:
logger.warning(f"switch_tenant: could not update user attributes: {e}")
raise HTTPException(status_code=500, detail="Could not update tenant attributes")
# Emitir nuevos tokens usando el refresh_token existente
keycloak_client = KeycloakOpenID(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
client_id=settings.KEYCLOAK_CLIENT_ID,
realm_name=keycloak_realm,
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
)
try:
token_response = keycloak_client.refresh_token(refresh_token)
except KeycloakError as e:
logger.warning(f"switch_tenant: token refresh failed: {e}")
raise HTTPException(status_code=401, detail="Token refresh failed; please log in again")
return TokenResponseDTO(
access_token=token_response["access_token"],
refresh_token=token_response["refresh_token"],
token_type="bearer",
expires_in=token_response["expires_in"],
)
def _verify_credentials_and_list_tenants(self, username: str, password: str) -> list:
"""
Verifica las credenciales del usuario contra Keycloak y, solo si son válidas,
devuelve la lista de tenants a los que tiene acceso.
Esto evita el oráculo de enumeración de usuarios del antiguo endpoint
/discover-tenants que no requería contraseña.
Args:
username: Nombre de usuario o email
password: Contraseña en texto plano
Returns:
Lista de dicts {id, name, slug} con los tenants del usuario
Raises:
HTTPException 401: Si las credenciales son inválidas
"""
from api.v1.modules.core.tenants.models import Tenant
from api.v1.modules.core.user_tenant.models import UserTenant
from sqlalchemy import and_
tenants = self.db.query(Tenant).filter(Tenant.is_active).all()
if not tenants:
raise HTTPException(status_code=401, detail="Invalid credentials")
realms: dict[str, list] = {}
for tenant in tenants:
realms.setdefault(tenant.keycloak_realm, []).append(tenant)
credentials_verified = False
matched_tenants = []
for realm_name, realm_tenants in realms.items():
try:
keycloak_admin = KeycloakAdmin(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
username=settings.KEYCLOAK_ADMIN_USERNAME,
password=settings.KEYCLOAK_ADMIN_PASSWORD,
realm_name=realm_name,
user_realm_name="master",
verify=True,
)
users = keycloak_admin.get_users({"username": username, "exact": True})
if not users:
users = keycloak_admin.get_users({"email": username, "exact": True})
if not users:
continue
keycloak_user_id = users[0]["id"]
# Verificar la contraseña contra este realm (una sola vez)
if not credentials_verified:
keycloak_client = KeycloakOpenID(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
client_id=settings.KEYCLOAK_CLIENT_ID,
realm_name=realm_name,
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
)
try:
keycloak_client.token(
username=username,
password=password,
grant_type=["password"],
)
credentials_verified = True
except KeycloakError:
# Contraseña incorrecta — no revelar que el usuario existe
raise HTTPException(status_code=401, detail="Invalid credentials")
# Recopilar tenants con acceso confirmado
for tenant in realm_tenants:
has_access = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.tenant_id == tenant.id,
UserTenant.is_active,
)
)
.first()
)
if has_access:
matched_tenants.append(
{"id": tenant.id, "name": tenant.name, "slug": tenant.slug}
)
except HTTPException:
raise
except Exception as e:
logger.warning(f"Could not query realm '{realm_name}' during credential check: {e}")
continue
if not credentials_verified:
raise HTTPException(status_code=401, detail="Invalid credentials")
return matched_tenants
def discover_user_tenants(self, username: str) -> list:
"""
[DEPRECATED] Usa _verify_credentials_and_list_tenants en su lugar.
Descubre los tenants activos a los que pertenece un usuario dado su username.
"""
from api.v1.modules.core.tenants.models import Tenant
from api.v1.modules.core.user_tenant.models import UserTenant
from sqlalchemy import and_
# 1. Obtener todos los tenants activos
tenants = self.db.query(Tenant).filter(Tenant.is_active).all()
if not tenants:
return []
# 2. Agrupar tenants por keycloak_realm para no repetir consultas admin
realms: dict[str, list] = {}
for tenant in tenants:
realms.setdefault(tenant.keycloak_realm, []).append(tenant)
matched_tenants = []
for realm_name, realm_tenants in realms.items():
try:
keycloak_admin = KeycloakAdmin(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
username=settings.KEYCLOAK_ADMIN_USERNAME,
password=settings.KEYCLOAK_ADMIN_PASSWORD,
realm_name=realm_name,
user_realm_name="master",
verify=True,
)
# Buscar por username exacto
users = keycloak_admin.get_users({"username": username, "exact": True})
if not users:
# Intentar por email
users = keycloak_admin.get_users({"email": username, "exact": True})
if not users:
continue
keycloak_user_id = users[0]["id"]
# 3. Para cada tenant en este realm, verificar UserTenant
for tenant in realm_tenants:
has_access = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.tenant_id == tenant.id,
UserTenant.is_active,
)
)
.first()
)
if has_access:
matched_tenants.append(
{"id": tenant.id, "name": tenant.name, "slug": tenant.slug}
)
except Exception as e:
logger.warning(
f"Could not query realm '{realm_name}' during tenant discovery: {e}"
)
continue
return matched_tenants
logger.error(f"SSO exchange error: {str(e)}")
raise HTTPException(status_code=500, detail="SSO exchange error")

View File

@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, 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 collect_user_role_names, get_current_user, validate_access_to_resource
from .dependencies import (
PermissionChecker,
RequirePermission,
@@ -64,11 +64,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
realm_roles = current_user.get("realm_access", {}).get("roles", [])
client_roles = []
for client in current_user.get("resource_access", {}).values():
client_roles.extend(client.get("roles", []))
is_keycloak_admin = "admin" in realm_roles or "admin" in client_roles
# 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)
# 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

View File

@@ -37,28 +37,22 @@ _AVATAR_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
@router.get("/stats", response_model=UserStatsDTO)
def get_user_statistics(
async def get_user_statistics(
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Obtiene estadísticas de usuarios del tenant actual
Muestra:
- Total de usuarios
- Usuarios activos e inactivos
- Límite de licencia
- Usuarios disponibles
- Porcentaje de uso
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id)
return service.get_user_stats()
return service.get_user_stats() # Este no es async en service.py
@router.get("/", response_model=UserListResponseDTO)
def list_users(
async def list_users(
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(20, ge=1, le=100, description="Tamaño de página"),
@@ -68,12 +62,10 @@ def list_users(
):
"""
Lista todos los usuarios del tenant con paginación
Se puede filtrar por término de búsqueda (busca en username, email, nombre)
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id)
result = service.get_tenant_users(page=page, page_size=page_size, search=search)
result = await service.get_tenant_users(page=page, page_size=page_size, search=search)
return result
@@ -124,7 +116,7 @@ def get_user_avatar_image(
@router.get("/{user_id}", response_model=UserResponseDTO)
def get_user(
async def get_user_detail(
user_id: str,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
@@ -132,34 +124,25 @@ def get_user(
):
"""
Obtiene información detallada de un usuario específico
El usuario debe pertenecer al tenant actual
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id)
return service.get_user(user_id)
return await service.get_user(user_id)
@router.post("/", response_model=UserResponseDTO, status_code=201)
def create_user(
async def create_new_user(
data: CreateUserRequestDTO,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Crea un nuevo usuario en Keycloak y lo asocia al tenant
Validaciones:
- Verifica que no se exceda el límite de usuarios de la licencia
- Verifica que el email y username sean únicos
- Crea el usuario con contraseña temporal
Nota: El tenant_id se obtiene automáticamente del servicio (del token del usuario actual)
Crea un nuevo usuario a través del Hub y lo asocia al tenant
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.create"])
service = UserService(db, tenant_id, company_id)
user = service.create_user(
user = await service.create_user(
email=data.email,
username=data.username,
first_name=data.first_name,
@@ -173,7 +156,7 @@ def create_user(
@router.put("/{user_id}", response_model=UserResponseDTO)
def update_user(
async def update_user_detail(
user_id: str,
data: UpdateUserRequestDTO,
company_id: int = Query(..., description="Company ID"),
@@ -182,17 +165,10 @@ def update_user(
):
"""
Actualiza información de un usuario
Puede actualizar:
- Datos personales (nombre, apellido, email)
- Estado (habilitado/deshabilitado)
- Verificación de email
- Rol en el tenant
- Perfil (avatar, teléfono, bio, preferencias)
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.update"])
service = UserService(db, tenant_id, company_id)
user = service.update_user(
user = await service.update_user(
user_id=user_id,
first_name=data.first_name,
last_name=data.last_name,
@@ -209,7 +185,7 @@ def update_user(
@router.delete("/{user_id}")
def delete_user(
async def delete_user_route(
user_id: str,
company_id: int = Query(..., description="Company ID"),
soft_delete: bool = Query(
@@ -221,18 +197,15 @@ def delete_user(
):
"""
Elimina un usuario del tenant
- soft_delete=True: Solo desactiva la relación (recomendado)
- soft_delete=False: Elimina permanentemente de Keycloak
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.delete"])
service = UserService(db, tenant_id, company_id)
service.delete_user(user_id, soft_delete=soft_delete)
await service.delete_user(user_id, soft_delete=soft_delete)
return {"message": "User deleted successfully"}
@router.post("/{user_id}/change-password")
def change_user_password(
async def change_user_password(
user_id: str,
data: ChangePasswordRequestDTO,
company_id: int = Query(..., description="Company ID"),
@@ -240,14 +213,11 @@ def change_user_password(
current_user: dict = Depends(get_current_user),
):
"""
Cambia la contraseña de un usuario
- temporary=True: Usuario debe cambiar la contraseña en el próximo login
- temporary=False: Contraseña permanente
Cambia la contraseña de un usuario a través del Hub
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.update"])
service = UserService(db, tenant_id, company_id)
service.change_password(user_id, data.password, data.temporary)
await service.change_password(user_id, data.password, data.temporary)
return {"message": "Password changed successfully"}
@@ -255,13 +225,12 @@ def change_user_password(
@router.get("/me/profile", response_model=UserResponseDTO)
def get_my_profile(
async def get_my_profile(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Obtiene el perfil completo del usuario actual
Incluye datos de Keycloak y datos de perfil (avatar, bio, etc.)
"""
keycloak_user_id = current_user.get("sub")
if not keycloak_user_id:
@@ -283,21 +252,17 @@ def get_my_profile(
)
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
return service.get_current_user_profile(keycloak_user_id)
return await service.get_current_user_profile(keycloak_user_id)
@router.put("/me/profile", response_model=UserResponseDTO)
def update_my_profile(
async def update_my_profile(
data: UpdateUserRequestDTO,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Actualiza el perfil del usuario actual
Puede actualizar:
- Datos de Keycloak: nombre, apellido, email
- Datos de perfil: avatar, teléfono, biografía, preferencias
"""
keycloak_user_id = current_user.get("sub")
if not keycloak_user_id:
@@ -319,7 +284,7 @@ def update_my_profile(
)
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
return service.update_current_user_profile(
return await service.update_current_user_profile(
keycloak_user_id=keycloak_user_id,
first_name=data.first_name,
last_name=data.last_name,

View File

@@ -1,13 +1,9 @@
"""
Servicio para gestionar usuarios de Keycloak con validación de licencias
"""
import logging
import httpx
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import HTTPException
from keycloak import KeycloakAdmin, KeycloakError
from sqlalchemy import and_, func
from sqlalchemy.orm import Session
@@ -19,24 +15,22 @@ from ..user_tenant.models import UserTenant
logger = logging.getLogger(__name__)
def _normalize_keycloak_user(
def _normalize_user(
user_data: Dict[str, Any],
role: Optional[str] = None,
user_tenant: Optional[Any] = None,
) -> Dict[str, Any]:
"""
Normaliza los datos de usuario de Keycloak al formato esperado por el DTO
Keycloak usa camelCase, nuestro DTO usa snake_case
Normaliza los datos de usuario al formato esperado por el DTO
"""
normalized = {
"id": user_data.get("id"),
"username": user_data.get("username", ""),
"id": user_data.get("id") or user_data.get("sub"),
"username": user_data.get("username") or user_data.get("preferred_username", ""),
"email": user_data.get("email", ""),
"first_name": user_data.get("firstName", ""),
"last_name": user_data.get("lastName", ""),
"enabled": user_data.get("enabled", False),
"email_verified": user_data.get("emailVerified", False),
"first_name": user_data.get("firstName") or user_data.get("name", "").split(" ")[0],
"last_name": user_data.get("lastName") or (" ".join(user_data.get("name", "").split(" ")[1:]) if " " in user_data.get("name", "") else ""),
"enabled": user_data.get("enabled", True),
"email_verified": user_data.get("emailVerified") or user_data.get("email_verified", False),
"created_timestamp": user_data.get("createdTimestamp"),
"role": role,
}
@@ -63,22 +57,13 @@ def _normalize_keycloak_user(
class UserService:
"""Servicio para gestionar usuarios en Keycloak"""
"""Servicio para gestionar usuarios vía Hub"""
def __init__(self, db: Session, tenant_id: int, company_id: int = None):
def __init__(self, db: Session, tenant_id: int = None, company_id: int = None):
self.db = db
self.tenant_id = tenant_id
self.company_id = company_id
# Inicializar cliente admin de Keycloak
self.keycloak_admin = KeycloakAdmin(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
username=settings.KEYCLOAK_ADMIN_USERNAME,
password=settings.KEYCLOAK_ADMIN_PASSWORD,
realm_name=settings.KEYCLOAK_REALM,
verify=True,
)
def _get_license(self) -> License:
"""Obtiene la licencia del tenant actual"""
license = (
@@ -126,7 +111,7 @@ class UserService:
f"Currently active: {active_users}. Please upgrade your license.",
)
def create_user(
async def create_user(
self,
email: str,
username: str,
@@ -138,74 +123,44 @@ class UserService:
email_verified: bool = False,
) -> Dict[str, Any]:
"""
Crea un nuevo usuario en Keycloak y lo asocia al tenant
Args:
email: Email del usuario
username: Nombre de usuario
first_name: Nombre
last_name: Apellido
password: Contraseña inicial
role: Rol en el tenant
enabled: Si el usuario está habilitado
email_verified: Si el email está verificado
Returns:
Información del usuario creado
Raises:
HTTPException: Si se alcanza el límite de usuarios o falla la creación
Crea un nuevo usuario a través del Hub y lo asocia localmente
"""
# Verificar límite de usuarios
self._check_user_limit()
try:
# Crear usuario en Keycloak
new_user = {
"email": email,
"username": username,
"enabled": enabled,
"emailVerified": email_verified,
"firstName": first_name,
"lastName": last_name,
"attributes": {
"tenant_id": [
str(self.tenant_id)
] # Atributo requerido por Keycloak
},
"credentials": [
{
"type": "password",
"value": password,
"temporary": True, # Usuario debe cambiar en primer login
# Mandar al Hub para creación en Keycloak
async with httpx.AsyncClient(timeout=10.0) as client:
hub_response = await client.post(
f"{settings.HUB_URL}api/v1/auth/register",
json={
"email": email,
"username": username,
"first_name": first_name,
"last_name": last_name,
"password": password,
"tenant_slug": "default", # TODO: Get real slug if needed
}
],
}
)
if hub_response.status_code != 201:
logger.error(f"Hub registration failed: {hub_response.text}")
raise HTTPException(status_code=hub_response.status_code, detail="Failed to create user in Hub")
user_id = self.keycloak_admin.create_user(new_user)
logger.info(f"User created in Keycloak: {user_id}")
user_data = hub_response.json()
user_id = user_data.get("user_id")
# Obtener company_id si no se proporcionó
if not self.company_id:
# Obtener la primera company del tenant
from api.v1.modules.a76.general_catalogs.company.models import Company
company = (
self.db.query(Company)
.filter(Company.tenant_id == self.tenant_id)
.first()
)
company = self.db.query(Company).filter(Company.tenant_id == self.tenant_id).first()
if not company:
raise HTTPException(
status_code=400,
detail="No company found for this tenant. Please create a company first.",
)
raise HTTPException(status_code=400, detail="No company found")
company_id = company.id
else:
company_id = self.company_id
# Crear relación con el tenant
# Crear relación local
user_tenant = UserTenant(
keycloak_user_id=user_id,
tenant_id=self.tenant_id,
@@ -216,36 +171,16 @@ class UserService:
self.db.add(user_tenant)
self.db.commit()
# Obtener información completa del usuario
user_info = self.keycloak_admin.get_user(user_id)
return _normalize_user(user_data, role, user_tenant)
return _normalize_keycloak_user(user_info, role, user_tenant)
except KeycloakError as e:
logger.error(f"Keycloak error creating user: {str(e)}")
self.db.rollback()
# Manejar errores específicos
if "User exists with same email" in str(e):
raise HTTPException(
status_code=409, detail="A user with this email already exists"
)
elif "User exists with same username" in str(e):
raise HTTPException(
status_code=409, detail="A user with this username already exists"
)
raise HTTPException(
status_code=500, detail=f"Error creating user in Keycloak: {str(e)}"
)
except Exception as e:
logger.error(f"Unexpected error creating user: {str(e)}")
logger.error(f"Error creating user: {str(e)}")
self.db.rollback()
raise HTTPException(
status_code=500, detail=f"Error creating user: {str(e)}"
)
if isinstance(e, HTTPException):
raise e
raise HTTPException(status_code=500, detail=str(e))
def get_tenant_users(
async def get_tenant_users(
self, page: int = 1, page_size: int = 20, search: Optional[str] = None
) -> Dict[str, Any]:
"""
@@ -295,41 +230,18 @@ class UserService:
user_roles_map[user_role.user_id] = []
user_roles_map[user_role.user_id].append(user_role.company_role.name)
# Obtener información de Keycloak para cada usuario
users = []
for ut in user_tenants:
try:
user_info = self.keycloak_admin.get_user(ut.keycloak_user_id)
# Obtener roles del usuario
roles = user_roles_map.get(ut.keycloak_user_id, [])
role_str = ", ".join(roles) if roles else None
normalized_user = _normalize_keycloak_user(user_info, role_str, ut)
# En lugar de consultar Keycloak uno a uno (lento y sin API directa ahora),
# devolvemos la info local mínima o consultamos un endpoint de "buscar varios" en el Hub si existiera.
# Por ahora, minimizamos el impacto devolviendo lo que tenemos local.
normalized_user = _normalize_user({
"id": ut.keycloak_user_id,
"username": "User", # Placeholder si no tenemos el dato local
}, role_str, ut)
# Filtrar por búsqueda si se proporciona
if search:
search_lower = search.lower()
if (
search_lower in normalized_user.get("username", "").lower()
or search_lower in normalized_user.get("email", "").lower()
or search_lower
in normalized_user.get("first_name", "").lower()
or search_lower
in normalized_user.get("last_name", "").lower()
or search_lower
in normalized_user.get("phone", "").lower()
or search_lower
in normalized_user.get("bio", "").lower()
):
users.append(normalized_user)
else:
users.append(normalized_user)
except KeycloakError as e:
logger.warning(
f"Could not fetch user {ut.keycloak_user_id} from Keycloak: {str(e)}"
)
users.append(normalized_user)
except Exception as e:
logger.warning(f"Error processing user {ut.keycloak_user_id}: {e}")
continue
total_pages = (total + page_size - 1) // page_size
@@ -348,31 +260,24 @@ class UserService:
status_code=500, detail=f"Error getting users: {str(e)}"
)
def get_user(self, user_id: str) -> Dict[str, Any]:
"""Obtiene un usuario específico del tenant"""
async def get_user(self, user_id: str) -> Dict[str, Any]:
"""Obtiene un usuario específico"""
from ..permissions.models import UserCompanyRole
from sqlalchemy.orm import joinedload
# Verificar que el usuario pertenece al tenant
user_tenant = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == user_id,
UserTenant.tenant_id == self.tenant_id,
UserTenant.is_active == True,
)
user_tenant = self.db.query(UserTenant).filter(
and_(
UserTenant.keycloak_user_id == user_id,
UserTenant.tenant_id == self.tenant_id,
UserTenant.is_active == True,
)
.first()
)
).first()
if not user_tenant:
raise HTTPException(status_code=404, detail="User not found in this tenant")
raise HTTPException(status_code=404, detail="User not found")
# Obtener roles del usuario en la compañía actual
user_roles = self.db.query(UserCompanyRole).options(
joinedload(UserCompanyRole.company_role)
).filter(
# Roles locales
user_roles = self.db.query(UserCompanyRole).options(joinedload(UserCompanyRole.company_role)).filter(
and_(
UserCompanyRole.user_id == user_id,
UserCompanyRole.company_id == self.company_id,
@@ -380,165 +285,58 @@ class UserService:
UserCompanyRole.is_active == True
)
).all()
roles = [ur.company_role.name for ur in user_roles]
role_str = ", ".join(roles) if roles else None
try:
user_info = self.keycloak_admin.get_user(user_id)
return _normalize_keycloak_user(user_info, role_str, user_tenant)
except KeycloakError as e:
logger.error(f"Error getting user from Keycloak: {str(e)}")
raise HTTPException(status_code=404, detail="User not found in Keycloak")
# TODO: Call Hub if more info is needed
return _normalize_user({"id": user_id}, role_str, user_tenant)
def update_user(
self,
user_id: str,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
email: Optional[str] = None,
enabled: Optional[bool] = None,
email_verified: Optional[bool] = None,
role: Optional[str] = None,
avatar_url: Optional[str] = None,
phone: Optional[str] = None,
bio: Optional[str] = None,
preferences: Optional[dict] = None,
) -> Dict[str, Any]:
"""Actualiza información de un usuario"""
# Verificar que el usuario pertenece al tenant
user_tenant = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == user_id,
UserTenant.tenant_id == self.tenant_id,
)
)
.first()
)
async def update_user(self, user_id: str, **kwargs) -> Dict[str, Any]:
"""Actualiza información local del usuario (e identidad vía Hub si se implementa)"""
user_tenant = self.db.query(UserTenant).filter(
and_(UserTenant.keycloak_user_id == user_id, UserTenant.tenant_id == self.tenant_id)
).first()
if not user_tenant:
raise HTTPException(status_code=404, detail="User not found in this tenant")
raise HTTPException(status_code=404, detail="User not found")
try:
# Preparar datos de actualización para Keycloak
update_data = {}
if first_name is not None:
update_data["firstName"] = first_name
if last_name is not None:
update_data["lastName"] = last_name
if email is not None:
update_data["email"] = email
if enabled is not None:
update_data["enabled"] = enabled
if email_verified is not None:
update_data["emailVerified"] = email_verified
# Actualizar campos locales
for field in ["role", "avatar_url", "phone", "bio", "preferences"]:
if field in kwargs and kwargs[field] is not None:
setattr(user_tenant, field, kwargs[field])
# Actualizar en Keycloak si hay cambios
if update_data:
self.keycloak_admin.update_user(user_id, update_data)
self.db.commit()
self.db.refresh(user_tenant)
return _normalize_user({"id": user_id}, user_tenant.role, user_tenant)
# Actualizar campos en UserTenant
if role is not None:
user_tenant.role = role
if avatar_url is not None and not str(avatar_url).startswith(
"/api/v1/core/users/avatar/"
):
user_tenant.avatar_url = avatar_url
if phone is not None:
user_tenant.phone = phone
if bio is not None:
user_tenant.bio = bio
if preferences is not None:
user_tenant.preferences = preferences
async def delete_user(self, user_id: str, soft_delete: bool = True) -> None:
"""Elimina/Desactiva usuario"""
user_tenant = self.db.query(UserTenant).filter(
and_(UserTenant.keycloak_user_id == user_id, UserTenant.tenant_id == self.tenant_id)
).first()
if not user_tenant:
raise HTTPException(status_code=404, detail="User not found")
if soft_delete:
user_tenant.is_active = False
self.db.commit()
else:
# TODO: Call Hub to delete from Keycloak
self.db.delete(user_tenant)
self.db.commit()
self.db.refresh(user_tenant)
# Obtener información actualizada
user_info = self.keycloak_admin.get_user(user_id)
return _normalize_keycloak_user(user_info, user_tenant.role, user_tenant)
except KeycloakError as e:
logger.error(f"Error updating user in Keycloak: {str(e)}")
self.db.rollback()
raise HTTPException(
status_code=500, detail=f"Error updating user: {str(e)}"
)
def delete_user(self, user_id: str, soft_delete: bool = True) -> None:
"""
Elimina un usuario del tenant
Args:
user_id: ID del usuario en Keycloak
soft_delete: Si es True, solo desactiva. Si es False, elimina de Keycloak
"""
# Verificar que el usuario pertenece al tenant
user_tenant = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == user_id,
UserTenant.tenant_id == self.tenant_id,
)
)
.first()
)
if not user_tenant:
raise HTTPException(status_code=404, detail="User not found in this tenant")
async def change_password(self, user_id: str, password: str, temporary: bool = True) -> None:
"""Cambia contraseña vía Hub"""
try:
if soft_delete:
# Solo desactivar la relación
user_tenant.is_active = False
self.db.commit()
else:
# Eliminar permanentemente de Keycloak
self.keycloak_admin.delete_user(user_id)
# Eliminar relación
self.db.delete(user_tenant)
self.db.commit()
except KeycloakError as e:
logger.error(f"Error deleting user from Keycloak: {str(e)}")
self.db.rollback()
raise HTTPException(
status_code=500, detail=f"Error deleting user: {str(e)}"
)
def change_password(
self, user_id: str, password: str, temporary: bool = True
) -> None:
"""Cambia la contraseña de un usuario"""
# Verificar que el usuario pertenece al tenant
user_tenant = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == user_id,
UserTenant.tenant_id == self.tenant_id,
UserTenant.is_active == True,
async with httpx.AsyncClient(timeout=10.0) as client:
await client.post(
f"{settings.HUB_URL}api/v1/auth/change-password",
json={"user_id": user_id, "password": password, "temporary": temporary}
)
)
.first()
)
if not user_tenant:
raise HTTPException(status_code=404, detail="User not found in this tenant")
try:
self.keycloak_admin.set_user_password(
user_id, password, temporary=temporary
)
except KeycloakError as e:
logger.error(f"Error changing user password: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error changing password: {str(e)}"
)
except Exception as e:
logger.error(f"Error changing password: {e}")
raise HTTPException(status_code=500, detail="Error changing password")
def get_user_stats(self) -> Dict[str, Any]:
"""Obtiene estadísticas de usuarios del tenant"""
@@ -582,95 +380,19 @@ class UserService:
"usage_percentage": round(usage_percentage, 2),
}
def get_current_user_profile(self, keycloak_user_id: str) -> Dict[str, Any]:
"""
Obtiene el perfil completo del usuario actual
Combina datos de Keycloak con datos de UserTenant
"""
user_tenant = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.is_active == True,
)
)
.first()
)
async def get_current_user_profile(self, keycloak_user_id: str) -> Dict[str, Any]:
"""Obtiene el perfil completo del usuario actual"""
# Reutilizamos verify_token para obtener info del Hub
from core.security import verify_token
user_info = await verify_token(keycloak_user_id) # keycloak_user_id es el token en este contexto, o el ID
# Nota: en routes.py se pasa el ID. Si necesitamos info real, pedimos al Hub.
user_tenant = self.db.query(UserTenant).filter(
and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.is_active == True)
).first()
if not user_tenant:
raise HTTPException(status_code=404, detail="User profile not found")
return _normalize_user(user_info, user_tenant.role if user_tenant else None, user_tenant)
try:
user_info = self.keycloak_admin.get_user(keycloak_user_id)
return _normalize_keycloak_user(user_info, user_tenant.role, user_tenant)
except KeycloakError as e:
logger.error(f"Error getting user from Keycloak: {str(e)}")
raise HTTPException(status_code=404, detail="User not found")
def update_current_user_profile(
self,
keycloak_user_id: str,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
email: Optional[str] = None,
avatar_url: Optional[str] = None,
phone: Optional[str] = None,
bio: Optional[str] = None,
preferences: Optional[dict] = None,
) -> Dict[str, Any]:
"""
Actualiza el perfil del usuario actual
"""
user_tenant = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.is_active == True,
)
)
.first()
)
if not user_tenant:
raise HTTPException(status_code=404, detail="User profile not found")
try:
# Actualizar Keycloak
update_data = {}
if first_name is not None:
update_data["firstName"] = first_name
if last_name is not None:
update_data["lastName"] = last_name
if email is not None:
update_data["email"] = email
if update_data:
self.keycloak_admin.update_user(keycloak_user_id, update_data)
# Actualizar campos de perfil en UserTenant
if avatar_url is not None and not str(avatar_url).startswith(
"/api/v1/core/users/avatar/"
):
user_tenant.avatar_url = avatar_url
if phone is not None:
user_tenant.phone = phone
if bio is not None:
user_tenant.bio = bio
if preferences is not None:
user_tenant.preferences = preferences
self.db.commit()
self.db.refresh(user_tenant)
# Retornar perfil actualizado
user_info = self.keycloak_admin.get_user(keycloak_user_id)
return _normalize_keycloak_user(user_info, user_tenant.role, user_tenant)
except KeycloakError as e:
logger.error(f"Error updating user profile: {str(e)}")
self.db.rollback()
raise HTTPException(
status_code=500, detail=f"Error updating profile: {str(e)}"
)
async def update_current_user_profile(self, keycloak_user_id: str, **kwargs) -> Dict[str, Any]:
"""Actualiza el perfil del usuario actual"""
return await self.update_user(keycloak_user_id, **kwargs)

View File

@@ -24,15 +24,7 @@ class Settings(BaseSettings):
CORE_DB_USER: str = "postgres"
CORE_DB_PASSWORD: str = "postgres"
TEST_DATABASE_URL: str = "postgresql://postgres:postgres@localhost:5432/anexo76_core"
# Keycloak
KEYCLOAK_SERVER_URL: str = "http://localhost:8080/kcauth"
KEYCLOAK_REALM: str = "master"
KEYCLOAK_CLIENT_ID: str = "anexo76-backend"
KEYCLOAK_CLIENT_SECRET: str = ""
KEYCLOAK_ADMIN_USERNAME: str = "admin"
KEYCLOAK_ADMIN_PASSWORD: str = "admin"
# Security
SECRET_KEY: str = "change-this-secret-key-in-production"
@@ -47,9 +39,19 @@ class Settings(BaseSettings):
# CORS
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
# License
LICENSE_CHECK_ENABLED: bool = True
# Hub de Aduanasoft — requerido siempre (SaaS y self-hosted)
HUB_URL: str = "http://localhost:8001"
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", mode="before")
@classmethod
def strip_quotes(cls, v: str) -> str:
if v and isinstance(v, str):
v = v.strip().strip('"').strip("'")
if not v.endswith("/"):
v += "/"
return v
return v
# External APIs
SITAR_API_URL: str = "api.sitar.aduanasoft.com:880"
COVE_API_URL: str = "https://api.vu.aduanasoft.com"
@@ -85,13 +87,6 @@ class Settings(BaseSettings):
env_file_encoding="utf-8",
)
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", mode="before")
@classmethod
def strip_quotes(cls, v: str) -> str:
if v:
return v.strip().strip('"').strip("'")
return v
@property
def core_database_url(self) -> str:
"""URL de conexión a la base de datos core"""

View File

@@ -1,18 +1,42 @@
import logging
import time
import httpx
from datetime import datetime, timezone
from typing import Callable, Optional
from fastapi import Request
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from .config import settings
from .database import scoped_core_db
from .security import get_tenant_from_token, verify_token
logger = logging.getLogger(__name__)
def _normalize_text(value: str | None) -> str:
if not value:
return ""
return str(value).strip().lower()
def _is_token_issue_message(*values: str | None) -> bool:
text = " ".join(_normalize_text(v) for v in values if v)
if not text:
return False
token_markers = ["token", "jwt", "bearer", "access"]
invalid_markers = [
"invalido", "inválido", "invalid", "not valid", "malformed", "signature", "unauthorized"
]
expired_markers = ["expirado", "expirada", "expired", "has expired", "caducado", "vencido"]
has_token_context = any(marker in text for marker in token_markers)
has_invalid_marker = any(marker in text for marker in invalid_markers)
has_expired_marker = any(marker in text for marker in expired_markers)
return has_expired_marker or (has_token_context and has_invalid_marker)
def _extract_company_id(request: Request) -> Optional[int]:
"""Obtiene ``company_id`` activa desde header ``X-Company-Id`` o cookie.
@@ -29,8 +53,10 @@ def _extract_company_id(request: Request) -> Optional[int]:
except (TypeError, ValueError):
return None
class TenantMiddleware(BaseHTTPMiddleware):
"""
Middleware original para extraer tenant_id y user_info del token.
"""
async def dispatch(self, request: Request, call_next: Callable):
doc_prefixes = ["/api/redoc", "/api/openapi.json"]
public_prefixes = [
@@ -64,7 +90,7 @@ class TenantMiddleware(BaseHTTPMiddleware):
token = auth_header.split(" ")[1]
try:
user_info = verify_token(token)
user_info = await verify_token(token)
tenant_id = get_tenant_from_token(user_info)
request.state.tenant_id = tenant_id
@@ -85,107 +111,203 @@ class TenantMiddleware(BaseHTTPMiddleware):
class LicenseValidationMiddleware(BaseHTTPMiddleware):
"""Middleware para validar la licencia del tenant antes de procesar requests."""
"""
Middleware que valida la licencia contra el Hub de Aduanasoft.
El Hub siempre es requerido — tanto en SaaS como en self-hosted.
Fail-closed: si el Hub no responde o la licencia es inválida, se bloquea el acceso.
"""
async def dispatch(self, request: Request, call_next: Callable):
if not settings.LICENSE_CHECK_ENABLED or settings.ENVIRONMENT == "development":
return await call_next(request)
exempt_paths = [
"/api/docs",
"/api/redoc",
"/openapi.json",
"/api/v1/auth",
"/api/v1/auth",
"/api/v1/status",
"/api/v1/status",
"/api/health",
"/api/",
"/api/docs", "/api/redoc", "/openapi.json",
"/api/v1/auth", "/api/v1/status", "/api/health",
"/api/v1/core/help-center",
"/api/v1/core/users/avatar",
]
is_exempt = False
for path in exempt_paths:
if request.url.path == path or (
path != "/" and request.url.path.startswith(path)
):
is_exempt = True
break
is_exempt = any(
request.url.path == path or (path != "/" and request.url.path.startswith(path))
for path in exempt_paths
)
if is_exempt:
return await call_next(request)
tenant_id = getattr(request.state, "tenant_id", None)
if not tenant_id:
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
# Permitimos pasar para que TenantMiddleware maneje el 401
return await call_next(request)
token = auth_header.split(" ")[1]
tenant_override = request.headers.get("X-Tenant-Override")
if not tenant_override:
# Fallback para flujos SSO cuando el override no viaja en header.
tenant_override = request.cookies.get("sso_tenant_id") or request.cookies.get("sso_tenant_pub")
# TenantMiddleware (corre antes) ya resolvió el token y dejó tenant en user_info.
# Sin esto, Swagger/curl sin cookies SSO llaman verify-license sin contexto y el Hub
# puede devolver 401 aunque /auth/me con el mismo Bearer responda 200.
if not tenant_override:
user_info = getattr(request.state, "user_info", None)
if isinstance(user_info, dict):
tid = user_info.get("tenant_id")
if tid is not None and str(tid).strip() != "":
tenant_override = str(tid)
hub_headers = {"Authorization": f"Bearer {token}"}
if tenant_override:
hub_headers["X-Tenant-Override"] = str(tenant_override)
logger.info("[license] tenant override propagated to Hub: %s", tenant_override)
# core.licenses / core.license_usage están bajo RLS por tenant_id:
# se abre la sesión con contexto explícito para que LicenseService
# vea las filas del tenant actual.
try:
with scoped_core_db(tenant_id=tenant_id) as db:
from api.v1.modules.core.licenses.service import LicenseService
# Validación contra el Hub Central
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(
f"{settings.HUB_URL}api/v1/auth/verify-license",
headers=hub_headers
)
license_service = LicenseService(db)
license_info = license_service.validate_license(tenant_id)
logger.info(f"🔑 verify-license → status={response.status_code} body={response.text[:300]}")
if not license_info["is_valid"]:
if response.status_code == 404:
# Endpoint no existe en este Hub — dejar pasar
return await call_next(request)
if response.status_code == 200:
data = response.json()
# Escenario 1: sin licencia asignada o licencia inactiva
if not data.get("valid", False):
message = data.get("message", "Sin licencia asignada para este tenant")
detail = data.get("detail")
reason = data.get("reason")
# Si el Hub reporta token inválido/expirado, devolver 401 para que
# el frontend dispare el auto-refresh (solo se activa con 401/403, no 402).
if _is_token_issue_message(message, detail, reason):
logger.warning(
"[license] token expirado/invalido detectado por verify-license; devolviendo 401 para silent refresh | message=%s detail=%s reason=%s",
message,
detail,
reason,
)
return JSONResponse(
status_code=401,
content={
"error": "TOKEN_EXPIRED",
"message": message,
"status_code": 401,
}
)
logger.warning(
"[license] licencia invalida para tenant=%s | message=%s",
data.get("tenant_slug"),
message,
)
return JSONResponse(
status_code=402,
content={
"error": "HTTP_ERROR",
"message": f"License validation failed: {license_info['reason']}",
"error": "LICENSE_ERROR",
"message": message,
"status_code": 402,
}
)
request.state.license_info = license_info
except Exception as e:
logger.error(f"License validation error: {str(e)}")
# Escenario 2: licencia vencida (verificación local de expires_at)
expires_at_str = data.get("expires_at")
if expires_at_str:
try:
expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at < datetime.now(timezone.utc):
logger.warning(
"[license] licencia expirada para tenant=%s | expires_at=%s",
data.get("tenant_slug"),
expires_at_str,
)
return JSONResponse(
status_code=402,
content={
"error": "LICENSE_EXPIRED",
"message": f"La licencia venció el {expires_at.strftime('%d/%m/%Y')}. Renueva tu suscripción.",
"status_code": 402,
}
)
except (ValueError, TypeError):
pass # Si no se puede parsear, dejamos pasar — el Hub es la fuente de verdad
request.state.license_info = data
return await call_next(request) # <--- Único camino al éxito
elif response.status_code == 401:
logger.warning("[license] Hub verify-license devolvio 401 (token invalido/expirado)")
return JSONResponse(
status_code=401,
content={
"error": "TOKEN_EXPIRED",
"message": "Token inválido o expirado.",
"status_code": 401,
}
)
elif response.status_code == 403:
return JSONResponse(
status_code=403,
content={
"error": "FORBIDDEN",
"message": "El Tenant no tiene permisos en el Hub central.",
"status_code": 403,
}
)
else:
logger.error(f"Hub error status: {response.status_code}")
return JSONResponse(
status_code=503,
content={
"error": "HUB_ERROR",
"message": "Error en el servidor de licencias.",
"status_code": 503,
}
)
except (httpx.ConnectError, httpx.TimeoutException) as e:
logger.critical(f"❌ CRITICAL: Hub unreachable: {str(e)}")
return JSONResponse(
status_code=500,
status_code=503,
content={
"error": "HTTP_ERROR",
"message": "License validation error",
"status_code": 500,
"error": "HUB_OFFLINE",
"message": "Servicio de licencias fuera de línea. Acceso denegado.",
"status_code": 503,
}
)
return await call_next(request)
except Exception as e:
logger.error(f"Unexpected license error: {str(e)}")
return JSONResponse(
status_code=500,
content={"error": "VALIDATION_ERROR", "message": "Error interno de validación.", "status_code": 500}
)
class RequestLoggingMiddleware(BaseHTTPMiddleware):
"""Middleware para logging de requests."""
"""
Middleware original para logging de performance.
"""
async def dispatch(self, request: Request, call_next: Callable):
start_time = time.time()
excluded_paths = [
"/api/docs",
"/api/redoc",
"/openapi.json",
"/api/v1/status",
"/api/health",
]
if any(
request.url.path == path or request.url.path.startswith(path + "/")
for path in excluded_paths
):
excluded_paths = ["/api/docs", "/api/redoc", "/openapi.json", "/api/v1/status", "/api/health"]
if any(request.url.path == path or request.url.path.startswith(path + "/") for path in excluded_paths):
return await call_next(request)
logger.info(f"Request: {request.method} {request.url.path}")
response = await call_next(request)
process_time = time.time() - start_time
logger.info(
f"Response: {request.method} {request.url.path} "
f"Status: {response.status_code} "
f"Duration: {process_time:.3f}s"
)
response.headers["X-Process-Time"] = str(process_time)
return response
return response

View File

@@ -3,79 +3,205 @@ Utilidades de seguridad y autenticación con Keycloak
"""
import logging
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Set
from fastapi import Depends, HTTPException, Security
from fastapi import Depends, HTTPException, Request, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from keycloak import KeycloakOpenID
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__)
# Configuración de Keycloak
keycloak_openid = KeycloakOpenID(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
client_id=settings.KEYCLOAK_CLIENT_ID,
realm_name=settings.KEYCLOAK_REALM,
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
)
# 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()
def verify_token(token: str) -> Dict[str, Any]:
async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str, Any]:
"""
Verifica y decodifica un token JWT de Keycloak
Args:
token: Token JWT
Returns:
Payload del token decodificado
Raises:
HTTPException: Si el token es inválido
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:
# Obtener clave pública de Keycloak
KEYCLOAK_PUBLIC_KEY = (
"-----BEGIN PUBLIC KEY-----\n"
+ keycloak_openid.public_key()
+ "\n-----END PUBLIC KEY-----"
)
headers: Dict[str, str] = {"Authorization": f"Bearer {token}"}
if tenant_id_override:
headers["X-Tenant-Override"] = tenant_id_override
# Decodificar y verificar token
options = {"verify_signature": True, "verify_aud": False, "verify_exp": True}
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(
f"{settings.HUB_URL}api/v1/auth/me",
headers=headers
)
decoded_token = jwt.decode(
token, KEYCLOAK_PUBLIC_KEY, algorithms=["RS256"], options=options
)
return decoded_token
except JWTError as e:
logger.error(f"Token verification failed: {str(e)}")
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
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
user_info = verify_token(token)
# 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
@@ -103,13 +229,14 @@ def has_role(required_role: str):
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", [])
user_roles = collect_user_role_names(current_user)
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}")
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}",
@@ -141,6 +268,29 @@ def get_tenant_from_token(user_info: Dict[str, Any]) -> Optional[int]:
return None
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:
@@ -208,23 +358,9 @@ def validate_access_to_resource(
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)
# 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
# 🚪 EXCEPCIÓN ESPECIAL: Si es el endpoint /me, permitimos el paso para el Bootstrap

View File

@@ -61,15 +61,6 @@ async def on_startup():
logger.info("Base de datos inicializada correctamente.")
# Configurar CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Agregar middlewares personalizados
if settings.DEBUG:
app.add_middleware(RequestLoggingMiddleware)
@@ -78,6 +69,16 @@ app.add_middleware(LicenseValidationMiddleware)
app.add_middleware(TenantMiddleware)
app.add_middleware(UserContextMiddleware)
# CORS debe ser el último en añadirse para que sea el más externo
# y cubra todas las respuestas, incluyendo las de los middlewares internos
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Centraliza startup para evitar on_event() (deprecated en FastAPI)

View File

@@ -13,7 +13,7 @@ psycopg2-binary==2.9.11
asyncpg==0.30.0
# Authentication & Authorization
python-keycloak==5.8.1
cachetools==5.5.0
python-jose[cryptography]==3.5.0
passlib[bcrypt]==1.7.4

View File

@@ -0,0 +1,21 @@
from core.middleware import _is_token_issue_message
def test_is_token_issue_message_detects_expired_token_in_spanish():
assert _is_token_issue_message("Token inválido o expirado") is True
def test_is_token_issue_message_detects_expired_token_in_english():
assert _is_token_issue_message("Invalid or expired token") is True
def test_is_token_issue_message_detects_detail_reason_combo():
assert _is_token_issue_message(
"Access denied",
"jwt signature validation failed",
"token malformed",
) is True
def test_is_token_issue_message_does_not_flag_real_license_error():
assert _is_token_issue_message("Sin licencia asignada para este tenant") is False

View File

@@ -30,3 +30,12 @@ services:
volumes: !override
- backend_cache:/app/__pycache__
- backend_uploads:/app/uploads
# En Jenkins/agents no existe la red externa de aduanasoft-hub (desarrollo local).
# Sin esto, `docker compose up` falla: "aduanasoft-hub_default ... could not be found".
# Red bridge propia por proyecto (COMPOSE_PROJECT_NAME) para E2E/CI en paralelo.
networks:
hub-net:
driver: bridge
external: false
name: ${COMPOSE_PROJECT_NAME:-anexo76-ci}_hub_net

View File

@@ -34,122 +34,6 @@ services:
memory: 256M
shm_size: 128mb
# PostgreSQL - Base de datos Keycloak
postgres-keycloak:
image: postgres:18-alpine
container_name: anexo76-postgres-keycloak
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_KEYCLOAK_PASSWORD:-postgres}
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
ports:
- "5433:5432"
volumes:
- postgres_keycloak_data:/var/lib/postgresql
networks:
- auth-net
- backend-net
restart: unless-stopped
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres -d keycloak || exit 1" ]
interval: 5s
timeout: 3s
retries: 10
start_period: 20s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
shm_size: 128mb
# Keycloak - Servidor de autenticación
keycloak:
image: quay.io/keycloak/keycloak:26.4
container_name: anexo76-keycloak
environment:
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
KC_DB: postgres
KC_DB_URL_HOST: postgres-keycloak
KC_DB_URL_PORT: "5432"
KC_DB_URL_DATABASE: keycloak
KC_DB_URL: jdbc:postgresql://postgres-keycloak:5432/keycloak
KC_DB_USERNAME: postgres
KC_DB_PASSWORD: ${POSTGRES_KEYCLOAK_PASSWORD:-postgres}
KC_DB_SCHEMA: public
KC_HOSTNAME: localhost
KC_HTTP_ENABLED: "true"
KC_HOSTNAME_STRICT: "false"
KC_HOSTNAME_STRICT_HTTPS: "false"
KC_PROXY_HEADERS: "xforwarded"
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
KC_HOSTNAME_PATH: /kcauth
KC_LOG_LEVEL: INFO
JAVA_OPTS_APPEND: "-Xms256m -Xmx512m -XX:MetaspaceSize=96M -XX:MaxMetaspaceSize=256m -Djava.net.preferIPv4Stack=true"
command:
- start-dev
- --http-relative-path=/kcauth
- --db=postgres
- --db-url-host=postgres-keycloak
- --db-url-port=5432
- --db-url-database=keycloak
- --db-username=postgres
- --db-password=${POSTGRES_KEYCLOAK_PASSWORD:-postgres}
- --http-enabled=true
- --hostname-strict=false
- --proxy-headers=xforwarded
# Host: KEYCLOAK_HTTP_PORT / KEYCLOAK_MANAGEMENT_PORT (CI, Jenkins) para no chocar con 8080/9000
ports:
- "${KEYCLOAK_HTTP_PORT:-8080}:8080"
- "${KEYCLOAK_MANAGEMENT_PORT:-9000}:9000"
depends_on:
postgres-keycloak:
condition: service_healthy
volumes:
- keycloak_data:/opt/keycloak/data
networks:
- auth-net
- backend-net
restart: unless-stopped
healthcheck:
test:
[
"CMD-SHELL",
"exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /kcauth/health/ready HTTP/1.1\r
Host: localhost\r
Connection: close\r
\r
' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1"
]
interval: 10s
timeout: 5s
retries: 30
start_period: 90s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
memory: 768M
reservations:
memory: 512M
# Backend - FastAPI
backend:
build:
@@ -169,10 +53,8 @@ services:
- CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core}
- CORE_DB_USER=${CORE_DB_USER:-postgres}
- CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres}
- KEYCLOAK_SERVER_URL=${KEYCLOAK_SERVER_URL:-http://keycloak:8080/kcauth}
- KEYCLOAK_REALM=${KEYCLOAK_REALM:-master}
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend}
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret}
# Keycloak — apunta al Keycloak del Hub (ya no tiene Keycloak propio)
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173,http://localhost:3000}
- SITAR_API_URL=${SITAR_API_URL}
- SITAR_API_USER=${SITAR_API_USER}
@@ -185,6 +67,8 @@ services:
- CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""}
- SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production}
- SPOKE_URLS=${SPOKE_URLS:-""}
# Hub — URL interna para validación de licencias
- HUB_URL=${HUB_URL:-http://host.docker.internal:8001}
- CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio}
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
- S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}}
@@ -199,10 +83,6 @@ services:
depends_on:
postgres-a76:
condition: service_healthy
keycloak:
condition: service_healthy
minio:
condition: service_healthy
volumes:
- ./backend:/app
- backend_cache:/app/__pycache__
@@ -210,6 +90,7 @@ services:
networks:
- backend-net
- frontend-net
- hub-net
restart: unless-stopped
command: [ "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--log-level", "info" ]
# El lifespan corre Alembic antes de servir; 1.ª subida a DB vacía puede tardar varios minutos (E2E/CI)
@@ -244,14 +125,20 @@ services:
- NODE_ENV=${NODE_ENV:-development}
- VITE_API_URL=${VITE_API_URL:-http://localhost:8000/api/}
- INTERNAL_API_URL=${INTERNAL_API_URL:-http://backend:8000/api/}
- VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL:-http://localhost:${KEYCLOAK_HTTP_PORT:-8080}/kcauth}
- VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL:-http://localhost:8085/kcauth}
- VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM:-master}
- VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID:-anexo76-frontend}
- KEYCLOAK_URL=${KEYCLOAK_URL:-http://keycloak:8080/kcauth}
# SSR server-side — usa URL interna del contenedor Keycloak (más rápido, sin salir a la LAN)
- KEYCLOAK_URL=${KEYCLOAK_URL:-http://hub-keycloak:8080/kcauth}
- KEYCLOAK_REALM=${KEYCLOAK_REALM:-master}
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend}
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-zRU5NuvUFtBSOuh7Kdc372AItoWGLgz9}
- VITE_HUB_MODE=${VITE_HUB_MODE:-true}
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-frontend}
# Hub — URL pública para el browser y URL interna para server-side
- VITE_HUB_URL=${VITE_HUB_URL:-http://localhost:8001}
- HUB_URL=${HUB_URL:-http://localhost:8001}
- INTERNAL_HUB_URL=${INTERNAL_HUB_URL:-http://host.docker.internal:8001}
# CORS / CSRF — trusted origins para svelte.config.js
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173,http://localhost:3001}
- TRUSTED_ORIGINS=${TRUSTED_ORIGINS:-}
ports:
- "5173:5173"
depends_on:
@@ -262,7 +149,7 @@ services:
- frontend_node_modules:/app/node_modules
networks:
- frontend-net
- auth-net
- hub-net
restart: unless-stopped
command: [ "pnpm", "run", "dev", "--", "--host", "0.0.0.0" ]
healthcheck:
@@ -277,7 +164,7 @@ services:
max-size: "10m"
max-file: "3"
# celery
# Celery Worker
celery_worker:
build: ./backend
container_name: worker
@@ -296,10 +183,6 @@ services:
- CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core}
- CORE_DB_USER=${CORE_DB_USER:-postgres}
- CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres}
- KEYCLOAK_SERVER_URL=${KEYCLOAK_SERVER_URL:-http://keycloak:8080/kcauth}
- KEYCLOAK_REALM=${KEYCLOAK_REALM:-master}
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend}
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret}
- VALKEY_URL=redis://valkey:6379/0
- SITAR_API_URL=${SITAR_API_URL}
- SITAR_API_USER=${SITAR_API_USER}
@@ -329,6 +212,7 @@ services:
networks:
- backend-net
# Celery Beat
celery_beat:
build: ./backend
container_name: celery_beat
@@ -410,10 +294,6 @@ services:
volumes:
postgres_app_data:
driver: local
postgres_keycloak_data:
driver: local
keycloak_data:
driver: local
frontend_node_modules:
driver: local
backend_cache:
@@ -426,7 +306,9 @@ volumes:
networks:
backend-net:
driver: bridge
auth-net:
driver: bridge
frontend-net:
driver: bridge
hub-net:
external: true
name: aduanasoft-hub_default

View File

@@ -7,7 +7,13 @@ declare global {
token: string | null;
isAuthenticated: boolean;
}
// interface PageData {}
interface PageData {
licenseError?: {
type: string;
message: string;
status: number;
};
}
// interface PageState {}
// interface Platform {}
}

View File

@@ -229,6 +229,18 @@ async function fetchApi<T = any>(
headers['Authorization'] = `Bearer ${token}`;
}
// Incluir tenant override para flujo SSO multi-tenant.
// sso_tenant_pub es una cookie no-HttpOnly que el servidor setea junto con sso_tenant_id.
if (browser) {
const tenantPub = document.cookie
.split('; ')
.find((c) => c.startsWith('sso_tenant_pub='))
?.split('=')[1];
if (tenantPub) {
headers['X-Tenant-Override'] = tenantPub;
}
}
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
@@ -236,24 +248,32 @@ async function fetchApi<T = any>(
credentials: 'include' // Importante: envía cookies con cada request
});
// Si recibimos 401 o 403 y no es el endpoint de refresh, intentar refrescar el token
if ((response.status === 401 || response.status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) {
// Si es 403 (Forbidden), mostrar toast de permisos insuficientes
if (response.status === 403) {
if (browser) {
toast.error('No tienes permisos para realizar esta acción', {
duration: 4000,
description: 'Contacta a tu administrador si crees que esto es un error'
});
}
// Retornar el error 403 sin intentar refresh
const data = await response.json();
return {
error: data.detail || 'No tienes permisos para realizar esta acción',
status: 403
};
// 403 = permisos, no autenticación: nunca intentar refresh.
if (response.status === 403 && !endpoint.includes('/auth/refresh') && retryCount === 0) {
if (browser) {
toast.error('No tienes permisos para realizar esta acción', {
duration: 4000,
description: 'Contacta a tu administrador si crees que esto es un error'
});
}
const data = await response.json();
return {
error: data.detail || 'No tienes permisos para realizar esta acción',
status: 403
};
}
// 402 = licencia inválida/expirada: no intentar refresh.
if (response.status === 402 && !endpoint.includes('/auth/refresh') && retryCount === 0) {
const data = await response.json().catch(() => ({}));
return {
error: data.message || data.detail || 'Licencia inválida o expirada',
status: 402
};
}
// Solo 401 dispara silent refresh.
if (response.status === 401 && !endpoint.includes('/auth/refresh') && retryCount === 0) {
// Si es 401, intentar refrescar el token
isRefreshing = true;
@@ -682,7 +702,16 @@ export const api = {
api.post('/v1/auth/refresh/', { refresh_token: refreshToken }),
logout: (data: { refresh_token: string, username?: string }) => api.post('/v1/auth/logout', data, { keepalive: true }),
me: () => api.get('/v1/auth/me/'),
health: () => api.get('/health')
health: () => api.get('/health'),
register: (data: {
username: string;
email: string;
password: string;
first_name: string;
last_name: string;
tenant_slug: string;
invite_token?: string;
}) => api.post('/v1/auth/register', data),
},
tenants: {

View File

@@ -405,18 +405,14 @@ export const logout = async () => {
deleteCookie('access_token');
// La cookie HttpOnly del refresh_token la limpia el servidor
// Logout de Keycloak JS si estaba autenticado con SSO
if (keycloakInstance?.authenticated) {
// Logout unificado (SSO y password): POST al logout route del servidor.
// Evita redirección visible al endpoint de Keycloak.
if (keycloakInstance) {
try {
await fetch('/logout', { method: 'POST' });
} catch { }
await keycloakInstance.logout({
redirectUri: window.location.origin + '/login'
});
return;
keycloakInstance.clearToken();
} catch {}
}
// Para login con password: POST al logout route del servidor
const form = document.createElement('form');
form.method = 'POST';
form.action = '/logout';

View File

@@ -124,11 +124,16 @@
</script>
<Sheet.Root bind:open={helpStore.isOpen}>
<Sheet.Trigger
class="fixed right-6 bottom-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-110 active:scale-95"
aria-label="Ayuda"
>
<HelpCircle size={28} />
<Sheet.Trigger>
{#snippet child({ props })}
<button
{...props}
class="fixed right-6 bottom-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-110 active:scale-95"
aria-label="Ayuda"
>
<HelpCircle size={28} />
</button>
{/snippet}
</Sheet.Trigger>
<Sheet.Content side="right" class="w-[400px] sm:w-[540px]">
<Sheet.Header>

View File

@@ -0,0 +1,100 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { logout } from '$lib/auth';
interface LicenseError {
type: string;
message: string;
status: number;
}
let { error }: { error: LicenseError } = $props();
const isHubOffline = error.type === 'HUB_OFFLINE' || error.type === 'HUB_ERROR';
function handleLogout() {
void logout();
}
</script>
<div class="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center p-8">
<div class="flex max-w-md flex-col items-center gap-6 text-center">
<!-- Icon -->
{#if isHubOffline}
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-900/30">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10 text-yellow-600 dark:text-yellow-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
/>
</svg>
</div>
{:else}
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-destructive/10">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10 text-destructive"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"
/>
</svg>
</div>
{/if}
<!-- Heading -->
<div class="flex flex-col gap-2">
<h1 class="text-2xl font-semibold tracking-tight text-foreground">
{#if isHubOffline}
Servicio de licencias no disponible
{:else}
Acceso suspendido
{/if}
</h1>
<p class="text-sm text-muted-foreground">
{error.message}
</p>
</div>
<!-- Help text -->
<div class="rounded-lg border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
{#if isHubOffline}
El servidor de licencias no está disponible en este momento. Por favor, inténtalo de nuevo
en unos minutos o contacta a soporte si el problema persiste.
{:else if error.type === 'LICENSE_ERROR'}
Tu organización no cuenta con una licencia activa para acceder al sistema. Contacta a tu
administrador o al equipo de soporte para regularizar tu suscripción.
{:else}
No tienes permisos para acceder al sistema. Contacta a tu administrador.
{/if}
</div>
<!-- Actions -->
<div class="flex gap-3">
{#if isHubOffline}
<Button variant="outline" onclick={() => window.location.reload()}>
Reintentar
</Button>
{/if}
<Button variant="destructive" onclick={handleLogout}>
Cerrar sesión
</Button>
</div>
</div>
</div>

View File

@@ -1,27 +1,27 @@
<script lang="ts">
import * as Card from "$lib/components/ui/card/index.js";
import * as Card from "$lib/components/ui/card/index.ts";
import {
FieldGroup,
Field,
FieldLabel,
FieldDescription,
} from "$lib/components/ui/field/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
} from "$lib/components/ui/field/index.ts";
import { Input } from "$lib/components/ui/input/index.ts";
import { Button } from "$lib/components/ui/button/index.ts";
import { cn } from "$lib/utils.ts";
import faviconUrl from '$lib/assets/favicon.svg';
import type { HTMLAttributes } from "svelte/elements";
import { page } from '$app/state';
import { enhance } from '$app/forms';
import { loginWithProvider } from '$lib/sso';
import { loginWithProvider } from '$lib/sso.ts';
import { onMount, tick } from 'svelte';
let { class: className, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
const id = $props.id();
let username = $state('demo');
let password = $state('demo123');
let username = $state('');
let password = $state('');
let tenantSlug = $state('');
let loading = $state(false);
// step 1 = credenciales, step 2 = selección de organización
@@ -32,12 +32,18 @@
// Descubrimiento de tenants
type TenantInfo = { id: number; name: string; slug: string };
let tenants = $state<TenantInfo[]>([]);
let discoveryError = $state('');
const error = $derived(page.form?.error || '');
const error = $derived(discoveryError || page.form?.error || '');
// Limpiar todo el localStorage y cookies al montar el componente de login
onMount(() => {
clearAllData();
// Si viene ?tenant= en la URL (ej: después del registro), pre-seleccionar
const urlTenant = new URL(window.location.href).searchParams.get('tenant');
if (urlTenant) {
tenantSlug = urlTenant;
}
});
// Función para limpiar cookies del cliente
@@ -61,22 +67,25 @@
}
async function fetchTenants(): Promise<TenantInfo[]> {
discoveryError = '';
try {
const apiBase = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, '');
// Llama a /login SIN tenant_slug: el backend verifica credenciales primero,
// luego devuelve las orgs. Sin contraseña válida no se revela nada.
const res = await fetch(`${apiBase}/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
const data = await res.json();
// { status: "choose_tenant", tenants: [...] }
// Múltiples tenants: { status: "choose_tenant", tenants: [...] }
if (data.tenants) return data.tenants;
// Un solo tenant: Hub devuelve token directo con data.tenant
if (data.access_token && data.tenant) return [data.tenant];
} else {
discoveryError = data.detail || 'Error de autenticación';
}
} catch {
// ignore — el server action mostrará el error de autenticación
discoveryError = 'Error de conexión con el servidor';
}
return [];
}
@@ -132,8 +141,11 @@
} else if (tenants.length > 1) {
// Varias orgs: mostrar selector
step = 2;
} else if (discoveryError) {
// Error claro del Hub (sin licencia, credenciales inválidas, etc.)
// No hacer submit — el error ya se muestra en discoveryError
} else {
// 0 orgs: enviar igual, el backend rechazará
// 0 orgs sin error: enviar igual, el backend rechazará
readyToSubmit = true;
await tick();
formEl?.requestSubmit();
@@ -142,11 +154,12 @@
}
loading = true;
return async ({ update, result }) => {
await update();
await update({ reset: false });
loading = false;
readyToSubmit = false;
if (result.type === 'failure') {
clearClientCookies();
step = 1;
}
};
}}

View File

@@ -49,7 +49,7 @@
<Sidebar.Root {collapsible} {...restProps}>
<Sidebar.Header>
<TeamSwitcher />
<TeamSwitcher {userTenants} />
</Sidebar.Header>
<Sidebar.Content>
<NavMain items={data.navMain} />

View File

@@ -7,9 +7,20 @@
import CheckIcon from '@lucide/svelte/icons/check';
import { companyStore } from '$lib/stores/company.svelte';
import { getBackendAssetUrl } from '$lib/utils';
import { invalidateAll } from '$app/navigation';
interface Tenant {
id: number;
name: string;
slug: string;
}
let { userTenants = [] }: { userTenants: Tenant[] } = $props();
const sidebar = useSidebar();
let switchingTenant = $state(false);
// Derivar la URL del logo usando el endpoint específico
let activeCompanyLogoUrl = $derived(
companyStore.activeCompany?.logo
@@ -24,11 +35,70 @@
companyStore.activeCompany?.name?.slice(0, 2).toUpperCase() || 'CO'
);
// Fallback en degradé cuando no hay logo cargado
const fallbackBg =
'radial-gradient(circle at 30% 30%, rgba(0,0,0,0.08), rgba(0,0,0,0.12)), linear-gradient(135deg, rgba(99,102,241,0.12), rgba(14,165,233,0.18))';
function readCookie(name: string): string | null {
if (typeof document === 'undefined') return null;
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift() ?? null;
return null;
}
const logoBg = $derived(activeCompanyLogoUrl ? `url(${activeCompanyLogoUrl})` : fallbackBg);
let activeTenantPubId = $derived.by<number | null>(() => {
const fromCookie = readCookie('sso_tenant_pub');
if (fromCookie && !Number.isNaN(Number(fromCookie))) return Number(fromCookie);
return null;
});
async function switchTenant(tenant: Tenant) {
if (switchingTenant) return;
switchingTenant = true;
try {
const res = await fetch('/api-sveltekit/auth/switch-tenant', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tenant_id: tenant.id }),
credentials: 'include',
});
if (res.ok) {
companyStore.clear();
await invalidateAll();
} else {
const err = await res.json().catch(() => ({}));
console.error('[team-switcher] switch-tenant error:', err);
}
} catch (e) {
console.error('[team-switcher] fetch error:', e);
} finally {
switchingTenant = false;
}
}
function normalizeIdentity(value: string | undefined | null): string {
return (value ?? '').trim().toLowerCase();
}
let tenantIdentitySet = $derived.by(() => {
const set = new Set<string>();
for (const tenant of userTenants) {
set.add(normalizeIdentity(tenant.name));
set.add(normalizeIdentity(tenant.slug));
}
set.delete('');
return set;
});
// Excluir del listado de companias cualquier registro que realmente represente al tenant.
let myCompanies = $derived(
companyStore.companies.filter((company) => !tenantIdentitySet.has(normalizeIdentity(company.name)))
);
$effect(() => {
const active = companyStore.activeCompany;
if (!active) return;
if (!tenantIdentitySet.has(normalizeIdentity(active.name))) return;
if (myCompanies.length === 0) return;
void companyStore.setActiveCompany(myCompanies[0], true);
});
</script>
<Sidebar.Menu>
@@ -99,18 +169,42 @@
side={sidebar.isMobile ? 'bottom' : 'right'}
sideOffset={4}
>
<DropdownMenu.Label class="text-xs text-muted-foreground">Mis Compañías</DropdownMenu.Label>
<DropdownMenu.Label class="text-xs text-muted-foreground">Tenant</DropdownMenu.Label>
{#if userTenants.length === 0}
<DropdownMenu.Item disabled class="gap-2 p-2">
<span class="text-muted-foreground">Sin tenant asignado</span>
</DropdownMenu.Item>
{:else}
{#each userTenants as tenant (tenant.id)}
<DropdownMenu.Item
onSelect={() => switchTenant(tenant)}
class="cursor-pointer gap-2 p-2"
disabled={switchingTenant}
>
<div class="flex size-6 items-center justify-center rounded-md border bg-muted">
<BuildingIcon class="size-3.5" />
</div>
<span class="truncate font-medium">{tenant.name}</span>
{#if activeTenantPubId === tenant.id}
<CheckIcon class="ml-auto size-4 text-primary" />
{/if}
</DropdownMenu.Item>
{/each}
{/if}
<DropdownMenu.Separator />
<DropdownMenu.Label class="text-xs text-muted-foreground">Mis compañías</DropdownMenu.Label>
{#if companyStore.loading}
<DropdownMenu.Item disabled class="gap-2 p-2">
<span class="text-muted-foreground">Cargando...</span>
</DropdownMenu.Item>
{:else if companyStore.companies.length === 0}
{:else if myCompanies.length === 0}
<DropdownMenu.Item disabled class="gap-2 p-2">
<span class="text-muted-foreground">No hay compañías disponibles</span>
<span class="text-muted-foreground">No tienes compañías disponibles</span>
</DropdownMenu.Item>
{:else}
{#each companyStore.companies as company, index (company.id)}
{#each myCompanies as company, index (company.id)}
<DropdownMenu.Item
onSelect={() => companyStore.setActiveCompany(company)}
class="cursor-pointer gap-2 p-2"

View File

@@ -82,10 +82,11 @@ export function clearAuthTokens(cookies: Cookies) {
/**
* Crea headers de autorización con el token Bearer
*/
export function createAuthHeaders(token: string, additionalHeaders?: Record<string, string>) {
export function createAuthHeaders(token: string, additionalHeaders?: Record<string, string>, tenantOverride?: string) {
return {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}),
...additionalHeaders
};
}
@@ -162,6 +163,9 @@ export async function authenticatedFetch(
// Construir URL completa
const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`;
// Leer tenant override de cookie SSO (flujo multi-tenant relay)
const tenantOverride = cookies.get('sso_tenant_id');
// Crear AbortController para timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => {
@@ -174,7 +178,7 @@ export async function authenticatedFetch(
const isFormData = options.body instanceof FormData;
const headers = isFormData
? { 'Authorization': `Bearer ${accessToken}`, ...(options.headers as Record<string, string> || {}) }
: createAuthHeaders(accessToken, options.headers as Record<string, string>);
: createAuthHeaders(accessToken, options.headers as Record<string, string>, tenantOverride);
let response = await fetch(url, {
...options,
@@ -205,7 +209,7 @@ export async function authenticatedFetch(
// Si el body es FormData, no incluir Content-Type
const newHeaders = isFormData
? { 'Authorization': `Bearer ${newToken}`, ...(options.headers as Record<string, string> || {}) }
: createAuthHeaders(newToken, options.headers as Record<string, string>);
: createAuthHeaders(newToken, options.headers as Record<string, string>, tenantOverride);
response = await fetch(url, {
...options,

View File

@@ -112,8 +112,14 @@ class CompanyStore {
}
} else {
console.error('Error loading companies:', response.error);
// Si falla la carga (ej: 401), limpiar el store
if (response.status === 401) {
if (response.status === 402) {
const { toast } = await import('svelte-sonner');
toast.error('Licencia inactiva', {
duration: 8000,
description: response.error || 'Tu licencia no está activa para este tenant. Contacta al administrador.'
});
this.clear();
} else if (response.status === 401) {
this.clear();
}
}

View File

@@ -1,41 +1,82 @@
/**
* Endpoint server-side para cambiar de tenant sin exponer el refresh_token al cliente.
*
* Flujo:
* 1. Cliente llama POST /api-sveltekit/auth/switch-tenant con { tenant_slug }
* 2. Este servidor lee access_token y refresh_token de las cookies (HttpOnly).
* 3. Llama al backend /v1/auth/switch-tenant con ambos tokens.
* 4. Si es exitoso, actualiza las cookies con los nuevos tokens.
* 5. Retorna ok al cliente para que recargue la página.
* Dos modos:
* - { tenant_id } → flujo SSO relay: solo actualiza cookie sso_tenant_id (override de tenant)
* - { tenant_slug } → flujo login clásico: re-emite tokens KC para el nuevo tenant
*/
import { json } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import type { RequestEvent } from '@sveltejs/kit';
import { getServerApiUrl, getAuthTokens, setAuthTokens } from '$lib/server/api';
export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
const { tenant_slug } = await request.json();
const body = await request.json();
const { tenant_id, tenant_slug } = body as { tenant_id?: number; tenant_slug?: string };
if (!tenant_slug) {
return json({ error: 'tenant_slug is required' }, { status: 400 });
if (!tenant_id && !tenant_slug) {
return json({ error: 'tenant_id or tenant_slug is required' }, { status: 400 });
}
const { accessToken, refreshToken } = getAuthTokens(cookies);
if (!accessToken || !refreshToken) {
if (!accessToken) {
return json({ error: 'Not authenticated' }, { status: 401 });
}
// Modo SSO relay: validar acceso vía Hub y actualizar cookie de override
if (tenant_id) {
try {
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, {
headers: { 'Authorization': `Bearer ${accessToken}` },
});
if (!tenantsRes.ok) {
return json({ error: 'Could not validate tenant access' }, { status: 403 });
}
const tenants: { id: number }[] = await tenantsRes.json();
const hasAccess = tenants.some((t) => t.id === tenant_id);
if (!hasAccess) {
return json({ error: 'Access denied to tenant' }, { status: 403 });
}
const isProduction = process.env.NODE_ENV === 'production';
cookies.set('sso_tenant_id', String(tenant_id), {
path: '/',
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
cookies.set('sso_tenant_pub', String(tenant_id), {
path: '/',
httpOnly: false,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
cookies.delete('active_company_id', { path: '/' });
return json({ ok: true });
} catch (error) {
console.error('[switch-tenant] SSO mode error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
}
// Modo login clásico: re-emitir tokens KC para el nuevo tenant
if (!refreshToken) {
return json({ error: 'Not authenticated' }, { status: 401 });
}
try {
const baseUrl = getServerApiUrl();
const response = await fetch(`${baseUrl}v1/auth/switch-tenant`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({ tenant_slug, refresh_token: refreshToken })
body: JSON.stringify({ tenant_slug, refresh_token: refreshToken }),
});
if (!response.ok) {
@@ -44,15 +85,13 @@ export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
}
const data = await response.json();
// Actualizar cookies con los nuevos tokens del nuevo tenant
setAuthTokens(cookies, data.access_token, data.refresh_token);
// Limpiar la compañía activa para que el dashboard recargue con el nuevo tenant
cookies.delete('active_company_id', { path: '/' });
cookies.delete('sso_tenant_id', { path: '/' });
cookies.delete('sso_tenant_pub', { path: '/' });
return json({ ok: true });
} catch (error) {
console.error('[switch-tenant] Error:', error);
console.error('[switch-tenant] Classic mode error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
};

View File

@@ -0,0 +1,101 @@
/**
* SSO auto-login page for Anexo76.
* The Hub App Launcher redirects here with ?relay=<token> after generating a relay token.
* This server-side load function exchanges the relay token for KC tokens via the
* Hub backend, sets HttpOnly cookies, and redirects to /dashboard.
*/
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url, cookies }) => {
const relayToken = url.searchParams.get('relay');
console.log('[SSO] relay token presente:', !!relayToken);
if (!relayToken) {
throw redirect(303, '/login?error=sso_missing_token');
}
// SSO exchange must call Hub backend, not Anexo76 backend.
// Use INTERNAL_HUB_URL for server-to-server communication.
let hubUrl = process.env.INTERNAL_HUB_URL;
if (!hubUrl) {
hubUrl = process.env.VITE_HUB_URL;
// Fallback: replace localhost with hub-backend for Docker
hubUrl = hubUrl?.replace('localhost', 'host.docker.internal').replace('127.0.0.1', 'host.docker.internal');
}
const baseUrl = hubUrl?.endsWith('/') ? hubUrl : `${hubUrl}/`;
let response: Response;
try {
response = await fetch(`${baseUrl}api/v1/auth/sso-exchange`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relay_token: relayToken }),
});
} catch (err) {
throw redirect(303, '/login?error=sso_hub_unreachable');
}
if (!response.ok) {
const body = await response.json().catch(() => ({}));
const detail = body?.detail || 'sso_exchange_failed';
throw redirect(303, `/login?error=${encodeURIComponent(detail)}`);
}
const tokens = await response.json();
console.log('[SSO] exchange exitoso, tokens recibidos:', {
hasAccessToken: !!tokens.access_token,
accessTokenLen: tokens.access_token?.length,
hasRefreshToken: !!tokens.refresh_token,
tenant_id: tokens.tenant_id,
tenant_slug: tokens.tenant_slug,
});
const isProduction = process.env.NODE_ENV === 'production';
console.log('[SSO] NODE_ENV:', process.env.NODE_ENV, '→ isProduction:', isProduction);
// access_token — NO HttpOnly (client JS reads it for Bearer headers)
cookies.set('access_token', tokens.access_token, {
path: '/',
httpOnly: false,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
// refresh_token — HttpOnly (never exposed to JS)
if (tokens.refresh_token) {
cookies.set('refresh_token', tokens.refresh_token, {
path: '/',
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
});
}
// sso_tenant_id — HttpOnly cookie con el tenant seleccionado.
// El backend lo pasa como X-Tenant-Override en Hub /auth/me para que
// devuelva el tenant correcto aunque el KC token tenga otro tenant baked in.
if (tokens.tenant_id) {
cookies.set('sso_tenant_id', String(tokens.tenant_id), {
path: '/',
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
// sso_tenant_pub — companion no-HttpOnly para que el cliente JS pueda
// leer el tenant override e incluirlo como X-Tenant-Override en fetch directo al backend.
// No es un secreto (solo un ID numérico; Hub valida UserTenant en cada request).
cookies.set('sso_tenant_pub', String(tokens.tenant_id), {
path: '/',
httpOnly: false,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
}
console.log('[SSO] cookies configuradas, redirigiendo a /dashboard');
throw redirect(303, '/dashboard');
};

View File

@@ -0,0 +1,11 @@
<script lang="ts">
// This page is never rendered — the server-side load always redirects.
// It exists only to satisfy SvelteKit's file-based routing requirement.
</script>
<div class="flex min-h-screen items-center justify-center">
<div class="flex flex-col items-center gap-4 text-center">
<div class="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent"></div>
<p class="text-sm text-slate-500">Iniciando sesión automáticamente…</p>
</div>
</div>

View File

@@ -1,16 +1,17 @@
import { redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import type { LayoutServerLoad } from './$types';
import {
validateAuth,
getUserCompanies,
getAuthTokens,
clearAuthTokens,
authenticatedFetch
getUserCompanies,
clearAuthTokens
} from '$lib/server/api';
export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
// Verificar si existe el token en las cookies
const { accessToken } = getAuthTokens(cookies);
console.log('[dashboard layout] access_token presente:', !!accessToken, '| url:', url.pathname);
// Si no hay token, redirigir al login, pero excluir la ruta /login para evitar bucle
if (!accessToken && url.pathname !== '/login') {
@@ -28,18 +29,29 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
// Cargar las compañías del usuario en el servidor (SSR)
const companies = await getUserCompanies(cookies, fetch);
// Cargar los tenants del usuario para el selector de organización
let userTenants: { id: number; name: string; slug: string; is_active: boolean }[] = [];
// Si la cookie active_company_id apunta a una compañía que ya no existe, limpiarla
const cookieCompanyId = cookies.get('active_company_id');
if (cookieCompanyId) {
const cookieId = parseInt(cookieCompanyId);
const stillExists = companies.some((c) => c.id === cookieId);
if (!stillExists) {
cookies.delete('active_company_id', { path: '/' });
}
}
// Cargar los tenants del usuario desde Hub (fuente de verdad multi-tenant)
let userTenants: { id: number; name: string; slug: string }[] = [];
try {
const tenantsRes = await authenticatedFetch(
`v1/core/user-tenants/${userData.sub}`,
{},
cookies,
fetch
);
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
const tenantOverride = cookies.get('sso_tenant_id');
const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}),
},
});
if (tenantsRes.ok) {
const tenantsData = await tenantsRes.json();
userTenants = tenantsData.tenants ?? [];
userTenants = await tenantsRes.json();
}
} catch {
// No bloquear el dashboard si falla la carga de tenants
@@ -70,7 +82,6 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
}
// Para cualquier otro error (conexión, etc), limpiar token y redirigir
console.error('🔐 [Dashboard] Error validando token:', error);
clearAuthTokens(cookies);
throw redirect(303, redirectOnFail);
}

View File

@@ -20,8 +20,10 @@
import type { SessionExpiredDetail } from '$lib/session-manager';
import { authStore } from '$lib/auth';
import { logout, getKeycloakInstance } from '$lib/auth';
import LicenseErrorScreen from '$lib/components/license-error-screen.svelte';
let { data, children }: { data: LayoutData; children: any } = $props();
type LicenseError = { type: string; message: string; status: number };
let { data, children }: { data: LayoutData & { licenseError?: LicenseError }; children: any } = $props();
let csvImportBanner = $state(false);
let csvImportBannerLabel = $state<string | null>(null);
@@ -132,6 +134,9 @@
});
</script>
{#if data.licenseError}
<LicenseErrorScreen error={data.licenseError} />
{:else}
<Sidebar.Provider>
<AppSidebar />
<Sidebar.Inset class="overflow-x-hidden">
@@ -187,3 +192,4 @@
<!-- Diálogo de advertencia de sesión por inactividad -->
<SessionTimeoutWarning />
{/if}

View File

@@ -30,8 +30,8 @@ export const actions = {
}
try {
const baseUrl = getServerApiUrl();
const loginUrl = `${baseUrl}v1/auth/login`;
const hubUrl = process.env.INTERNAL_HUB_URL || 'http://host.docker.internal:8001';
const loginUrl = `${hubUrl}/api/v1/auth/login`;
const requestBody = {
username,

View File

@@ -1,14 +1,49 @@
import { redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ cookies }) => {
export const POST: RequestHandler = async ({ cookies, request }) => {
const refreshToken = cookies.get('refresh_token');
// Post-logout siempre va al workspace login, no al login local de Anexo76.
// Desde el workspace el usuario puede volver a autenticarse con Microsoft
// y el relay lo traerá de vuelta automáticamente.
// HUB_URL es la URL pública del workspace (ej: https://workspace.aduanasoft.com)
const hubPublicUrl = (env.HUB_URL || '').replace(/\/+$/, '');
const workspaceLoginUrl = hubPublicUrl
? `${hubPublicUrl}/login`
: `${new URL(request.url).origin}/login`;
// Eliminar todas las cookies de autenticación
cookies.delete('access_token', { path: '/' });
cookies.delete('refresh_token', { path: '/' });
// Eliminar la cookie de la compañía activa
cookies.delete('active_company_id', { path: '/' });
cookies.delete('sso_tenant_id', { path: '/' });
cookies.delete('sso_tenant_pub', { path: '/' });
// Redirigir al login
throw redirect(303, '/login');
// Llamar al Hub para revocar el refresh token.
// La navegación final siempre debe volver al login local de anexo76
// sin redirigir al endpoint de logout de Keycloak.
if (refreshToken) {
try {
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
const res = await fetch(`${hubUrl}/api/v1/auth/logout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
refresh_token: refreshToken,
post_logout_redirect_uri: workspaceLoginUrl,
}),
});
if (res.ok) {
await res.json().catch(() => ({}));
}
} catch (err: any) {
// Si falla la llamada al Hub, caer al workspace login de todos modos
}
}
throw redirect(303, workspaceLoginUrl);
};

View File

@@ -1,6 +1,28 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { api } from '$lib/api';
import { onMount } from 'svelte';
// Parámetros del URL — se rellenan desde el link de invitación
let inviteToken = $state('');
let inviteTenantSlug = $state('');
let inviteEmail = $state('');
let isInviteFlow = $state(false);
onMount(() => {
const params = new URL(window.location.href).searchParams;
inviteToken = params.get('invite_token') ?? '';
inviteTenantSlug = params.get('tenant') ?? '';
inviteEmail = params.get('email') ?? '';
isInviteFlow = Boolean(inviteToken && inviteTenantSlug);
if (isInviteFlow) {
// Pre-rellenar campos bloqueados desde la invitación
formData.tenant_slug = inviteTenantSlug;
if (inviteEmail) formData.email = inviteEmail;
}
});
let formData = $state({
username: '',
@@ -9,25 +31,24 @@
confirmPassword: '',
first_name: '',
last_name: '',
tenant_slug: 'aduanasoft' // Por defecto
tenant_slug: 'aduanasoft'
});
let loading = $state(false);
let error = $state('');
let passwordError = $state('');
let success = $state(false);
async function handleRegister(e: Event) {
e.preventDefault();
error = '';
passwordError = '';
// Validar que las contraseñas coincidan
if (formData.password !== formData.confirmPassword) {
passwordError = 'Las contraseñas no coinciden';
return;
}
// Validar longitud de contraseña
if (formData.password.length < 8) {
passwordError = 'La contraseña debe tener al menos 8 caracteres';
return;
@@ -36,22 +57,30 @@
loading = true;
try {
const response = await api.auth.register({
const payload: Record<string, string> = {
username: formData.username,
email: formData.email,
password: formData.password,
first_name: formData.first_name,
last_name: formData.last_name,
tenant_slug: formData.tenant_slug
});
tenant_slug: formData.tenant_slug,
};
if (inviteToken) {
payload.invite_token = inviteToken;
}
const response = await api.auth.register(payload as any);
if (response.error) {
throw new Error(response.error);
}
// Registro exitoso, redirigir al login
alert(`¡Registro exitoso! Bienvenido ${response.data.username}`);
goto('/login');
success = true;
// Redirigir al login con el tenant pre-seleccionado tras unos segundos
setTimeout(() => {
goto(`/login?tenant=${encodeURIComponent(formData.tenant_slug)}`);
}, 2000);
} catch (err: any) {
error = err.message || 'Error al registrar usuario';
} finally {
@@ -77,6 +106,45 @@
</p>
</div>
<!-- Banner de invitación -->
{#if isInviteFlow}
<div class="mt-4 rounded-md bg-blue-50 border border-blue-200 p-4">
<div class="flex">
<svg class="h-5 w-5 text-blue-400 mt-0.5 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
<div>
<p class="text-sm font-medium text-blue-800">Invitación válida</p>
<p class="text-sm text-blue-700 mt-1">
Estás registrándote en <strong>{inviteTenantSlug}</strong>.
El enlace caduca en 48 horas y es de un solo uso.
</p>
</div>
</div>
</div>
{/if}
<!-- Pantalla de éxito -->
{#if success}
<div class="mt-8 rounded-lg bg-white px-6 py-8 shadow text-center">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900">¡Cuenta creada!</h3>
<p class="mt-2 text-sm text-gray-600">
Te hemos enviado un correo de verificación a <strong>{formData.email}</strong>.
Confirma tu email antes de iniciar sesión.
</p>
<a
href="/login"
class="mt-6 inline-block rounded-md bg-blue-600 px-5 py-2 text-sm font-semibold text-white hover:bg-blue-500"
>
Ir al login
</a>
</div>
{:else}
<!-- Formulario de registro -->
<div class="mt-8">
<div class="rounded-lg bg-white px-6 py-8 shadow">
@@ -108,9 +176,13 @@
id="email"
bind:value={formData.email}
required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
readonly={isInviteFlow && Boolean(inviteEmail)}
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 {isInviteFlow && inviteEmail ? 'bg-gray-50 text-gray-500 cursor-not-allowed' : ''}"
placeholder="usuario@ejemplo.com"
/>
{#if isInviteFlow && inviteEmail}
<p class="mt-1 text-xs text-gray-500">El email está fijado por la invitación.</p>
{/if}
</div>
<!-- Nombre -->
@@ -187,15 +259,25 @@
<label for="tenant_slug" class="block text-sm font-medium text-gray-700">
Empresa
</label>
<select
id="tenant_slug"
bind:value={formData.tenant_slug}
required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
>
<option value="aduanasoft">AduanaSoft</option>
<!-- Agregar más tenants aquí -->
</select>
{#if isInviteFlow}
<input
type="text"
id="tenant_slug"
value={formData.tenant_slug}
readonly
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm bg-gray-50 text-gray-500 cursor-not-allowed"
/>
<p class="mt-1 text-xs text-gray-500">Fijado por la invitación.</p>
{:else}
<select
id="tenant_slug"
bind:value={formData.tenant_slug}
required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
>
<option value="aduanasoft">AduanaSoft</option>
</select>
{/if}
</div>
<!-- Errores -->
@@ -233,5 +315,6 @@
</div>
</div>
</div>
{/if}
</div>
</div>

65
scripts/backend-entrypoint.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/bin/bash
set -e
# Script de inicialización para el Backend FastAPI
# Espera a las dependencias y ejecuta migraciones antes de iniciar
echo "=========================================="
echo "Backend FastAPI - Inicialización"
echo "=========================================="
# Función para esperar a un puerto TCP usando Python
wait_for_tcp() {
local host=$1
local port=$2
local service=$3
local max_attempts=30
local attempt=1
echo "Esperando a que $service esté disponible en ${host}:${port}..."
while [ $attempt -le $max_attempts ]; do
if python3 -c "import socket; s = socket.socket(); s.settimeout(3); s.connect(('$host', $port)); s.close()" 2>/dev/null; then
echo "$service está listo y accesible"
return 0
fi
echo "$service no está listo aún... (intento $attempt/$max_attempts)"
attempt=$((attempt + 1))
sleep 2
done
echo "⚠ WARNING: $service no estuvo disponible después de $max_attempts intentos"
echo " Continuando de todas formas..."
return 0
}
# Esperar a PostgreSQL
wait_for_tcp "${CORE_DB_HOST:-postgres-a76}" "${CORE_DB_PORT:-5432}" "PostgreSQL"
# Esperar al Hub de Aduanasoft (opcional, pero recomendado)
# Extraer host y puerto de HUB_URL si es posible, o usar el default del docker-compose
# HUB_HOST=$(echo $HUB_URL | sed -e 's|http://||' -e 's|:.*||')
# HUB_PORT=$(echo $HUB_URL | sed -e 's|.*:||' -e 's|/.*||')
# wait_for_tcp "${HUB_HOST:-host.docker.internal}" "${HUB_PORT:-8001}" "Aduanasoft Hub"
# Ejecutar migraciones de Alembic
#if [ -d "/app/alembic" ]; then
# echo "Ejecutando migraciones de Alembic..."
# alembic upgrade head || {
# echo "⚠ WARNING: Error al ejecutar migraciones"
# echo " Verificando estado de la base de datos..."
# alembic current || echo " No se pudo determinar la versión actual"
# }
# echo "✓ Migraciones completadas"
#else
# echo "⚠ WARNING: Directorio /app/alembic no encontrado"
# echo " Las migraciones de base de datos no se ejecutaron"
#fi
echo "=========================================="
echo "Iniciando aplicación FastAPI..."
echo "=========================================="
# Ejecutar el comando que se pasó al contenedor
exec "$@"

File diff suppressed because it is too large Load Diff