Puestas de seguridad y limpieza de codigo

This commit is contained in:
2026-02-25 10:34:11 -06:00
parent 96757ffdd2
commit e4d4d0cce6
11 changed files with 88 additions and 53 deletions

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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}

View File

@@ -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"

View File

@@ -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}",

View File

@@ -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}

View File

@@ -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}

View File

@@ -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<HelpArticle[]> {
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<HelpArticle> {
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<void> {
@@ -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();
}
};

View File

@@ -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