Files
server-26/drb-c2-core/app/routers/incidents.py
Logan 18d96193ab Security fixes
auth.py

secrets.compare_digest replaces == for service key comparison (timing-safe)
Added require_service_key — bot-only endpoints (trip/event join/leave)
Added require_service_key_or_admin — node commands/config (bot via service key OR dashboard admin via Firebase)
Added _RateLimiter with three shared instances: trip_chat_limiter (20/5min per user), summarize_limiter (5/10min per incident), bootstrap_limiter (2/hr per system)
nodes.py

send_command and assign_system now require require_service_key_or_admin — the Discord bot can still call them via service key, but regular Firebase users are blocked
tokens.py

add_token, flush_tokens, set_preferred_system, delete_token all require require_admin_token
Token masking changed from token[:10] + "…" + token[-4:] to "•••" + token[-4:]
systems.py

All write endpoints (create, update, delete, ai-flags, ten-codes, vocabulary writes, bootstrap) now require require_admin_token
bootstrap_vocabulary also calls bootstrap_limiter.check(system_id)
incidents.py

POST /incidents/summarize (bulk) now requires require_admin_token
POST /incidents/{id}/summarize now calls summarize_limiter.check(incident_id)
trips.py

join_trip, leave_trip, join_event, leave_event require require_service_key — only the Discord bot can set Discord attendee identity
delete_trip, delete_event require require_service_key_or_admin
trip_chat rate-limited per caller UID, history stripped to user/assistant roles only, user message truncated to 2000 chars, Maps query strings capped at 200 chars
upload.py

Rejects files larger than settings.upload_max_bytes (default 100MB) with 413
storage.py

_safe_audio_filename() derives GCS object name from call_id + allowlisted extension, completely ignoring the client-supplied filename
config.py

Added upload_max_bytes: int = 100 * 1024 * 1024
Both Dockerfiles — python:3.14-slim → python:3.12-slim
2026-06-21 13:40:08 -04:00

112 lines
4.0 KiB
Python

import uuid
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
from app.models import IncidentCreate, IncidentUpdate
from app.internal import firestore as fstore
from app.internal.auth import require_admin_token, require_service_or_firebase_token, summarize_limiter
router = APIRouter(prefix="/incidents", tags=["incidents"])
@router.get("")
async def list_incidents(status: Optional[str] = None, type: Optional[str] = None):
filters = {}
if status:
filters["status"] = status
if type:
filters["type"] = type
return await fstore.collection_list("incidents", **filters)
@router.post("/summarize")
async def summarize_all_stale(
background_tasks: BackgroundTasks,
_: dict = Depends(require_admin_token),
):
"""Immediately run the summarizer pass on all stale incidents (don't wait for the next interval)."""
from app.internal.summarizer import _run_summary_pass
background_tasks.add_task(_run_summary_pass)
return {"ok": True}
@router.get("/{incident_id}")
async def get_incident(incident_id: str):
doc = await fstore.doc_get("incidents", incident_id)
if not doc:
raise HTTPException(404, f"Incident '{incident_id}' not found.")
return doc
@router.post("")
async def create_incident(body: IncidentCreate, _: dict = Depends(require_admin_token)):
now = datetime.now(timezone.utc).isoformat()
incident_id = str(uuid.uuid4())
doc = {
"incident_id": incident_id,
"title": body.title,
"type": body.type,
"status": body.status,
"location": body.location,
"call_ids": body.call_ids,
"summary": body.summary,
"tags": body.tags,
"started_at": now,
"updated_at": now,
}
await fstore.doc_set("incidents", incident_id, doc, merge=False)
return doc
@router.put("/{incident_id}")
async def update_incident(incident_id: str, body: IncidentUpdate, _: dict = Depends(require_admin_token)):
doc = await fstore.doc_get("incidents", incident_id)
if not doc:
raise HTTPException(404, f"Incident '{incident_id}' not found.")
updates = body.model_dump(exclude_none=True)
updates["updated_at"] = datetime.now(timezone.utc).isoformat()
await fstore.doc_update("incidents", incident_id, updates)
return {**doc, **updates}
@router.delete("/{incident_id}")
async def delete_incident(incident_id: str, _: dict = Depends(require_admin_token)):
doc = await fstore.doc_get("incidents", incident_id)
if not doc:
raise HTTPException(404, f"Incident '{incident_id}' not found.")
await fstore.doc_delete("incidents", incident_id)
return {"ok": True}
@router.post("/{incident_id}/summarize")
async def summarize_incident(
incident_id: str,
background_tasks: BackgroundTasks,
decoded: dict = Depends(require_service_or_firebase_token),
):
"""Immediately run the summarizer for a specific incident."""
from app.internal.summarizer import _summarize_incident
inc = await fstore.doc_get("incidents", incident_id)
if not inc:
raise HTTPException(404, f"Incident '{incident_id}' not found.")
# Rate limit by incident ID to prevent repeated expensive LLM calls
summarize_limiter.check(incident_id)
background_tasks.add_task(_summarize_incident, inc)
return {"ok": True, "incident_id": incident_id}
@router.post("/{incident_id}/calls/{call_id}")
async def link_call_to_incident(incident_id: str, call_id: str, _: dict = Depends(require_admin_token)):
doc = await fstore.doc_get("incidents", incident_id)
if not doc:
raise HTTPException(404, f"Incident '{incident_id}' not found.")
call_ids = doc.get("call_ids", [])
if call_id not in call_ids:
call_ids.append(call_id)
await fstore.doc_update("incidents", incident_id, {
"call_ids": call_ids,
"updated_at": datetime.now(timezone.utc).isoformat(),
})
await fstore.doc_update("calls", call_id, {"incident_id": incident_id})
return {"ok": True}