mirror of
https://github.com/mblanke/ThreatHunt.git
synced 2026-03-01 05:50:21 -05:00
297 lines
9.9 KiB
Python
297 lines
9.9 KiB
Python
"""API routes for case management — CRUD for cases, tasks, and activity logs."""
|
|
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select, func, desc
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db import get_db
|
|
from app.db.models import Case, CaseTask, ActivityLog, _new_id, _utcnow
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/cases", tags=["cases"])
|
|
|
|
|
|
# ── Pydantic models ──────────────────────────────────────────────────
|
|
|
|
class CaseCreate(BaseModel):
|
|
title: str
|
|
description: Optional[str] = None
|
|
severity: str = "medium"
|
|
tlp: str = "amber"
|
|
pap: str = "amber"
|
|
priority: int = 2
|
|
assignee: Optional[str] = None
|
|
tags: Optional[list[str]] = None
|
|
hunt_id: Optional[str] = None
|
|
mitre_techniques: Optional[list[str]] = None
|
|
iocs: Optional[list[dict]] = None
|
|
|
|
|
|
class CaseUpdate(BaseModel):
|
|
title: Optional[str] = None
|
|
description: Optional[str] = None
|
|
severity: Optional[str] = None
|
|
tlp: Optional[str] = None
|
|
pap: Optional[str] = None
|
|
status: Optional[str] = None
|
|
priority: Optional[int] = None
|
|
assignee: Optional[str] = None
|
|
tags: Optional[list[str]] = None
|
|
mitre_techniques: Optional[list[str]] = None
|
|
iocs: Optional[list[dict]] = None
|
|
|
|
|
|
class TaskCreate(BaseModel):
|
|
title: str
|
|
description: Optional[str] = None
|
|
assignee: Optional[str] = None
|
|
|
|
|
|
class TaskUpdate(BaseModel):
|
|
title: Optional[str] = None
|
|
description: Optional[str] = None
|
|
status: Optional[str] = None
|
|
assignee: Optional[str] = None
|
|
order: Optional[int] = None
|
|
|
|
|
|
# ── Helper: log activity ─────────────────────────────────────────────
|
|
|
|
async def _log_activity(
|
|
db: AsyncSession,
|
|
entity_type: str,
|
|
entity_id: str,
|
|
action: str,
|
|
details: dict | None = None,
|
|
):
|
|
log = ActivityLog(
|
|
entity_type=entity_type,
|
|
entity_id=entity_id,
|
|
action=action,
|
|
details=details,
|
|
created_at=_utcnow(),
|
|
)
|
|
db.add(log)
|
|
|
|
|
|
# ── Case CRUD ─────────────────────────────────────────────────────────
|
|
|
|
@router.post("", summary="Create a case")
|
|
async def create_case(body: CaseCreate, db: AsyncSession = Depends(get_db)):
|
|
now = _utcnow()
|
|
case = Case(
|
|
id=_new_id(),
|
|
title=body.title,
|
|
description=body.description,
|
|
severity=body.severity,
|
|
tlp=body.tlp,
|
|
pap=body.pap,
|
|
priority=body.priority,
|
|
assignee=body.assignee,
|
|
tags=body.tags,
|
|
hunt_id=body.hunt_id,
|
|
mitre_techniques=body.mitre_techniques,
|
|
iocs=body.iocs,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
db.add(case)
|
|
await _log_activity(db, "case", case.id, "created", {"title": body.title})
|
|
await db.commit()
|
|
await db.refresh(case)
|
|
return _case_to_dict(case)
|
|
|
|
|
|
@router.get("", summary="List cases")
|
|
async def list_cases(
|
|
status: Optional[str] = Query(None),
|
|
hunt_id: Optional[str] = Query(None),
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
q = select(Case).order_by(desc(Case.updated_at))
|
|
if status:
|
|
q = q.where(Case.status == status)
|
|
if hunt_id:
|
|
q = q.where(Case.hunt_id == hunt_id)
|
|
q = q.offset(offset).limit(limit)
|
|
result = await db.execute(q)
|
|
cases = result.scalars().all()
|
|
|
|
count_q = select(func.count(Case.id))
|
|
if status:
|
|
count_q = count_q.where(Case.status == status)
|
|
if hunt_id:
|
|
count_q = count_q.where(Case.hunt_id == hunt_id)
|
|
total = (await db.execute(count_q)).scalar() or 0
|
|
|
|
return {"cases": [_case_to_dict(c) for c in cases], "total": total}
|
|
|
|
|
|
@router.get("/{case_id}", summary="Get case detail")
|
|
async def get_case(case_id: str, db: AsyncSession = Depends(get_db)):
|
|
case = await db.get(Case, case_id)
|
|
if not case:
|
|
raise HTTPException(status_code=404, detail="Case not found")
|
|
return _case_to_dict(case)
|
|
|
|
|
|
@router.put("/{case_id}", summary="Update a case")
|
|
async def update_case(case_id: str, body: CaseUpdate, db: AsyncSession = Depends(get_db)):
|
|
case = await db.get(Case, case_id)
|
|
if not case:
|
|
raise HTTPException(status_code=404, detail="Case not found")
|
|
changes = {}
|
|
for field in ["title", "description", "severity", "tlp", "pap", "status",
|
|
"priority", "assignee", "tags", "mitre_techniques", "iocs"]:
|
|
val = getattr(body, field)
|
|
if val is not None:
|
|
old = getattr(case, field)
|
|
setattr(case, field, val)
|
|
changes[field] = {"old": old, "new": val}
|
|
if "status" in changes and changes["status"]["new"] == "in-progress" and not case.started_at:
|
|
case.started_at = _utcnow()
|
|
if "status" in changes and changes["status"]["new"] in ("resolved", "closed"):
|
|
case.resolved_at = _utcnow()
|
|
case.updated_at = _utcnow()
|
|
await _log_activity(db, "case", case.id, "updated", changes)
|
|
await db.commit()
|
|
await db.refresh(case)
|
|
return _case_to_dict(case)
|
|
|
|
|
|
@router.delete("/{case_id}", summary="Delete a case")
|
|
async def delete_case(case_id: str, db: AsyncSession = Depends(get_db)):
|
|
case = await db.get(Case, case_id)
|
|
if not case:
|
|
raise HTTPException(status_code=404, detail="Case not found")
|
|
await db.delete(case)
|
|
await db.commit()
|
|
return {"deleted": True}
|
|
|
|
|
|
# ── Task CRUD ─────────────────────────────────────────────────────────
|
|
|
|
@router.post("/{case_id}/tasks", summary="Add task to case")
|
|
async def create_task(case_id: str, body: TaskCreate, db: AsyncSession = Depends(get_db)):
|
|
case = await db.get(Case, case_id)
|
|
if not case:
|
|
raise HTTPException(status_code=404, detail="Case not found")
|
|
now = _utcnow()
|
|
task = CaseTask(
|
|
id=_new_id(),
|
|
case_id=case_id,
|
|
title=body.title,
|
|
description=body.description,
|
|
assignee=body.assignee,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
db.add(task)
|
|
await _log_activity(db, "case", case_id, "task_created", {"title": body.title})
|
|
await db.commit()
|
|
await db.refresh(task)
|
|
return _task_to_dict(task)
|
|
|
|
|
|
@router.put("/{case_id}/tasks/{task_id}", summary="Update a task")
|
|
async def update_task(case_id: str, task_id: str, body: TaskUpdate, db: AsyncSession = Depends(get_db)):
|
|
task = await db.get(CaseTask, task_id)
|
|
if not task or task.case_id != case_id:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
for field in ["title", "description", "status", "assignee", "order"]:
|
|
val = getattr(body, field)
|
|
if val is not None:
|
|
setattr(task, field, val)
|
|
task.updated_at = _utcnow()
|
|
await _log_activity(db, "case", case_id, "task_updated", {"task_id": task_id})
|
|
await db.commit()
|
|
await db.refresh(task)
|
|
return _task_to_dict(task)
|
|
|
|
|
|
@router.delete("/{case_id}/tasks/{task_id}", summary="Delete a task")
|
|
async def delete_task(case_id: str, task_id: str, db: AsyncSession = Depends(get_db)):
|
|
task = await db.get(CaseTask, task_id)
|
|
if not task or task.case_id != case_id:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
await db.delete(task)
|
|
await db.commit()
|
|
return {"deleted": True}
|
|
|
|
|
|
# ── Activity Log ──────────────────────────────────────────────────────
|
|
|
|
@router.get("/{case_id}/activity", summary="Get case activity log")
|
|
async def get_activity(
|
|
case_id: str,
|
|
limit: int = Query(50, ge=1, le=200),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
q = (
|
|
select(ActivityLog)
|
|
.where(ActivityLog.entity_type == "case", ActivityLog.entity_id == case_id)
|
|
.order_by(desc(ActivityLog.created_at))
|
|
.limit(limit)
|
|
)
|
|
result = await db.execute(q)
|
|
logs = result.scalars().all()
|
|
return {
|
|
"logs": [
|
|
{
|
|
"id": l.id,
|
|
"action": l.action,
|
|
"details": l.details,
|
|
"user_id": l.user_id,
|
|
"created_at": l.created_at.isoformat() if l.created_at else None,
|
|
}
|
|
for l in logs
|
|
]
|
|
}
|
|
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────
|
|
|
|
def _case_to_dict(c: Case) -> dict:
|
|
return {
|
|
"id": c.id,
|
|
"title": c.title,
|
|
"description": c.description,
|
|
"severity": c.severity,
|
|
"tlp": c.tlp,
|
|
"pap": c.pap,
|
|
"status": c.status,
|
|
"priority": c.priority,
|
|
"assignee": c.assignee,
|
|
"tags": c.tags or [],
|
|
"hunt_id": c.hunt_id,
|
|
"owner_id": c.owner_id,
|
|
"mitre_techniques": c.mitre_techniques or [],
|
|
"iocs": c.iocs or [],
|
|
"started_at": c.started_at.isoformat() if c.started_at else None,
|
|
"resolved_at": c.resolved_at.isoformat() if c.resolved_at else None,
|
|
"created_at": c.created_at.isoformat() if c.created_at else None,
|
|
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
|
"tasks": [_task_to_dict(t) for t in (c.tasks or [])],
|
|
}
|
|
|
|
|
|
def _task_to_dict(t: CaseTask) -> dict:
|
|
return {
|
|
"id": t.id,
|
|
"case_id": t.case_id,
|
|
"title": t.title,
|
|
"description": t.description,
|
|
"status": t.status,
|
|
"assignee": t.assignee,
|
|
"order": t.order,
|
|
"created_at": t.created_at.isoformat() if t.created_at else None,
|
|
"updated_at": t.updated_at.isoformat() if t.updated_at else None,
|
|
}
|