| 1 | """ |
| 2 | Routes for case template + template-task management (issue #792, Phase 2). |
| 3 | |
| 4 | The entire router is gated on the ``admin`` or ``analyst`` scope — customers |
| 5 | (``customer_user`` scope) cannot view or modify templates. Customer-facing |
| 6 | visibility of *applied* tasks on cases is handled separately in Phase 3 |
| 7 | where the read-only path is exposed under the case detail endpoints. |
| 8 | """ |
| 9 | |
| 10 | from typing import List |
| 11 | from typing import Optional |
| 12 | |
| 13 | from fastapi import APIRouter |
| 14 | from fastapi import Depends |
| 15 | from fastapi import HTTPException |
| 16 | from fastapi import Query |
| 17 | from fastapi import Security |
| 18 | from sqlalchemy import select |
| 19 | from sqlalchemy.ext.asyncio import AsyncSession |
| 20 | |
| 21 | from app.auth.utils import AuthHandler |
| 22 | from app.db.db_session import get_db |
| 23 | from app.incidents.models import CaseTemplate |
| 24 | from app.incidents.schema.case_templates import CaseTemplateCreate |
| 25 | from app.incidents.schema.case_templates import CaseTemplateLibraryEntry |
| 26 | from app.incidents.schema.case_templates import CaseTemplateLibraryListResponse |
| 27 | from app.incidents.schema.case_templates import CaseTemplateLibraryRefreshResponse |
| 28 | from app.incidents.schema.case_templates import CaseTemplateLibraryTask |
| 29 | from app.incidents.schema.case_templates import CaseTemplateListResponse |
| 30 | from app.incidents.schema.case_templates import CaseTemplateOperationResponse |
| 31 | from app.incidents.schema.case_templates import CaseTemplateTaskCreate |
| 32 | from app.incidents.schema.case_templates import CaseTemplateTaskOperationResponse |
| 33 | from app.incidents.schema.case_templates import CaseTemplateTaskUpdate |
| 34 | from app.incidents.schema.case_templates import CaseTemplateUpdate |
| 35 | from app.incidents.services import case_templates as service |
| 36 | from app.incidents.services import template_library |
| 37 | |
| 38 | # Scope guard applied to every route on this router. Returns the username, |
| 39 | # which we use as the audit actor for create operations. |
| 40 | _require_admin_or_analyst = AuthHandler().require_any_scope("admin", "analyst") |
| 41 | |
| 42 | case_templates_router = APIRouter( |
| 43 | dependencies=[Security(_require_admin_or_analyst)], |
| 44 | ) |
| 45 | |
| 46 | |
| 47 | # --------------------------------------------------------------------------- |
| 48 | # Template CRUD |
| 49 | # --------------------------------------------------------------------------- |
| 50 | |
| 51 | |
| 52 | @case_templates_router.get( |
| 53 | "", |
| 54 | response_model=CaseTemplateListResponse, |
| 55 | description="List case templates. Admin/analyst only.", |
| 56 | ) |
| 57 | async def list_case_templates( |
| 58 | customer_code: Optional[str] = Query( |
| 59 | None, |
| 60 | description="Filter to templates for this customer plus global templates (unless include_global=False).", |
| 61 | ), |
| 62 | source: Optional[str] = Query( |
| 63 | None, |
| 64 | description="Filter to templates for this alert source plus source-agnostic templates (unless include_global=False).", |
| 65 | ), |
| 66 | include_global: bool = Query( |
| 67 | True, |
| 68 | description="When filtering by customer_code/source, also include rows where that field is NULL (i.e., global / any).", |
| 69 | ), |
| 70 | db: AsyncSession = Depends(get_db), |
| 71 | ) -> CaseTemplateListResponse: |
| 72 | return await service.list_templates( |
| 73 | session=db, |
| 74 | customer_code=customer_code, |
| 75 | source=source, |
| 76 | include_global=include_global, |
| 77 | ) |
| 78 | |
| 79 | |
| 80 | @case_templates_router.post( |
| 81 | "", |
| 82 | response_model=CaseTemplateOperationResponse, |
| 83 | description="Create a new case template (with optional initial task list).", |
| 84 | ) |
| 85 | async def create_case_template( |
| 86 | request: CaseTemplateCreate, |
| 87 | db: AsyncSession = Depends(get_db), |
| 88 | actor: str = Security(_require_admin_or_analyst), |
| 89 | ) -> CaseTemplateOperationResponse: |
| 90 | return await service.create_template(request=request, actor=actor, session=db) |
| 91 | |
| 92 | |
| 93 | # --------------------------------------------------------------------------- |
| 94 | # Case Template Library — read-only catalog of playbooks pulled from |
| 95 | # https://github.com/socfortress/CoPilot-Case-Templates. The Library tab in |
| 96 | # the admin UI calls these endpoints. Importing an entry creates a normal |
| 97 | # CaseTemplate row via the existing ``create_template`` service. |
| 98 | # |
| 99 | # IMPORTANT: these MUST be declared before the ``/{template_id}`` route below. |
| 100 | # FastAPI matches routes in registration order; if ``/{template_id}`` is first |
| 101 | # it would swallow ``/library`` and try to coerce "library" to int, returning |
| 102 | # HTTP 422 "Input is not a valid integer." |
| 103 | # --------------------------------------------------------------------------- |
| 104 | |
| 105 | |
| 106 | def _library_entry_to_response(entry: dict) -> CaseTemplateLibraryEntry: |
| 107 | """Convert a parsed-and-normalised library entry dict into its API shape.""" |
| 108 | return CaseTemplateLibraryEntry( |
| 109 | key=entry["key"], |
| 110 | name=entry["name"], |
| 111 | description=entry.get("description"), |
| 112 | source=entry.get("source"), |
| 113 | match_field=entry.get("match_field"), |
| 114 | match_value=entry.get("match_value"), |
| 115 | tags=entry.get("tags", {}), |
| 116 | tasks=[CaseTemplateLibraryTask(**t) for t in entry.get("tasks", [])], |
| 117 | file_path=entry.get("_file_path"), |
| 118 | ) |
| 119 | |
| 120 | |
| 121 | @case_templates_router.get( |
| 122 | "/library", |
| 123 | response_model=CaseTemplateLibraryListResponse, |
| 124 | description=( |
| 125 | "List investigation-playbook entries available in the Case-Templates " |
| 126 | "library repo on GitHub. Read-only; nothing is persisted until an " |
| 127 | "admin clicks Import." |
| 128 | ), |
| 129 | ) |
| 130 | async def list_library_entries_endpoint() -> CaseTemplateLibraryListResponse: |
| 131 | try: |
| 132 | entries = await template_library.list_library_entries() |
| 133 | return CaseTemplateLibraryListResponse( |
| 134 | entries=[_library_entry_to_response(e) for e in entries], |
| 135 | invalid_paths=template_library.template_library_cache.invalid_paths, |
| 136 | last_refresh=template_library.template_library_cache.last_refresh, |
| 137 | success=True, |
| 138 | message=f"Retrieved {len(entries)} library entr(ies)", |
| 139 | ) |
| 140 | except Exception as e: |
| 141 | return CaseTemplateLibraryListResponse( |
| 142 | entries=[], |
| 143 | invalid_paths=[], |
| 144 | last_refresh=template_library.template_library_cache.last_refresh, |
| 145 | success=False, |
| 146 | message=f"Failed to load case-template library: {e}", |
| 147 | ) |
| 148 | |
| 149 | |
| 150 | @case_templates_router.post( |
| 151 | "/library/refresh", |
| 152 | response_model=CaseTemplateLibraryRefreshResponse, |
| 153 | description="Force a re-fetch of the Case-Templates library repo (bypasses the 30-minute cache).", |
| 154 | ) |
| 155 | async def refresh_library_endpoint() -> CaseTemplateLibraryRefreshResponse: |
| 156 | try: |
| 157 | result = await template_library.refresh_library() |
| 158 | return CaseTemplateLibraryRefreshResponse( |
| 159 | loaded=result["loaded"], |
| 160 | invalid_paths=result["invalid_paths"], |
| 161 | last_refresh=result["last_refresh"], |
| 162 | success=True, |
| 163 | message=f"Library refreshed: {result['loaded']} entr(ies) loaded, {len(result['invalid_paths'])} skipped", |
| 164 | ) |
| 165 | except Exception as e: |
| 166 | return CaseTemplateLibraryRefreshResponse( |
| 167 | loaded=0, |
| 168 | invalid_paths=[], |
| 169 | last_refresh=template_library.template_library_cache.last_refresh, |
| 170 | success=False, |
| 171 | message=f"Failed to refresh case-template library: {e}", |
| 172 | ) |
| 173 | |
| 174 | |
| 175 | @case_templates_router.post( |
| 176 | "/library/{key}/import", |
| 177 | response_model=CaseTemplateOperationResponse, |
| 178 | description=( |
| 179 | "Import a library entry as a new CaseTemplate row. Imports as a " |
| 180 | "**global** template (no customer_code, no source-scope) by default. " |
| 181 | "If a CaseTemplate already exists with the same name, returns HTTP 409 " |
| 182 | "— admins should rename or delete the existing one before re-importing." |
| 183 | ), |
| 184 | ) |
| 185 | async def import_library_entry_endpoint( |
| 186 | key: str, |
| 187 | db: AsyncSession = Depends(get_db), |
| 188 | actor: str = Security(_require_admin_or_analyst), |
| 189 | ) -> CaseTemplateOperationResponse: |
| 190 | entry = await template_library.get_library_entry(key) |
| 191 | if entry is None: |
| 192 | raise HTTPException( |
| 193 | status_code=404, |
| 194 | detail=f"Library entry '{key}' not found. Try POST /library/refresh if you just pushed it.", |
| 195 | ) |
| 196 | |
| 197 | existing = await db.execute(select(CaseTemplate).where(CaseTemplate.name == entry["name"])) |
| 198 | if existing.scalars().first() is not None: |
| 199 | raise HTTPException( |
| 200 | status_code=409, |
| 201 | detail=( |
| 202 | f"A case template named '{entry['name']}' already exists. " |
| 203 | "Rename or delete the existing template before re-importing this entry." |
| 204 | ), |
| 205 | ) |
| 206 | |
| 207 | payload = CaseTemplateCreate( |
| 208 | name=entry["name"], |
| 209 | description=entry.get("description"), |
| 210 | customer_code=None, |
| 211 | source=entry.get("source"), |
| 212 | is_default=False, |
| 213 | match_field=entry.get("match_field"), |
| 214 | match_value=entry.get("match_value"), |
| 215 | tasks=[ |
| 216 | CaseTemplateTaskCreate( |
| 217 | title=t["title"], |
| 218 | description=t.get("description"), |
| 219 | guidelines=t.get("guidelines"), |
| 220 | mandatory=t.get("mandatory", False), |
| 221 | order_index=t["order_index"], |
| 222 | ) |
| 223 | for t in entry.get("tasks", []) |
| 224 | ], |
| 225 | ) |
| 226 | return await service.create_template(request=payload, actor=actor, session=db) |
| 227 | |
| 228 | |
| 229 | # --------------------------------------------------------------------------- |
| 230 | # Wildcard /{template_id} routes — must be declared AFTER the static |
| 231 | # /library routes above for the same reason FastAPI route ordering matters. |
| 232 | # --------------------------------------------------------------------------- |
| 233 | |
| 234 | |
| 235 | @case_templates_router.get( |
| 236 | "/{template_id}", |
| 237 | response_model=CaseTemplateOperationResponse, |
| 238 | description="Fetch a single case template by ID, including its tasks.", |
| 239 | ) |
| 240 | async def get_case_template( |
| 241 | template_id: int, |
| 242 | db: AsyncSession = Depends(get_db), |
| 243 | ) -> CaseTemplateOperationResponse: |
| 244 | return await service.get_template(template_id=template_id, session=db) |
| 245 | |
| 246 | |
| 247 | @case_templates_router.patch( |
| 248 | "/{template_id}", |
| 249 | response_model=CaseTemplateOperationResponse, |
| 250 | description="Partial update of template metadata. Tasks are managed via the task endpoints.", |
| 251 | ) |
| 252 | async def update_case_template( |
| 253 | template_id: int, |
| 254 | request: CaseTemplateUpdate, |
| 255 | db: AsyncSession = Depends(get_db), |
| 256 | ) -> CaseTemplateOperationResponse: |
| 257 | return await service.update_template(template_id=template_id, request=request, session=db) |
| 258 | |
| 259 | |
| 260 | @case_templates_router.delete( |
| 261 | "/{template_id}", |
| 262 | response_model=CaseTemplateOperationResponse, |
| 263 | description=( |
| 264 | "Delete a template and its template tasks. Existing CaseTask snapshots on real cases " |
| 265 | "are preserved (template_task_id is set to NULL on those rows so audit history survives)." |
| 266 | ), |
| 267 | ) |
| 268 | async def delete_case_template( |
| 269 | template_id: int, |
| 270 | db: AsyncSession = Depends(get_db), |
| 271 | ) -> CaseTemplateOperationResponse: |
| 272 | return await service.delete_template(template_id=template_id, session=db) |
| 273 | |
| 274 | |
| 275 | # --------------------------------------------------------------------------- |
| 276 | # Template task CRUD |
| 277 | # --------------------------------------------------------------------------- |
| 278 | |
| 279 | |
| 280 | @case_templates_router.post( |
| 281 | "/{template_id}/tasks", |
| 282 | response_model=CaseTemplateTaskOperationResponse, |
| 283 | description="Add a task to an existing template.", |
| 284 | ) |
| 285 | async def add_case_template_task( |
| 286 | template_id: int, |
| 287 | request: CaseTemplateTaskCreate, |
| 288 | db: AsyncSession = Depends(get_db), |
| 289 | ) -> CaseTemplateTaskOperationResponse: |
| 290 | return await service.add_template_task(template_id=template_id, request=request, session=db) |
| 291 | |
| 292 | |
| 293 | @case_templates_router.patch( |
| 294 | "/tasks/{task_id}", |
| 295 | response_model=CaseTemplateTaskOperationResponse, |
| 296 | description="Partial update of a template task definition.", |
| 297 | ) |
| 298 | async def update_case_template_task( |
| 299 | task_id: int, |
| 300 | request: CaseTemplateTaskUpdate, |
| 301 | db: AsyncSession = Depends(get_db), |
| 302 | ) -> CaseTemplateTaskOperationResponse: |
| 303 | return await service.update_template_task(task_id=task_id, request=request, session=db) |
| 304 | |
| 305 | |
| 306 | @case_templates_router.delete( |
| 307 | "/tasks/{task_id}", |
| 308 | response_model=CaseTemplateTaskOperationResponse, |
| 309 | description="Delete a template task. Existing CaseTask snapshots on real cases keep their data.", |
| 310 | ) |
| 311 | async def delete_case_template_task( |
| 312 | task_id: int, |
| 313 | db: AsyncSession = Depends(get_db), |
| 314 | ) -> CaseTemplateTaskOperationResponse: |
| 315 | return await service.delete_template_task(task_id=task_id, session=db) |
| 316 | |
| 317 | |
| 318 | @case_templates_router.post( |
| 319 | "/{template_id}/tasks/reorder", |
| 320 | response_model=CaseTemplateOperationResponse, |
| 321 | description=( |
| 322 | "Reorder tasks within a template. Pass the full ordered list of task IDs; " |
| 323 | "tasks not included keep their existing order_index value." |
| 324 | ), |
| 325 | ) |
| 326 | async def reorder_case_template_tasks( |
| 327 | template_id: int, |
| 328 | ordered_task_ids: List[int], |
| 329 | db: AsyncSession = Depends(get_db), |
| 330 | ) -> CaseTemplateOperationResponse: |
| 331 | return await service.reorder_template_tasks( |
| 332 | template_id=template_id, |
| 333 | ordered_task_ids=ordered_task_ids, |
| 334 | session=db, |
| 335 | ) |