From e4d4d0cce64e2f2bd7be1d2a61fd64df1ce1d129 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Wed, 25 Feb 2026 10:34:11 -0600 Subject: [PATCH] Puestas de seguridad y limpieza de codigo --- .env.example | 1 - .../api/v1/modules/core/help_center/routes.py | 56 +++++++++++++++---- .../v1/modules/core/help_center/schemas.py | 1 - .../v1/modules/core/help_center/services.py | 5 -- .../api/v1/modules/core/help_center/tasks.py | 16 ++---- backend/core/config.py | 1 - backend/core/security.py | 4 ++ docker-compose.prod.yml | 2 - docker-compose.yml | 3 - frontend/src/lib/api/help.ts | 44 +++++++++++---- start.sh | 8 +-- 11 files changed, 88 insertions(+), 53 deletions(-) diff --git a/.env.example b/.env.example index bcfa9c07..87dcbf5f 100644 --- a/.env.example +++ b/.env.example @@ -62,7 +62,6 @@ VALKEY_URL=redis://valkey:6379/0 CENTRAL_SERVER_URL= # UUID único de este cliente (Opcional, se genera uno si está vacío) -CLIENT_UUID= # Token de seguridad compartido (Debe ser IDÉNTICO en Hub y Clientes) SYNC_SECRET_TOKEN=change-this-sync-token-in-production diff --git a/backend/api/v1/modules/core/help_center/routes.py b/backend/api/v1/modules/core/help_center/routes.py index d8e3bd93..29f576b5 100644 --- a/backend/api/v1/modules/core/help_center/routes.py +++ b/backend/api/v1/modules/core/help_center/routes.py @@ -2,12 +2,13 @@ import shutil import os import uuid from datetime import datetime -from typing import List, Optional +from typing import List, Optional, Dict, Any from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Header, status, UploadFile, File from sqlalchemy.orm import Session from core.database import get_core_db from core.config import settings +from core.security import get_current_user, has_role from .schemas import HelpArticleInDB, HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse from .services import HelpCenterService from .tasks import sync_single_article_task @@ -80,7 +81,10 @@ def sync_help_article(sync_data: HelpSyncRequest, db: Session = Depends(get_core return result @router.post("/upload-image/") -def upload_help_image(file: UploadFile = File(...)): +def upload_help_image( + file: UploadFile = File(...), + current_user: Dict[str, Any] = Depends(has_role("admin")) +): """Sube una imagen para usar en los artículos.""" try: file_ext = os.path.splitext(file.filename)[1] @@ -98,7 +102,10 @@ def upload_help_image(file: UploadFile = File(...)): raise HTTPException(status_code=500, detail=str(e)) @router.post("/upload-asset/") -def upload_help_asset(file: UploadFile = File(...)): +def upload_help_asset( + file: UploadFile = File(...), + current_user: Dict[str, Any] = Depends(has_role("admin")) +): """Sube cualquier tipo de archivo (PDF, Video, etc.) para la biblioteca.""" try: file_ext = os.path.splitext(file.filename)[1].lower() @@ -132,17 +139,24 @@ def upload_help_asset(file: UploadFile = File(...)): raise HTTPException(status_code=500, detail=str(e)) @router.get("/articles/", response_model=List[HelpArticleInDB]) -def list_articles(db: Session = Depends(get_core_db)): +def list_articles( + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user) +): """Lista todos los artículos de ayuda.""" return HelpCenterService.get_all(db) -@router.get("/modifications/", response_model=List[HelpArticleInDB]) +@router.get("/modifications/", response_model=List[HelpArticleInDB], dependencies=[Depends(verify_sync_token)]) def get_modifications(since: datetime, db: Session = Depends(get_core_db)): - """Obtiene artículos modificados desde la fecha indicada (Polling).""" + """Obtiene artículos modificados desde la fecha indicada (Polling). Requiere X-Sync-Token.""" return HelpCenterService.get_modifications(db, since) @router.get("/articles/{article_uuid}/", response_model=HelpArticleInDB) -def get_article(article_uuid: UUID, db: Session = Depends(get_core_db)): +def get_article( + article_uuid: UUID, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user) +): """Obtiene un artículo por UUID.""" article = HelpCenterService.get_by_uuid(db, article_uuid) if not article: @@ -150,12 +164,20 @@ def get_article(article_uuid: UUID, db: Session = Depends(get_core_db)): return article @router.post("/articles/", response_model=HelpArticleInDB, status_code=status.HTTP_201_CREATED) -def create_article(article: HelpArticleCreate, db: Session = Depends(get_core_db)): +def create_article( + article: HelpArticleCreate, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(has_role("admin")) +): """Crea un nuevo artículo.""" import logging logger = logging.getLogger(__name__) - logger.info(f"DEBUG: Creating new article: {article.title}") + logger.info(f"DEBUG: Creating new article: {article.title} by {current_user.get('preferred_username')}") + # Fill last_editor with admin username + if current_user.get('preferred_username'): + article.last_editor = current_user.get('preferred_username') + new_article = HelpCenterService.create(db, article) logger.info(f"DEBUG: Article created successfully in DB. UUID: {new_article.uuid}") @@ -164,8 +186,16 @@ def create_article(article: HelpArticleCreate, db: Session = Depends(get_core_db return new_article @router.patch("/articles/{article_uuid}/", response_model=HelpArticleInDB) -def update_article(article_uuid: UUID, article_data: HelpArticleUpdate, db: Session = Depends(get_core_db)): +def update_article( + article_uuid: UUID, + article_data: HelpArticleUpdate, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(has_role("admin")) +): """Actualiza un artículo.""" + if current_user.get('preferred_username'): + article_data.last_editor = current_user.get('preferred_username') + article = HelpCenterService.update(db, article_uuid, article_data) if not article: raise HTTPException(status_code=404, detail="Article not found") @@ -175,7 +205,11 @@ def update_article(article_uuid: UUID, article_data: HelpArticleUpdate, db: Sess return article @router.delete("/articles/{article_uuid}/", status_code=status.HTTP_204_NO_CONTENT) -def delete_article(article_uuid: UUID, db: Session = Depends(get_core_db)): +def delete_article( + article_uuid: UUID, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(has_role("admin")) +): """Elimina un artículo.""" if not HelpCenterService.delete(db, article_uuid): raise HTTPException(status_code=404, detail="Article not found") diff --git a/backend/api/v1/modules/core/help_center/schemas.py b/backend/api/v1/modules/core/help_center/schemas.py index 5f82995a..5fc67a37 100644 --- a/backend/api/v1/modules/core/help_center/schemas.py +++ b/backend/api/v1/modules/core/help_center/schemas.py @@ -50,7 +50,6 @@ class HelpSyncRequest(BaseModel): client_file_url: Optional[str] = None client_file_size: Optional[int] = None client_mime_type: Optional[str] = None - origin_client_uuid: Optional[UUID] = None class HelpSyncResponse(BaseModel): status: str diff --git a/backend/api/v1/modules/core/help_center/services.py b/backend/api/v1/modules/core/help_center/services.py index 31f91e93..8f6f36cb 100644 --- a/backend/api/v1/modules/core/help_center/services.py +++ b/backend/api/v1/modules/core/help_center/services.py @@ -149,11 +149,6 @@ class HelpCenterService: """ Lógica de sincronización "Smart Sync" (Last Write Wins). """ - # Loop Prevention: If the update originated from this client, ignore it. - from core.config import settings - if settings.CLIENT_UUID and sync_data.origin_client_uuid and str(sync_data.origin_client_uuid) == settings.CLIENT_UUID: - return HelpSyncResponse(status="OK", message="Skipped: I am the origin.") - db_article = db.query(HelpArticle).filter(HelpArticle.uuid == sync_data.article_uuid).first() client_updated_at = sync_data.client_updated_at diff --git a/backend/api/v1/modules/core/help_center/tasks.py b/backend/api/v1/modules/core/help_center/tasks.py index 659a015f..e9c2c6ab 100644 --- a/backend/api/v1/modules/core/help_center/tasks.py +++ b/backend/api/v1/modules/core/help_center/tasks.py @@ -38,10 +38,9 @@ def sync_single_article_task(article_uuid_str: str): @shared_task(name="broadcast_help_update") -def broadcast_help_update(article_uuid_str: str, origin_client_uuid_str: str = None): +def broadcast_help_update(article_uuid_str: str): """ - Difunde una actualización de artículo a todos los spokes configurados, - excepto al que originó el cambio (si existe). + Difunde una actualización de artículo a todos los spokes configurados. """ if not settings.SPOKE_URLS: logger.info("No SPOKE_URLS configured. Skipping broadcast.") @@ -65,18 +64,12 @@ def broadcast_help_update(article_uuid_str: str, origin_client_uuid_str: str = N client_slug=article.slug, last_editor=article.last_editor, client_category=article.category, - client_order=article.order, - origin_client_uuid=UUID(origin_client_uuid_str) if origin_client_uuid_str else None + client_order=article.order ).model_dump(mode='json') with httpx.Client() as client: for spoke_url in spokes: # Loop Prevention: Skip if the spoke is the origin - # NOTE: This assumes SPOKE_URLS contains the unique Sync URL of the client. - # A more robust check would be comparing CLIENT_UUID if we knew the URL->UUID mapping ahead of time. - # For this implementation, we rely on the `origin_client_uuid` being passed in the payload - # and the receiving client checking it against its own settings.CLIENT_UUID. - try: logger.info(f"Broadcasting update to {spoke_url}") response = client.post( @@ -120,8 +113,7 @@ def sync_single_article(article_uuid): client_slug=article.slug, last_editor=article.last_editor, client_category=article.category, - client_order=article.order, - origin_client_uuid=UUID(settings.CLIENT_UUID) if settings.CLIENT_UUID else None + client_order=article.order ) headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN} diff --git a/backend/core/config.py b/backend/core/config.py index 0304c7cb..f4b9cddc 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -44,7 +44,6 @@ class Settings(BaseSettings): SYNC_SECRET_TOKEN: str = "change-this-sync-token-in-production" CENTRAL_SERVER_URL: str = "http://localhost:8000/api/v1/core/help-center/sync/" SPOKE_URLS: str = "" # Comma separated list of Spoke URLs for Broadcast (Hub only) - CLIENT_UUID: str = "" # Unique identifier for this Client instance (Spoke only) # CORS CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000" diff --git a/backend/core/security.py b/backend/core/security.py index 0bcde710..7fb90728 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -106,6 +106,10 @@ def has_role(required_role: str): user_roles = current_user.get("realm_access", {}).get("roles", []) if required_role not in user_roles: + logger.warning(f"Role denied. Required: {required_role}. User actually has: {user_roles}") + # Also check client roles as a debug fallback + client_roles = current_user.get("resource_access", {}) + logger.warning(f"User client roles: {client_roles}") raise HTTPException( status_code=403, detail=f"User does not have required role: {required_role}", diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ccb78bdb..9b422bfa 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -180,7 +180,6 @@ services: - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0} - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""} - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production} - - CLIENT_UUID=${CLIENT_UUID:-""} - SPOKE_URLS=${SPOKE_URLS:-""} ports: - "3467:8000" @@ -225,7 +224,6 @@ services: - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0} - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""} - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production} - - CLIENT_UUID=${CLIENT_UUID:-""} - SPOKE_URLS=${SPOKE_URLS:-""} - CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76} - CORE_DB_PORT=${CORE_DB_PORT:-5432} diff --git a/docker-compose.yml b/docker-compose.yml index f15f8519..7beec5d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -184,7 +184,6 @@ services: - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0} - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""} - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production} - - CLIENT_UUID=${CLIENT_UUID:-""} - SPOKE_URLS=${SPOKE_URLS:-""} ports: - "8000:8000" @@ -285,7 +284,6 @@ services: - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0} - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""} - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production} - - CLIENT_UUID=${CLIENT_UUID:-""} - SPOKE_URLS=${SPOKE_URLS:-""} - CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76} - CORE_DB_PORT=${CORE_DB_PORT:-5432} @@ -310,7 +308,6 @@ services: - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0} - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""} - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production} - - CLIENT_UUID=${CLIENT_UUID:-""} - SPOKE_URLS=${SPOKE_URLS:-""} - CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76} - CORE_DB_PORT=${CORE_DB_PORT:-5432} diff --git a/frontend/src/lib/api/help.ts b/frontend/src/lib/api/help.ts index 7d1f4723..70693704 100644 --- a/frontend/src/lib/api/help.ts +++ b/frontend/src/lib/api/help.ts @@ -1,14 +1,27 @@ -import { authStore } from '$lib/auth'; +import { getToken, authStore } from '$lib/auth'; import { get } from 'svelte/store'; const api_url = import.meta.env.VITE_API_URL; const BASE_URL = `${api_url.endsWith('/') ? api_url : api_url + '/'}v1/core/help-center`; +function getAuthToken(): string | null { + // 1. First try getToken() which checks Keycloak and localStorage + let token = getToken(); + + // 2. If somehow empty, explicitly check authStore value + if (!token) { + const auth = get(authStore); + token = auth.token; + } + + return token; +} + function getHeaders() { - const auth = get(authStore); + const token = getAuthToken(); return { 'Content-Type': 'application/json', - ...(auth.token ? { 'Authorization': `Bearer ${auth.token}` } : {}) + ...(token ? { 'Authorization': `Bearer ${token}` } : {}) }; } @@ -29,13 +42,13 @@ export interface HelpArticle { export const helpApi = { async listArticles(): Promise { - const response = await fetch(`${BASE_URL}/articles/`); + const response = await fetch(`${BASE_URL}/articles/`, { headers: getHeaders() }); if (!response.ok) throw new Error('Failed to fetch articles'); return response.json(); }, async getArticle(uuid: string): Promise { - const response = await fetch(`${BASE_URL}/articles/${uuid}/`); + const response = await fetch(`${BASE_URL}/articles/${uuid}/`, { headers: getHeaders() }); if (!response.ok) throw new Error('Failed to fetch article'); return response.json(); }, @@ -46,7 +59,8 @@ export const helpApi = { headers: getHeaders(), body: JSON.stringify(data) }); - if (!response.ok) throw new Error('Failed to update article'); + if (response.status === 403) throw new Error('No tienes permisos para editar artículos (Requiere rol Admin)'); + if (!response.ok) throw new Error('Error al guardar cambios'); return response.json(); }, @@ -56,7 +70,8 @@ export const helpApi = { headers: getHeaders(), body: JSON.stringify(data) }); - if (!response.ok) throw new Error('Failed to create article'); + if (response.status === 403) throw new Error('No tienes permisos para crear artículos (Requiere rol Admin)'); + if (!response.ok) throw new Error('Error al crear el artículo'); return response.json(); }, @@ -65,7 +80,8 @@ export const helpApi = { method: 'DELETE', headers: getHeaders() }); - if (!response.ok) throw new Error('Failed to delete article'); + if (response.status === 403) throw new Error('No tienes permisos para eliminar (Requiere rol Admin)'); + if (!response.ok) throw new Error('Error al eliminar'); }, async triggerSync(): Promise { @@ -76,15 +92,17 @@ export const helpApi = { const formData = new FormData(); formData.append('file', file); + const token = getAuthToken(); const response = await fetch(`${BASE_URL}/upload-image/`, { method: 'POST', // No Content-Type header for FormData, browser sets it with boundary headers: { - ...(get(authStore).token ? { 'Authorization': `Bearer ${get(authStore).token}` } : {}) + ...(token ? { 'Authorization': `Bearer ${token}` } : {}) }, body: formData }); - if (!response.ok) throw new Error('Failed to upload image'); + if (response.status === 403) throw new Error('No tienes permisos para subir imágenes (Requiere rol Admin)'); + if (!response.ok) throw new Error('Error al subir imagen'); return response.json(); }, @@ -92,14 +110,16 @@ export const helpApi = { const formData = new FormData(); formData.append('file', file); + const token = getAuthToken(); const response = await fetch(`${BASE_URL}/upload-asset/`, { method: 'POST', headers: { - ...(get(authStore).token ? { 'Authorization': `Bearer ${get(authStore).token}` } : {}) + ...(token ? { 'Authorization': `Bearer ${token}` } : {}) }, body: formData }); - if (!response.ok) throw new Error('Failed to upload asset'); + if (response.status === 403) throw new Error('No tienes permisos para subir archivos (Requiere rol Admin)'); + if (!response.ok) throw new Error('Error al subir archivo'); return response.json(); } }; diff --git a/start.sh b/start.sh index 462c55db..130493d3 100755 --- a/start.sh +++ b/start.sh @@ -95,12 +95,13 @@ if ! grep -q "VITE_HUB_MODE=" .env || [ "${RECONFIGURE:-false}" == "true" ]; the read -p "Seleccione una opción [1-2, Enter para omitir]: " SERVER_ROLE if [ "$SERVER_ROLE" == "1" ] || [ "$SERVER_ROLE" == "2" ]; then - SYNC_TOKEN=$(openssl rand -hex 16 2>/dev/null || echo "dev-sync-token-$(date +%s)") + GENERATED_TOKEN=$(openssl rand -hex 16 2>/dev/null || echo "dev-sync-token-$(date +%s)") + read -p "Ingrese el Token de Sincronización Secreto [Presione Enter para generar uno aleatorio: $GENERATED_TOKEN]: " SYNC_TOKEN + SYNC_TOKEN=${SYNC_TOKEN:-$GENERATED_TOKEN} if [ "$SERVER_ROLE" == "1" ]; then echo -e "${GREEN}Configurando como HUB...${NC}" CENTRAL_URL="" - CLIENT_ID="" SPOKE_URLS="" HUB_MODE="true" else @@ -108,15 +109,12 @@ if ! grep -q "VITE_HUB_MODE=" .env || [ "${RECONFIGURE:-false}" == "true" ]; the HUB_MODE="false" read -p "Ingrese la URL del Hub [http://100.78.6.108:8001/api/v1/core/help-center/sync/]: " CENTRAL_URL CENTRAL_URL=${CENTRAL_URL:-http://100.78.6.108:8001/api/v1/core/help-center/sync/} - read -p "Ingrese el UUID de este cliente (presione Enter para generar uno): " CLIENT_ID - CLIENT_ID=${CLIENT_ID:-$(cat /proc/sys/kernel/random/uuid 2>/dev/null || echo "client-$(date +%s)")} SPOKE_URLS="" fi # Aplicar configuraciones sed -i "s|^CENTRAL_SERVER_URL=.*|CENTRAL_SERVER_URL=$CENTRAL_URL|" .env 2>/dev/null || echo "CENTRAL_SERVER_URL=$CENTRAL_URL" >> .env sed -i "s|^SPOKE_URLS=.*|SPOKE_URLS=$SPOKE_URLS|" .env 2>/dev/null || echo "SPOKE_URLS=$SPOKE_URLS" >> .env - sed -i "s|^CLIENT_UUID=.*|CLIENT_UUID=$CLIENT_ID|" .env 2>/dev/null || echo "CLIENT_UUID=$CLIENT_ID" >> .env if ! grep -q "SYNC_SECRET_TOKEN=" .env; then echo "SYNC_SECRET_TOKEN=$SYNC_TOKEN" >> .env fi