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 from croniter import croniter
import pytz 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.maintenance_schedule import MaintenanceSchedule
from app.models.tree import Tree from app.models.tree import Tree
from app.models.user import User from app.models.user import User
@@ -47,6 +47,7 @@ async def create_schedule(
data: MaintenanceScheduleCreate, data: MaintenanceScheduleCreate,
current_user: Annotated[User, Depends(get_current_active_user)], current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
): ):
"""Create a cron schedule for a maintenance flow. One per flow.""" """Create a cron schedule for a maintenance flow. One per flow."""
# Verify user's team owns the tree # Verify user's team owns the tree
@@ -108,6 +109,7 @@ async def update_schedule(
data: MaintenanceScheduleUpdate, data: MaintenanceScheduleUpdate,
current_user: Annotated[User, Depends(get_current_active_user)], current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
): ):
"""Update a schedule (disable, change cron, change timezone, change target list).""" """Update a schedule (disable, change cron, change timezone, change target list)."""
result = await db.execute( result = await db.execute(
@@ -121,6 +123,7 @@ async def update_schedule(
await _get_tree_or_403(schedule.tree_id, current_user, db) await _get_tree_or_403(schedule.tree_id, current_user, db)
update_fields = data.model_fields_set update_fields = data.model_fields_set
was_active = schedule.is_active
if "cron_expression" in update_fields and data.cron_expression is not None: if "cron_expression" in update_fields and data.cron_expression is not None:
schedule.cron_expression = data.cron_expression schedule.cron_expression = data.cron_expression
if "timezone" in update_fields and data.timezone is not None: 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: if "is_active" in update_fields and data.is_active is not None:
schedule.is_active = data.is_active schedule.is_active = data.is_active
# Recompute next_run_at if schedule timing changed # Recompute next_run_at if schedule timing changed or schedule is being re-activated
if "cron_expression" in update_fields or "timezone" in update_fields: 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: try:
schedule.next_run_at = _compute_next_run(schedule.cron_expression, schedule.timezone) schedule.next_run_at = _compute_next_run(schedule.cron_expression, schedule.timezone)
except (ValueError, KeyError) as e: except (ValueError, KeyError) as e:

View File

@@ -528,6 +528,9 @@ async def batch_launch_sessions(
if tree.status == 'draft': if tree.status == 'draft':
raise HTTPException(status_code=400, detail="Cannot batch-launch a draft flow") 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": if tree.tree_type != "maintenance":
raise HTTPException(status_code=400, detail="Batch launch is only for maintenance flows") 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 import select
from sqlalchemy.ext.asyncio import AsyncSession 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.target_list import TargetList
from app.models.user import User from app.models.user import User
from app.schemas.target_list import TargetListCreate, TargetListUpdate, TargetListResponse from app.schemas.target_list import TargetListCreate, TargetListUpdate, TargetListResponse
@@ -34,6 +34,7 @@ async def create_target_list(
data: TargetListCreate, data: TargetListCreate,
current_user: Annotated[User, Depends(get_current_active_user)], current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
): ):
"""Create a new target list for the current team.""" """Create a new target list for the current team."""
if not current_user.team_id: if not current_user.team_id:
@@ -75,6 +76,7 @@ async def update_target_list(
data: TargetListUpdate, data: TargetListUpdate,
current_user: Annotated[User, Depends(get_current_active_user)], current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
): ):
result = await db.execute( result = await db.execute(
select(TargetList).where( select(TargetList).where(
@@ -102,6 +104,7 @@ async def delete_target_list(
list_id: UUID, list_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)], current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
): ):
result = await db.execute( result = await db.execute(
select(TargetList).where( select(TargetList).where(

View File

@@ -4,6 +4,8 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.schedulers.base import SchedulerNotRunningError
from apscheduler.jobstores.base import JobLookupError
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
import pytz import pytz
from sqlalchemy import select from sqlalchemy import select
@@ -135,5 +137,8 @@ def unregister_schedule(schedule_id: str) -> None:
"""Remove a schedule from APScheduler.""" """Remove a schedule from APScheduler."""
job_id = f"maintenance_{schedule_id}" job_id = f"maintenance_{schedule_id}"
if scheduler.get_job(job_id): if scheduler.get_job(job_id):
scheduler.remove_job(job_id) try:
logger.info(f"Unregistered schedule {schedule_id}") 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 // Group sessions by batch_id for run history
const batchMap = new Map<string, Session[]>() const batchMap = new Map<string, Session[]>()
for (const s of recentSessions) { 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) ?? [] const existing = batchMap.get(key) ?? []
batchMap.set(key, [...existing, s]) batchMap.set(key, [...existing, s])
} }

View File

@@ -60,6 +60,8 @@ export interface Session {
scratchpad: string scratchpad: string
next_steps: string next_steps: string
session_variables: Record<string, string> session_variables: Record<string, string>
batch_id?: string
target_label?: string
} }
export interface SessionCreate { export interface SessionCreate {