fix: Add missing API endpoints and validation improvements

- Add cancel_sprint and delete_sprint endpoints to sprints.py
- Add unassign_issue endpoint to issues.py
- Add remove_issue_from_sprint endpoint to sprints.py
- Add CRUD methods: remove_sprint_from_issues, unassign, remove_from_sprint
- Add validation to prevent closed issues in active/planned sprints
- Add authorization tests for SSE events endpoint
- Fix IDOR vulnerabilities in agents.py and projects.py
- Add Syndarix models migration (0004)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 15:39:51 +01:00
parent b43fa8ace2
commit cea97afe25
10 changed files with 1156 additions and 74 deletions

View File

@@ -7,6 +7,7 @@ Users can create, read, update, and manage the lifecycle of their projects.
"""
import logging
import os
from typing import Any
from uuid import UUID
@@ -22,6 +23,7 @@ from app.core.exceptions import (
DuplicateError,
ErrorCode,
NotFoundError,
ValidationException,
)
from app.crud.syndarix.project import project as project_crud
from app.models.syndarix.enums import ProjectStatus
@@ -44,6 +46,10 @@ logger = logging.getLogger(__name__)
# Initialize rate limiter
limiter = Limiter(key_func=get_remote_address)
# Use higher rate limits in test environment
IS_TEST = os.getenv("IS_TEST", "False") == "True"
RATE_MULTIPLIER = 100 if IS_TEST else 1
def _build_project_response(project_data: dict[str, Any]) -> ProjectResponse:
"""
@@ -111,7 +117,7 @@ def _check_project_ownership(project: Any, current_user: User) -> None:
""",
operation_id="create_project",
)
@limiter.limit("10/minute")
@limiter.limit(f"{10 * RATE_MULTIPLIER}/minute")
async def create_project(
request: Request,
project_in: ProjectCreate,
@@ -184,7 +190,7 @@ async def create_project(
""",
operation_id="list_projects",
)
@limiter.limit("30/minute")
@limiter.limit(f"{30 * RATE_MULTIPLIER}/minute")
async def list_projects(
request: Request,
pagination: PaginationParams = Depends(),
@@ -247,7 +253,7 @@ async def list_projects(
""",
operation_id="get_project",
)
@limiter.limit("60/minute")
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
async def get_project(
request: Request,
project_id: UUID,
@@ -293,7 +299,7 @@ async def get_project(
""",
operation_id="get_project_by_slug",
)
@limiter.limit("60/minute")
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
async def get_project_by_slug(
request: Request,
slug: str,
@@ -348,7 +354,7 @@ async def get_project_by_slug(
""",
operation_id="update_project",
)
@limiter.limit("20/minute")
@limiter.limit(f"{20 * RATE_MULTIPLIER}/minute")
async def update_project(
request: Request,
project_id: UUID,
@@ -422,7 +428,7 @@ async def update_project(
""",
operation_id="archive_project",
)
@limiter.limit("10/minute")
@limiter.limit(f"{10 * RATE_MULTIPLIER}/minute")
async def archive_project(
request: Request,
project_id: UUID,
@@ -493,7 +499,7 @@ async def archive_project(
""",
operation_id="pause_project",
)
@limiter.limit("10/minute")
@limiter.limit(f"{10 * RATE_MULTIPLIER}/minute")
async def pause_project(
request: Request,
project_id: UUID,
@@ -516,23 +522,26 @@ async def pause_project(
_check_project_ownership(project, current_user)
# Validate current status
# Validate current status (business logic validation, not authorization)
if project.status == ProjectStatus.PAUSED:
raise AuthorizationError(
raise ValidationException(
message="Project is already paused",
error_code=ErrorCode.OPERATION_FORBIDDEN,
error_code=ErrorCode.VALIDATION_ERROR,
field="status",
)
if project.status == ProjectStatus.ARCHIVED:
raise AuthorizationError(
raise ValidationException(
message="Cannot pause an archived project",
error_code=ErrorCode.OPERATION_FORBIDDEN,
error_code=ErrorCode.VALIDATION_ERROR,
field="status",
)
if project.status == ProjectStatus.COMPLETED:
raise AuthorizationError(
raise ValidationException(
message="Cannot pause a completed project",
error_code=ErrorCode.OPERATION_FORBIDDEN,
error_code=ErrorCode.VALIDATION_ERROR,
field="status",
)
# Update status to PAUSED
@@ -552,7 +561,7 @@ async def pause_project(
return _build_project_response(project_data)
except (NotFoundError, AuthorizationError):
except (NotFoundError, AuthorizationError, ValidationException):
raise
except Exception as e:
logger.error(f"Error pausing project {project_id}: {e!s}", exc_info=True)
@@ -573,7 +582,7 @@ async def pause_project(
""",
operation_id="resume_project",
)
@limiter.limit("10/minute")
@limiter.limit(f"{10 * RATE_MULTIPLIER}/minute")
async def resume_project(
request: Request,
project_id: UUID,
@@ -596,23 +605,26 @@ async def resume_project(
_check_project_ownership(project, current_user)
# Validate current status
# Validate current status (business logic validation, not authorization)
if project.status == ProjectStatus.ACTIVE:
raise AuthorizationError(
raise ValidationException(
message="Project is already active",
error_code=ErrorCode.OPERATION_FORBIDDEN,
error_code=ErrorCode.VALIDATION_ERROR,
field="status",
)
if project.status == ProjectStatus.ARCHIVED:
raise AuthorizationError(
raise ValidationException(
message="Cannot resume an archived project",
error_code=ErrorCode.OPERATION_FORBIDDEN,
error_code=ErrorCode.VALIDATION_ERROR,
field="status",
)
if project.status == ProjectStatus.COMPLETED:
raise AuthorizationError(
raise ValidationException(
message="Cannot resume a completed project",
error_code=ErrorCode.OPERATION_FORBIDDEN,
error_code=ErrorCode.VALIDATION_ERROR,
field="status",
)
# Update status to ACTIVE
@@ -632,7 +644,7 @@ async def resume_project(
return _build_project_response(project_data)
except (NotFoundError, AuthorizationError):
except (NotFoundError, AuthorizationError, ValidationException):
raise
except Exception as e:
logger.error(f"Error resuming project {project_id}: {e!s}", exc_info=True)