fix: apply code review security and robustness fixes
- Add require_engineer_or_admin to POST/PUT/DELETE in target_lists.py (blocks viewers from write ops) - Add require_engineer_or_admin to POST/PATCH in maintenance_schedules.py (blocks viewers from write ops) - Add team ownership guard in batch_launch_sessions after active/published checks (Fix 2) - Wrap scheduler.remove_job in try/except for SchedulerNotRunningError and JobLookupError (Fix 3) - Recompute next_run_at when is_active flips to True, capturing was_active before update (Fix 4) - Add optional batch_id and target_label fields to Session type; remove unsafe cast in MaintenanceFlowDetailPage.tsx (Fix 5) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from croniter import croniter
|
||||
import pytz
|
||||
|
||||
from app.api.deps import get_current_active_user, get_db
|
||||
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
|
||||
from app.models.maintenance_schedule import MaintenanceSchedule
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
@@ -47,6 +47,7 @@ async def create_schedule(
|
||||
data: MaintenanceScheduleCreate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Create a cron schedule for a maintenance flow. One per flow."""
|
||||
# Verify user's team owns the tree
|
||||
@@ -108,6 +109,7 @@ async def update_schedule(
|
||||
data: MaintenanceScheduleUpdate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Update a schedule (disable, change cron, change timezone, change target list)."""
|
||||
result = await db.execute(
|
||||
@@ -121,6 +123,7 @@ async def update_schedule(
|
||||
await _get_tree_or_403(schedule.tree_id, current_user, db)
|
||||
|
||||
update_fields = data.model_fields_set
|
||||
was_active = schedule.is_active
|
||||
if "cron_expression" in update_fields and data.cron_expression is not None:
|
||||
schedule.cron_expression = data.cron_expression
|
||||
if "timezone" in update_fields and data.timezone is not None:
|
||||
@@ -130,8 +133,9 @@ async def update_schedule(
|
||||
if "is_active" in update_fields and data.is_active is not None:
|
||||
schedule.is_active = data.is_active
|
||||
|
||||
# Recompute next_run_at if schedule timing changed
|
||||
if "cron_expression" in update_fields or "timezone" in update_fields:
|
||||
# Recompute next_run_at if schedule timing changed or schedule is being re-activated
|
||||
reactivating = "is_active" in update_fields and data.is_active is True and not was_active
|
||||
if "cron_expression" in update_fields or "timezone" in update_fields or reactivating:
|
||||
try:
|
||||
schedule.next_run_at = _compute_next_run(schedule.cron_expression, schedule.timezone)
|
||||
except (ValueError, KeyError) as e:
|
||||
|
||||
@@ -528,6 +528,9 @@ async def batch_launch_sessions(
|
||||
if tree.status == 'draft':
|
||||
raise HTTPException(status_code=400, detail="Cannot batch-launch a draft flow")
|
||||
|
||||
if not current_user.is_super_admin and tree.team_id != current_user.team_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
if tree.tree_type != "maintenance":
|
||||
raise HTTPException(status_code=400, detail="Batch launch is only for maintenance flows")
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user, get_db
|
||||
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
|
||||
from app.models.target_list import TargetList
|
||||
from app.models.user import User
|
||||
from app.schemas.target_list import TargetListCreate, TargetListUpdate, TargetListResponse
|
||||
@@ -34,6 +34,7 @@ async def create_target_list(
|
||||
data: TargetListCreate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Create a new target list for the current team."""
|
||||
if not current_user.team_id:
|
||||
@@ -75,6 +76,7 @@ async def update_target_list(
|
||||
data: TargetListUpdate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(TargetList).where(
|
||||
@@ -102,6 +104,7 @@ async def delete_target_list(
|
||||
list_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(TargetList).where(
|
||||
|
||||
@@ -4,6 +4,8 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.schedulers.base import SchedulerNotRunningError
|
||||
from apscheduler.jobstores.base import JobLookupError
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
import pytz
|
||||
from sqlalchemy import select
|
||||
@@ -135,5 +137,8 @@ def unregister_schedule(schedule_id: str) -> None:
|
||||
"""Remove a schedule from APScheduler."""
|
||||
job_id = f"maintenance_{schedule_id}"
|
||||
if scheduler.get_job(job_id):
|
||||
scheduler.remove_job(job_id)
|
||||
logger.info(f"Unregistered schedule {schedule_id}")
|
||||
try:
|
||||
scheduler.remove_job(job_id)
|
||||
logger.info(f"Unregistered schedule {schedule_id}")
|
||||
except (SchedulerNotRunningError, JobLookupError):
|
||||
logger.warning(f"Could not remove job {job_id}: scheduler not running or job already removed")
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function MaintenanceFlowDetailPage() {
|
||||
// Group sessions by batch_id for run history
|
||||
const batchMap = new Map<string, Session[]>()
|
||||
for (const s of recentSessions) {
|
||||
const key = (s as Session & { batch_id?: string }).batch_id ?? s.id
|
||||
const key = s.batch_id ?? s.id
|
||||
const existing = batchMap.get(key) ?? []
|
||||
batchMap.set(key, [...existing, s])
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface Session {
|
||||
scratchpad: string
|
||||
next_steps: string
|
||||
session_variables: Record<string, string>
|
||||
batch_id?: string
|
||||
target_label?: string
|
||||
}
|
||||
|
||||
export interface SessionCreate {
|
||||
|
||||
Reference in New Issue
Block a user