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

@@ -432,6 +432,94 @@ class CRUDIssue(CRUDBase[Issue, IssueCreate, IssueUpdate]):
logger.error(f"Error getting pending sync issues: {e!s}", exc_info=True)
raise
async def remove_sprint_from_issues(
self,
db: AsyncSession,
*,
sprint_id: UUID,
) -> int:
"""Remove sprint assignment from all issues in a sprint.
Used when deleting a sprint to clean up references.
Returns:
Number of issues updated
"""
try:
from sqlalchemy import update
result = await db.execute(
update(Issue)
.where(Issue.sprint_id == sprint_id)
.values(sprint_id=None)
)
await db.commit()
return result.rowcount
except Exception as e:
await db.rollback()
logger.error(
f"Error removing sprint {sprint_id} from issues: {e!s}",
exc_info=True,
)
raise
async def unassign(
self,
db: AsyncSession,
*,
issue_id: UUID,
) -> Issue | None:
"""Remove agent assignment from an issue.
Returns:
Updated issue or None if not found
"""
try:
result = await db.execute(select(Issue).where(Issue.id == issue_id))
issue = result.scalar_one_or_none()
if not issue:
return None
issue.assigned_agent_id = None
await db.commit()
await db.refresh(issue)
return issue
except Exception as e:
await db.rollback()
logger.error(f"Error unassigning issue {issue_id}: {e!s}", exc_info=True)
raise
async def remove_from_sprint(
self,
db: AsyncSession,
*,
issue_id: UUID,
) -> Issue | None:
"""Remove an issue from its current sprint.
Returns:
Updated issue or None if not found
"""
try:
result = await db.execute(select(Issue).where(Issue.id == issue_id))
issue = result.scalar_one_or_none()
if not issue:
return None
issue.sprint_id = None
await db.commit()
await db.refresh(issue)
return issue
except Exception as e:
await db.rollback()
logger.error(
f"Error removing issue {issue_id} from sprint: {e!s}",
exc_info=True,
)
raise
# Create a singleton instance for use across the application
issue = CRUDIssue(Issue)

View File

@@ -185,9 +185,18 @@ class CRUDSprint(CRUDBase[Sprint, SprintCreate, SprintUpdate]):
sprint_id: UUID,
start_date: date | None = None,
) -> Sprint | None:
"""Start a planned sprint."""
"""Start a planned sprint.
Uses row-level locking (SELECT FOR UPDATE) to prevent race conditions
when multiple requests try to start sprints concurrently.
"""
try:
result = await db.execute(select(Sprint).where(Sprint.id == sprint_id))
# Lock the sprint row to prevent concurrent modifications
result = await db.execute(
select(Sprint)
.where(Sprint.id == sprint_id)
.with_for_update()
)
sprint = result.scalar_one_or_none()
if not sprint:
@@ -198,8 +207,17 @@ class CRUDSprint(CRUDBase[Sprint, SprintCreate, SprintUpdate]):
f"Cannot start sprint with status {sprint.status.value}"
)
# Check for existing active sprint in project
active_sprint = await self.get_active_sprint(db, project_id=sprint.project_id)
# Check for existing active sprint with lock to prevent race condition
# Lock all sprints for this project to ensure atomic check-and-update
active_check = await db.execute(
select(Sprint)
.where(
Sprint.project_id == sprint.project_id,
Sprint.status == SprintStatus.ACTIVE,
)
.with_for_update()
)
active_sprint = active_check.scalar_one_or_none()
if active_sprint:
raise ValueError(
f"Project already has an active sprint: {active_sprint.name}"