Files
server-26/drb-c2-core/app/routers/systems.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

201 lines
7.3 KiB
Python

import uuid
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Dict, Optional
from app.models import SystemCreate, SystemRecord
from app.internal import firestore as fstore
from app.internal.auth import require_admin_token, bootstrap_limiter
router = APIRouter(prefix="/systems", tags=["systems"])
class VocabularyTermBody(BaseModel):
term: str
class TenCodesBody(BaseModel):
ten_codes: Dict[str, str]
class AiFlagsBody(BaseModel):
stt_enabled: Optional[bool] = None
correlation_enabled: Optional[bool] = None
@router.get("")
async def list_systems():
return await fstore.collection_list("systems")
@router.get("/{system_id}")
async def get_system(system_id: str):
system = await fstore.doc_get("systems", system_id)
if not system:
raise HTTPException(404, f"System '{system_id}' not found.")
return system
@router.post("", status_code=201)
async def create_system(body: SystemCreate, _: dict = Depends(require_admin_token)):
system_id = str(uuid.uuid4())
doc = SystemRecord(system_id=system_id, **body.model_dump())
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
return doc
@router.put("/{system_id}")
async def update_system(system_id: str, body: SystemCreate, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
await fstore.doc_update("systems", system_id, body.model_dump())
return {**existing, **body.model_dump()}
@router.delete("/{system_id}", status_code=204)
async def delete_system(system_id: str, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
await fstore.doc_delete("systems", system_id)
# ── Per-system AI flag overrides ──────────────────────────────────────────────
@router.put("/{system_id}/ai-flags")
async def update_system_ai_flags(
system_id: str,
body: AiFlagsBody,
_: dict = Depends(require_admin_token),
):
"""
Set per-system AI flag overrides. Only fields included in the body are
written; omitted fields remain unchanged (or absent, meaning inherit global).
Pass null to clear an override and fall back to the global flag.
"""
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
current: dict = existing.get("ai_flags") or {}
for field, value in body.model_dump(exclude_unset=True).items():
if value is None:
current.pop(field, None) # clear override → inherit global
else:
current[field] = value
await fstore.doc_update("systems", system_id, {"ai_flags": current})
return {"ok": True, "ai_flags": current}
# ── Ten-codes endpoints ────────────────────────────────────────────────────────
@router.get("/{system_id}/ten-codes")
async def get_ten_codes(system_id: str):
"""Return the ten-code dictionary for a system."""
system = await fstore.doc_get("systems", system_id)
if not system:
raise HTTPException(404, f"System '{system_id}' not found.")
return {"ten_codes": system.get("ten_codes") or {}}
@router.put("/{system_id}/ten-codes")
async def update_ten_codes(
system_id: str,
body: TenCodesBody,
_: dict = Depends(require_admin_token),
):
"""Replace the ten-code dictionary for a system."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
await fstore.doc_update("systems", system_id, {"ten_codes": body.ten_codes})
return {"ok": True, "ten_codes": body.ten_codes}
# ── Vocabulary endpoints ───────────────────────────────────────────────────────
@router.get("/{system_id}/vocabulary")
async def get_vocabulary(system_id: str):
"""Return approved vocabulary and pending induction suggestions."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
from app.internal.vocabulary_learner import get_vocabulary as _get
return await _get(system_id)
@router.post("/{system_id}/vocabulary/bootstrap", status_code=202)
async def bootstrap_vocabulary(
system_id: str,
decoded: dict = Depends(require_admin_token),
):
"""Trigger a one-shot GPT-4o bootstrap to seed the vocabulary from local knowledge."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
bootstrap_limiter.check(system_id)
from app.internal.vocabulary_learner import bootstrap_system_vocabulary
terms = await bootstrap_system_vocabulary(system_id)
return {"added": len(terms), "terms": terms}
@router.post("/{system_id}/vocabulary/terms")
async def add_vocabulary_term(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Manually add a term to the approved vocabulary."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
from app.internal.vocabulary_learner import add_term
await add_term(system_id, body.term.strip())
return {"ok": True}
@router.delete("/{system_id}/vocabulary/terms")
async def remove_vocabulary_term(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Remove a term from the approved vocabulary."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
from app.internal.vocabulary_learner import remove_term
await remove_term(system_id, body.term)
return {"ok": True}
@router.post("/{system_id}/vocabulary/pending/approve")
async def approve_pending(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Move a pending induction suggestion into the approved vocabulary."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
from app.internal.vocabulary_learner import approve_pending_term
await approve_pending_term(system_id, body.term)
return {"ok": True}
@router.post("/{system_id}/vocabulary/pending/dismiss")
async def dismiss_pending(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Dismiss a pending induction suggestion without adding it."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
from app.internal.vocabulary_learner import dismiss_pending_term
await dismiss_pending_term(system_id, body.term)
return {"ok": True}