main
py 335 lines 12.6 KB
Raw
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 )