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:
chihlasm
2026-02-17 16:15:19 -05:00
parent a4717e9dd7
commit 6240d68d09
6 changed files with 24 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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