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

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