diff --git a/backend/.env.example b/backend/.env.example index 677e007d..e73a096e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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= diff --git a/backend/alembic/versions/g1b2c3d4e5f6_invoice_header_user_field_length.py b/backend/alembic/versions/g1b2c3d4e5f6_invoice_header_user_field_length.py new file mode 100644 index 00000000..e37c00b9 --- /dev/null +++ b/backend/alembic/versions/g1b2c3d4e5f6_invoice_header_user_field_length.py @@ -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', + ) diff --git a/backend/alembic/versions/h1a2b3c4d5e6_merge_multiple_heads.py b/backend/alembic/versions/h1a2b3c4d5e6_merge_multiple_heads.py new file mode 100644 index 00000000..b16b8b4b --- /dev/null +++ b/backend/alembic/versions/h1a2b3c4d5e6_merge_multiple_heads.py @@ -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 diff --git a/backend/alembic/versions/i7j8k9l0m1n2_fix_varchar20_truncation_errors.py b/backend/alembic/versions/i7j8k9l0m1n2_fix_varchar20_truncation_errors.py new file mode 100644 index 00000000..1dfec32a --- /dev/null +++ b/backend/alembic/versions/i7j8k9l0m1n2_fix_varchar20_truncation_errors.py @@ -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', + ) diff --git a/backend/api/v1/modules/a24/discharges/models.py b/backend/api/v1/modules/a24/discharges/models.py index 47d5511a..13549411 100644 --- a/backend/api/v1/modules/a24/discharges/models.py +++ b/backend/api/v1/modules/a24/discharges/models.py @@ -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 ───────────────────────────────────────────────────── diff --git a/backend/api/v1/modules/a76/audit_log/middleware.py b/backend/api/v1/modules/a76/audit_log/middleware.py index 0d16a79b..b5f4ed68 100644 --- a/backend/api/v1/modules/a76/audit_log/middleware.py +++ b/backend/api/v1/modules/a76/audit_log/middleware.py @@ -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 diff --git a/backend/api/v1/modules/a76/audit_log/models.py b/backend/api/v1/modules/a76/audit_log/models.py index ace69a32..31b51f03 100644 --- a/backend/api/v1/modules/a76/audit_log/models.py +++ b/backend/api/v1/modules/a76/audit_log/models.py @@ -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) diff --git a/backend/api/v1/modules/a76/invoices/models.py b/backend/api/v1/modules/a76/invoices/models.py index e4e7c086..8cf823e6 100644 --- a/backend/api/v1/modules/a76/invoices/models.py +++ b/backend/api/v1/modules/a76/invoices/models.py @@ -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( diff --git a/backend/api/v1/modules/a76/invoices/routes.py b/backend/api/v1/modules/a76/invoices/routes.py index 25443111..5e75b450 100644 --- a/backend/api/v1/modules/a76/invoices/routes.py +++ b/backend/api/v1/modules/a76/invoices/routes.py @@ -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: diff --git a/backend/api/v1/modules/a76/invoices/schemas.py b/backend/api/v1/modules/a76/invoices/schemas.py index e8df1724..70f3382e 100644 --- a/backend/api/v1/modules/a76/invoices/schemas.py +++ b/backend/api/v1/modules/a76/invoices/schemas.py @@ -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" ) diff --git a/backend/api/v1/modules/a76/rule_octave/balances/models.py b/backend/api/v1/modules/a76/rule_octave/balances/models.py index 97aa262a..7ef83c83 100644 --- a/backend/api/v1/modules/a76/rule_octave/balances/models.py +++ b/backend/api/v1/modules/a76/rule_octave/balances/models.py @@ -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 diff --git a/backend/api/v1/modules/core/auth/dto.py b/backend/api/v1/modules/core/auth/dto.py index f8e9527e..ebe50f91 100644 --- a/backend/api/v1/modules/core/auth/dto.py +++ b/backend/api/v1/modules/core/auth/dto.py @@ -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") diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py index fbb9a69a..36121f7e 100644 --- a/backend/api/v1/modules/core/auth/routes.py +++ b/backend/api/v1/modules/core/auth/routes.py @@ -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 diff --git a/backend/api/v1/modules/core/auth/service.py b/backend/api/v1/modules/core/auth/service.py index 2fafd364..8b090267 100644 --- a/backend/api/v1/modules/core/auth/service.py +++ b/backend/api/v1/modules/core/auth/service.py @@ -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") diff --git a/backend/api/v1/modules/core/permissions/routes.py b/backend/api/v1/modules/core/permissions/routes.py index 13201c17..903db2ce 100644 --- a/backend/api/v1/modules/core/permissions/routes.py +++ b/backend/api/v1/modules/core/permissions/routes.py @@ -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 diff --git a/backend/api/v1/modules/core/users/routes.py b/backend/api/v1/modules/core/users/routes.py index 4323bc25..d6f5e667 100644 --- a/backend/api/v1/modules/core/users/routes.py +++ b/backend/api/v1/modules/core/users/routes.py @@ -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, diff --git a/backend/api/v1/modules/core/users/service.py b/backend/api/v1/modules/core/users/service.py index 38464160..e843dd10 100644 --- a/backend/api/v1/modules/core/users/service.py +++ b/backend/api/v1/modules/core/users/service.py @@ -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) diff --git a/backend/core/config.py b/backend/core/config.py index 207f8691..ff90deee 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -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""" diff --git a/backend/core/middleware.py b/backend/core/middleware.py index 257afd4f..654feadf 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -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 \ No newline at end of file diff --git a/backend/core/security.py b/backend/core/security.py index d05112e8..164486ab 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -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 diff --git a/backend/main.py b/backend/main.py index 54685087..8af725a5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/requirements.txt b/backend/requirements.txt index 86175896..2b326129 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/unit/core/test_license_middleware.py b/backend/tests/unit/core/test_license_middleware.py new file mode 100644 index 00000000..55a277cb --- /dev/null +++ b/backend/tests/unit/core/test_license_middleware.py @@ -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 diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml index ade259c0..61f00397 100644 --- a/docker-compose.ci.yml +++ b/docker-compose.ci.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 49cbc088..a6c91756 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 9cffaf74..38957027 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -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 {} } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 156ae2e6..f3e09140 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -229,6 +229,18 @@ async function fetchApi( 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( 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: { diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index a14bd1d0..d30e14f9 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -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'; diff --git a/frontend/src/lib/components/help/HelpDrawer.svelte b/frontend/src/lib/components/help/HelpDrawer.svelte index 3d31f312..8ed7c314 100644 --- a/frontend/src/lib/components/help/HelpDrawer.svelte +++ b/frontend/src/lib/components/help/HelpDrawer.svelte @@ -124,11 +124,16 @@ - - + + {#snippet child({ props })} + + {/snippet} diff --git a/frontend/src/lib/components/license-error-screen.svelte b/frontend/src/lib/components/license-error-screen.svelte new file mode 100644 index 00000000..6e3637f7 --- /dev/null +++ b/frontend/src/lib/components/license-error-screen.svelte @@ -0,0 +1,100 @@ + + +
+
+ + {#if isHubOffline} +
+ +
+ {:else} +
+ +
+ {/if} + + +
+

+ {#if isHubOffline} + Servicio de licencias no disponible + {:else} + Acceso suspendido + {/if} +

+

+ {error.message} +

+
+ + +
+ {#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} +
+ + +
+ {#if isHubOffline} + + {/if} + +
+
+
diff --git a/frontend/src/lib/components/login-form.svelte b/frontend/src/lib/components/login-form.svelte index 80d0493e..28b9bb60 100644 --- a/frontend/src/lib/components/login-form.svelte +++ b/frontend/src/lib/components/login-form.svelte @@ -1,27 +1,27 @@ @@ -99,18 +169,42 @@ side={sidebar.isMobile ? 'bottom' : 'right'} sideOffset={4} > - Mis Compañías + Tenant + {#if userTenants.length === 0} + + Sin tenant asignado + + {:else} + {#each userTenants as tenant (tenant.id)} + switchTenant(tenant)} + class="cursor-pointer gap-2 p-2" + disabled={switchingTenant} + > +
+ +
+ {tenant.name} + {#if activeTenantPubId === tenant.id} + + {/if} +
+ {/each} + {/if} + + + Mis compañías {#if companyStore.loading} Cargando... - {:else if companyStore.companies.length === 0} + {:else if myCompanies.length === 0} - No hay compañías disponibles + No tienes compañías disponibles {:else} - {#each companyStore.companies as company, index (company.id)} + {#each myCompanies as company, index (company.id)} companyStore.setActiveCompany(company)} class="cursor-pointer gap-2 p-2" diff --git a/frontend/src/lib/server/api.ts b/frontend/src/lib/server/api.ts index a68524d0..09a3c2a2 100644 --- a/frontend/src/lib/server/api.ts +++ b/frontend/src/lib/server/api.ts @@ -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) { +export function createAuthHeaders(token: string, additionalHeaders?: Record, 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 || {}) } - : createAuthHeaders(accessToken, options.headers as Record); + : createAuthHeaders(accessToken, options.headers as Record, 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 || {}) } - : createAuthHeaders(newToken, options.headers as Record); + : createAuthHeaders(newToken, options.headers as Record, tenantOverride); response = await fetch(url, { ...options, diff --git a/frontend/src/lib/stores/company.svelte.ts b/frontend/src/lib/stores/company.svelte.ts index 833d2ab0..9d824f82 100644 --- a/frontend/src/lib/stores/company.svelte.ts +++ b/frontend/src/lib/stores/company.svelte.ts @@ -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(); } } diff --git a/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts b/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts index 9996e79c..cd2781f7 100644 --- a/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts +++ b/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts @@ -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 }); } }; diff --git a/frontend/src/routes/auth/sso/+page.server.ts b/frontend/src/routes/auth/sso/+page.server.ts new file mode 100644 index 00000000..c6709f2b --- /dev/null +++ b/frontend/src/routes/auth/sso/+page.server.ts @@ -0,0 +1,101 @@ +/** + * SSO auto-login page for Anexo76. + * The Hub App Launcher redirects here with ?relay= 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'); +}; diff --git a/frontend/src/routes/auth/sso/+page.svelte b/frontend/src/routes/auth/sso/+page.svelte new file mode 100644 index 00000000..e40b13b2 --- /dev/null +++ b/frontend/src/routes/auth/sso/+page.svelte @@ -0,0 +1,11 @@ + + +
+
+
+

Iniciando sesión automáticamente…

+
+
diff --git a/frontend/src/routes/dashboard/+layout.server.ts b/frontend/src/routes/dashboard/+layout.server.ts index e38234b7..155659c9 100644 --- a/frontend/src/routes/dashboard/+layout.server.ts +++ b/frontend/src/routes/dashboard/+layout.server.ts @@ -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); } diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index bb050398..18bc8527 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -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(null); @@ -132,6 +134,9 @@ }); +{#if data.licenseError} + +{:else} @@ -187,3 +192,4 @@ +{/if} diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index 157e9634..6255426a 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -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, diff --git a/frontend/src/routes/logout/+server.ts b/frontend/src/routes/logout/+server.ts index 679b0816..07bab424 100644 --- a/frontend/src/routes/logout/+server.ts +++ b/frontend/src/routes/logout/+server.ts @@ -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); }; diff --git a/frontend/src/routes/register/+page.svelte b/frontend/src/routes/register/+page.svelte index 54e4254e..282d117d 100644 --- a/frontend/src/routes/register/+page.svelte +++ b/frontend/src/routes/register/+page.svelte @@ -1,6 +1,28 @@