main
py 2,753 lines 119 KB
Raw
1 import io
2 import mimetypes
3 from typing import List
4 from typing import Optional
5
6 from fastapi import APIRouter
7 from fastapi import Depends
8 from fastapi import File
9 from fastapi import HTTPException
10 from fastapi import Query
11 from fastapi import Security
12 from fastapi import UploadFile
13 from fastapi.responses import StreamingResponse
14 from loguru import logger
15 from sqlalchemy import select
16 from sqlalchemy.ext.asyncio import AsyncSession
17
18 from app.auth.models.users import User
19 from app.auth.services.universal import select_all_users
20 from app.auth.utils import AuthHandler
21 from app.connectors.wazuh_indexer.utils.universal import (
22 get_available_indices_via_source,
23 )
24 from app.connectors.wazuh_indexer.utils.universal import get_index_mappings_key_names
25 from app.connectors.wazuh_indexer.utils.universal import get_index_source
26 from app.customer_provisioning.routes.provision import check_customer_exists
27 from app.data_store.data_store_operations import (
28 list_case_report_template_data_store_files,
29 )
30 from app.db.db_session import get_db
31 from app.db.universal_models import Customers
32 from app.incidents.models import Alert
33 from app.incidents.models import Case
34 from app.incidents.models import CaseAlertLink
35 from app.incidents.models import CaseComment
36 from app.incidents.models import Comment
37 from app.incidents.models import FieldName
38 from app.incidents.schema.case_templates import CaseTaskCreate
39 from app.incidents.schema.case_templates import CaseTaskUpdate
40 from app.incidents.schema.db_operations import AITriggerResponse
41 from app.incidents.schema.db_operations import AlertContextCreate
42 from app.incidents.schema.db_operations import AlertContextResponse
43 from app.incidents.schema.db_operations import AlertCreate
44 from app.incidents.schema.db_operations import AlertFilterOptionsResponse
45 from app.incidents.schema.db_operations import AlertIoCCreate
46 from app.incidents.schema.db_operations import AlertIoCDelete
47 from app.incidents.schema.db_operations import AlertIoCResponse
48 from app.incidents.schema.db_operations import AlertOutResponse
49 from app.incidents.schema.db_operations import AlertResponse
50 from app.incidents.schema.db_operations import AlertStatus
51 from app.incidents.schema.db_operations import AlertTagCreate
52 from app.incidents.schema.db_operations import AlertTagDelete
53 from app.incidents.schema.db_operations import AlertTagResponse
54 from app.incidents.schema.db_operations import AssetCreate
55 from app.incidents.schema.db_operations import AssetResponse
56 from app.incidents.schema.db_operations import AssignedToAlert
57 from app.incidents.schema.db_operations import AssignedToCase
58 from app.incidents.schema.db_operations import AvailableIndicesResponse
59 from app.incidents.schema.db_operations import AvailableSourcesResponse
60 from app.incidents.schema.db_operations import AvailableUsersResponse
61 from app.incidents.schema.db_operations import CaseAlertLinkCreate
62 from app.incidents.schema.db_operations import CaseAlertLinkResponse
63 from app.incidents.schema.db_operations import CaseAlertLinksCreate
64 from app.incidents.schema.db_operations import CaseAlertLinksResponse
65 from app.incidents.schema.db_operations import CaseAlertUnLink
66 from app.incidents.schema.db_operations import CaseAlertUnLinkResponse
67 from app.incidents.schema.db_operations import CaseCommentCreate
68 from app.incidents.schema.db_operations import CaseCommentEdit
69 from app.incidents.schema.db_operations import CaseCommentResponse
70 from app.incidents.schema.db_operations import CaseCreate
71 from app.incidents.schema.db_operations import CaseCreateFromAlert
72 from app.incidents.schema.db_operations import CaseDataStoreResponse
73 from app.incidents.schema.db_operations import CaseFilterOptionsResponse
74 from app.incidents.schema.db_operations import CaseNotificationCreate
75 from app.incidents.schema.db_operations import CaseNotificationResponse
76 from app.incidents.schema.db_operations import CaseOutResponse
77 from app.incidents.schema.db_operations import CaseReportTemplateDataStoreListResponse
78 from app.incidents.schema.db_operations import CaseReportTemplateDataStoreResponse
79 from app.incidents.schema.db_operations import CaseResponse
80 from app.incidents.schema.db_operations import CommentCreate
81 from app.incidents.schema.db_operations import CommentEdit
82 from app.incidents.schema.db_operations import CommentResponse
83 from app.incidents.schema.db_operations import ConfiguredSourcesResponse
84 from app.incidents.schema.db_operations import DefaultReportTemplateFileNames
85 from app.incidents.schema.db_operations import DeleteAlertsRequest
86 from app.incidents.schema.db_operations import DeleteAlertsResponse
87 from app.incidents.schema.db_operations import EscalateAlert
88 from app.incidents.schema.db_operations import EscalateCase
89 from app.incidents.schema.db_operations import FieldAndAssetNames
90 from app.incidents.schema.db_operations import FieldAndAssetNamesResponse
91 from app.incidents.schema.db_operations import ListCaseDataStoreResponse
92 from app.incidents.schema.db_operations import MappingsResponse
93 from app.incidents.schema.db_operations import NotificationResponse
94 from app.incidents.schema.db_operations import PutAITrigger
95 from app.incidents.schema.db_operations import PutNotification
96 from app.incidents.schema.db_operations import SocfortressRecommendsWazuhAlertTitleName
97 from app.incidents.schema.db_operations import SocfortressRecommendsWazuhAssetName
98 from app.incidents.schema.db_operations import SocfortressRecommendsWazuhFieldNames
99 from app.incidents.schema.db_operations import SocfortressRecommendsWazuhIoCFieldNames
100 from app.incidents.schema.db_operations import SocfortressRecommendsWazuhResponse
101 from app.incidents.schema.db_operations import SocfortressRecommendsWazuhTimeFieldName
102 from app.incidents.schema.db_operations import UpdateAlertStatus
103 from app.incidents.schema.db_operations import UpdateCaseStatus
104 from app.incidents.schema.incident_alert import CreatedAlertPayload
105 from app.incidents.schema.incident_alert import CreatedCaseNotificationPayload
106
107 # from app.incidents.services.db_operations import list_alerts
108 # from app.incidents.services.db_operations import alerts_open_multiple_filters
109 # from app.incidents.services.db_operations import alerts_in_progress_multiple_filters
110 # from app.incidents.services.db_operations import alerts_closed_multiple_filters
111 from app.incidents.services.db_operations import add_alert_title_name
112 from app.incidents.services.db_operations import add_asset_name
113 from app.incidents.services.db_operations import add_field_name
114 from app.incidents.services.db_operations import add_ioc_name
115 from app.incidents.services.db_operations import add_timefield_name
116 from app.incidents.services.db_operations import alert_total
117 from app.incidents.services.db_operations import alert_total_by_alert_title
118 from app.incidents.services.db_operations import alert_total_by_assest_name
119 from app.incidents.services.db_operations import alert_total_by_customer_codes
120 from app.incidents.services.db_operations import alert_total_for_user
121 from app.incidents.services.db_operations import alerts_closed
122 from app.incidents.services.db_operations import alerts_closed_by_alert_title
123 from app.incidents.services.db_operations import alerts_closed_by_asset_name
124 from app.incidents.services.db_operations import alerts_closed_by_assigned_to
125 from app.incidents.services.db_operations import alerts_closed_by_customer_code
126 from app.incidents.services.db_operations import alerts_closed_by_customer_codes
127 from app.incidents.services.db_operations import alerts_closed_by_ioc
128 from app.incidents.services.db_operations import alerts_closed_by_source
129 from app.incidents.services.db_operations import alerts_closed_by_tag
130 from app.incidents.services.db_operations import alerts_closed_for_user
131 from app.incidents.services.db_operations import alerts_in_progress
132 from app.incidents.services.db_operations import alerts_in_progress_by_alert_title
133 from app.incidents.services.db_operations import alerts_in_progress_by_assest_name
134 from app.incidents.services.db_operations import alerts_in_progress_by_assigned_to
135 from app.incidents.services.db_operations import alerts_in_progress_by_customer_code
136 from app.incidents.services.db_operations import alerts_in_progress_by_customer_codes
137 from app.incidents.services.db_operations import alerts_in_progress_by_ioc
138 from app.incidents.services.db_operations import alerts_in_progress_by_source
139 from app.incidents.services.db_operations import alerts_in_progress_by_tag
140 from app.incidents.services.db_operations import alerts_in_progress_for_user
141 from app.incidents.services.db_operations import alerts_open
142 from app.incidents.services.db_operations import alerts_open_by_alert_title
143 from app.incidents.services.db_operations import alerts_open_by_assest_name
144 from app.incidents.services.db_operations import alerts_open_by_assigned_to
145 from app.incidents.services.db_operations import alerts_open_by_customer_code
146 from app.incidents.services.db_operations import alerts_open_by_customer_codes
147 from app.incidents.services.db_operations import alerts_open_by_ioc
148 from app.incidents.services.db_operations import alerts_open_by_source
149 from app.incidents.services.db_operations import alerts_open_by_tag
150 from app.incidents.services.db_operations import alerts_open_for_user
151 from app.incidents.services.db_operations import alerts_total_by_assigned_to
152 from app.incidents.services.db_operations import alerts_total_by_customer_code
153 from app.incidents.services.db_operations import alerts_total_by_ioc
154 from app.incidents.services.db_operations import alerts_total_by_source
155 from app.incidents.services.db_operations import alerts_total_by_tag
156 from app.incidents.services.db_operations import alerts_total_multiple_filters
157 from app.incidents.services.db_operations import case_alert_unlink
158 from app.incidents.services.db_operations import case_total_for_user
159 from app.incidents.services.db_operations import cases_closed_for_user
160 from app.incidents.services.db_operations import cases_in_progress_for_user
161 from app.incidents.services.db_operations import cases_open_for_user
162 from app.incidents.services.db_operations import create_alert
163 from app.incidents.services.db_operations import create_alert_context
164 from app.incidents.services.db_operations import create_alert_ioc
165 from app.incidents.services.db_operations import create_alert_tag
166 from app.incidents.services.db_operations import create_asset
167 from app.incidents.services.db_operations import create_case
168 from app.incidents.services.db_operations import create_case_alert_link
169 from app.incidents.services.db_operations import create_case_alert_links_bulk
170 from app.incidents.services.db_operations import create_case_comment
171 from app.incidents.services.db_operations import create_case_from_alert
172 from app.incidents.services.db_operations import create_comment
173 from app.incidents.services.db_operations import delete_alert
174 from app.incidents.services.db_operations import delete_alert_ioc
175 from app.incidents.services.db_operations import delete_alert_tag
176 from app.incidents.services.db_operations import delete_alert_title_name
177 from app.incidents.services.db_operations import delete_asset_name
178 from app.incidents.services.db_operations import delete_case
179 from app.incidents.services.db_operations import delete_case_comment
180 from app.incidents.services.db_operations import delete_comment
181 from app.incidents.services.db_operations import delete_field_name
182 from app.incidents.services.db_operations import delete_file_from_case
183 from app.incidents.services.db_operations import delete_ioc_name
184 from app.incidents.services.db_operations import delete_report_template
185 from app.incidents.services.db_operations import delete_timefield_name
186 from app.incidents.services.db_operations import download_file_from_case
187 from app.incidents.services.db_operations import download_report_template
188 from app.incidents.services.db_operations import edit_case_comment
189 from app.incidents.services.db_operations import edit_comment
190 from app.incidents.services.db_operations import file_exists
191 from app.incidents.services.db_operations import get_alert_by_id
192 from app.incidents.services.db_operations import get_alert_context_by_id
193 from app.incidents.services.db_operations import get_alert_filter_options
194 from app.incidents.services.db_operations import get_alert_title_names
195 from app.incidents.services.db_operations import get_asset_names
196 from app.incidents.services.db_operations import get_case_by_id
197 from app.incidents.services.db_operations import get_customer_ai_trigger
198 from app.incidents.services.db_operations import get_customer_notification
199 from app.incidents.services.db_operations import get_field_names
200 from app.incidents.services.db_operations import get_ioc_names
201 from app.incidents.services.db_operations import get_timefield_names
202 from app.incidents.services.db_operations import increment_case_notification_count
203 from app.incidents.services.db_operations import is_alert_linked_to_case
204 from app.incidents.services.db_operations import list_alert_by_assigned_to
205 from app.incidents.services.db_operations import list_alert_by_status
206 from app.incidents.services.db_operations import list_alerts_by_asset_name
207 from app.incidents.services.db_operations import list_alerts_by_customer_code
208 from app.incidents.services.db_operations import list_alerts_by_ioc
209 from app.incidents.services.db_operations import list_alerts_by_source
210 from app.incidents.services.db_operations import list_alerts_by_tag
211 from app.incidents.services.db_operations import list_alerts_by_title
212 from app.incidents.services.db_operations import list_alerts_for_user
213 from app.incidents.services.db_operations import list_alerts_multiple_filters
214 from app.incidents.services.db_operations import list_all_files
215 from app.incidents.services.db_operations import list_cases_by_asset_name
216 from app.incidents.services.db_operations import list_cases_by_assigned_to
217 from app.incidents.services.db_operations import list_cases_by_customer_code
218 from app.incidents.services.db_operations import list_cases_by_status
219 from app.incidents.services.db_operations import list_cases_for_user
220 from app.incidents.services.db_operations import list_files_by_case_id
221 from app.incidents.services.db_operations import put_customer_ai_trigger
222 from app.incidents.services.db_operations import put_customer_notification
223 from app.incidents.services.db_operations import replace_alert_title_name
224 from app.incidents.services.db_operations import replace_asset_name
225 from app.incidents.services.db_operations import replace_field_name
226 from app.incidents.services.db_operations import replace_ioc_name
227 from app.incidents.services.db_operations import replace_timefield_name
228 from app.incidents.services.db_operations import report_template_exists
229 from app.incidents.services.db_operations import update_alert_assigned_to
230 from app.incidents.services.db_operations import update_alert_escalated
231 from app.incidents.services.db_operations import update_alert_status
232 from app.incidents.services.db_operations import update_case_assigned_to
233 from app.incidents.services.db_operations import update_case_customer_code
234 from app.incidents.services.db_operations import update_case_escalated
235 from app.incidents.services.db_operations import update_case_status
236 from app.incidents.services.db_operations import upload_file_to_case
237 from app.incidents.services.db_operations import upload_report_template
238 from app.incidents.services.db_operations import upload_report_template_to_data_store
239 from app.incidents.services.db_operations import validate_source_exists
240 from app.incidents.services.incident_case import handle_customer_notifications_case
241 from app.middleware.customer_access import customer_access_handler
242
243 incidents_db_operations_router = APIRouter()
244
245
246 @incidents_db_operations_router.get(
247 "/ai_trigger/{customer_code}",
248 response_model=AITriggerResponse,
249 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
250 )
251 async def get_customer_ai_trigger_endpoint(
252 customer_code: str,
253 _customer: Customers = Depends(check_customer_exists),
254 db: AsyncSession = Depends(get_db),
255 ):
256 return AITriggerResponse(
257 ai_triggers=await get_customer_ai_trigger(customer_code, db),
258 success=True,
259 message="AI Trigger retrieved successfully",
260 )
261
262
263 @incidents_db_operations_router.put(
264 "/ai_trigger",
265 response_model=AITriggerResponse,
266 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
267 )
268 async def put_customer_ai_trigger_endpoint(
269 notification: PutAITrigger,
270 _customer: Customers = Depends(check_customer_exists),
271 db: AsyncSession = Depends(get_db),
272 ):
273 await put_customer_ai_trigger(notification, db)
274 return AITriggerResponse(
275 ai_triggers=await get_customer_ai_trigger(notification.customer_code, db),
276 success=True,
277 message="AI Trigger updated successfully",
278 )
279
280
281 @incidents_db_operations_router.get(
282 "/notification/{customer_code}",
283 response_model=NotificationResponse,
284 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
285 )
286 async def get_customer_notification_endpoint(
287 customer_code: str,
288 _customer: Customers = Depends(check_customer_exists),
289 db: AsyncSession = Depends(get_db),
290 ):
291 return NotificationResponse(
292 notifications=await get_customer_notification(customer_code, db),
293 success=True,
294 message="Notification retrieved successfully",
295 )
296
297
298 @incidents_db_operations_router.put(
299 "/notification",
300 response_model=NotificationResponse,
301 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
302 )
303 async def put_customer_notification_endpoint(
304 notification: PutNotification,
305 _customer: Customers = Depends(check_customer_exists),
306 db: AsyncSession = Depends(get_db),
307 ):
308 await put_customer_notification(notification, db)
309 return NotificationResponse(
310 notifications=await get_customer_notification(notification.customer_code, db),
311 success=True,
312 message="Notification updated successfully",
313 )
314
315
316 @incidents_db_operations_router.get(
317 "/available-source/{index_name}",
318 response_model=AvailableSourcesResponse,
319 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
320 )
321 async def get_available_source_values(index_name: str, session: AsyncSession = Depends(get_db)):
322 return AvailableSourcesResponse(source=await get_index_source(index_name), success=True, message="Source retrieved successfully")
323
324
325 @incidents_db_operations_router.get(
326 "/available-indices/{source}",
327 response_model=AvailableIndicesResponse,
328 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
329 )
330 async def get_available_indices(source: str, session: AsyncSession = Depends(get_db)):
331 return AvailableIndicesResponse(
332 indices=await get_available_indices_via_source(source),
333 success=True,
334 message="Indices retrieved successfully",
335 )
336
337
338 @incidents_db_operations_router.get(
339 "/socfortress/recommends/wazuh",
340 response_model=SocfortressRecommendsWazuhResponse,
341 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
342 )
343 async def get_socfortress_recommends_wazuh(session: AsyncSession = Depends(get_db)):
344 return SocfortressRecommendsWazuhResponse(
345 field_names=[field.value for field in SocfortressRecommendsWazuhFieldNames],
346 asset_name=SocfortressRecommendsWazuhAssetName.agent_name.value,
347 timefield_name=SocfortressRecommendsWazuhTimeFieldName.timestamp_utc.value,
348 alert_title_name=SocfortressRecommendsWazuhAlertTitleName.rule_description.value,
349 ioc_field_names=[ioc.value for ioc in SocfortressRecommendsWazuhIoCFieldNames],
350 source="wazuh",
351 success=True,
352 message="Field names and asset names retrieved successfully",
353 )
354
355
356 @incidents_db_operations_router.get(
357 "/configured/sources",
358 response_model=ConfiguredSourcesResponse,
359 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
360 )
361 async def get_configured_sources(session: AsyncSession = Depends(get_db)):
362 query = select(FieldName.source).distinct()
363 result = await session.execute(query)
364 return ConfiguredSourcesResponse(sources=[row[0] for row in result], success=True, message="Configured sources retrieved successfully")
365
366
367 @incidents_db_operations_router.delete(
368 "/configured/sources/{source}",
369 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
370 )
371 async def delete_configured_source(source: str, session: AsyncSession = Depends(get_db)):
372 # Fully deletes the configured sources `field_names`, `asset_name`, timefield_name`, alert_title_name`
373 field_names = await get_field_names(source, session)
374 asset_name = await get_asset_names(source, session)
375 timefield_name = await get_timefield_names(source, session)
376 alert_title_name = await get_alert_title_names(source, session)
377 ioc_names = await get_ioc_names(source, session)
378
379 logger.info(
380 f"Field names found: {field_names}, Asset name found: {asset_name}, Timefield name found: {timefield_name}, Alert title name found: {alert_title_name}",
381 )
382
383 for field_name in field_names:
384 await delete_field_name(source, field_name, session)
385
386 await delete_asset_name(source, asset_name, session)
387
388 await delete_timefield_name(source, timefield_name, session)
389
390 await delete_alert_title_name(source, alert_title_name, session)
391
392 if ioc_names:
393 for ioc_name in ioc_names:
394 await delete_ioc_name(ioc_name=ioc_name, source=source, session=session)
395
396 logger.info(f"Field names and asset names deleted successfully for source {source}. Committing changes to the database")
397
398 await session.commit()
399
400 return {"message": f"Configured source {source} deleted successfully", "success": True}
401
402
403 @incidents_db_operations_router.get(
404 "/mappings/fields-assets-title-and-timefield",
405 response_model=MappingsResponse,
406 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
407 )
408 async def get_wazuh_fields_and_assets(index_name: str, session: AsyncSession = Depends(get_db)):
409 index_mapping = await get_index_mappings_key_names(index_name)
410 return MappingsResponse(available_mappings=index_mapping, success=True, message="Field names and asset names retrieved successfully")
411
412
413 @incidents_db_operations_router.get(
414 "/fields-assets-title-and-timefield",
415 response_model=FieldAndAssetNamesResponse,
416 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
417 )
418 async def get_source_fields_and_assets(source: str, session: AsyncSession = Depends(get_db)):
419 await validate_source_exists(source, session)
420 return FieldAndAssetNamesResponse(
421 field_names=await get_field_names(source, session),
422 asset_name=await get_asset_names(source, session),
423 timefield_name=await get_timefield_names(source, session),
424 alert_title_name=await get_alert_title_names(source, session),
425 ioc_field_names=await get_ioc_names(source, session),
426 source=source,
427 success=True,
428 message="Field names and asset names retrieved successfully",
429 )
430
431
432 @incidents_db_operations_router.post(
433 "/fields-assets-title-and-timefield",
434 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
435 )
436 async def create_wazuh_fields_and_assets(names: FieldAndAssetNames, session: AsyncSession = Depends(get_db)):
437 for field_name in names.field_names:
438 await add_field_name(names.source, field_name, session)
439
440 await add_asset_name(names.source, names.asset_name, session)
441
442 await add_timefield_name(names.source, names.timefield_name, session)
443
444 await add_alert_title_name(names.source, names.alert_title_name, session)
445
446 if names.ioc_field_names:
447 for ioc_name in names.ioc_field_names:
448 await add_ioc_name(names.source, ioc_name, session)
449
450 logger.info(f"Field names, asset names, and timefield name created successfully for source {names.source}")
451
452 await session.commit()
453
454 return {"message": "Field names and asset names created successfully", "success": True}
455
456
457 @incidents_db_operations_router.put(
458 "/fields-assets-title-and-timefield",
459 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
460 )
461 async def update_fields_and_assets(names: FieldAndAssetNames, session: AsyncSession = Depends(get_db)):
462 await replace_field_name(names.source, names.field_names, session)
463
464 await replace_asset_name(names.source, names.asset_name, session)
465
466 await replace_timefield_name(names.source, names.timefield_name, session)
467
468 await replace_alert_title_name(names.source, names.alert_title_name, session)
469
470 await replace_ioc_name(names.source, names.ioc_field_names, session)
471
472 return {"message": "Field names and asset names created successfully", "success": True}
473
474
475 @incidents_db_operations_router.delete(
476 "/delete-fields-assets-title-and-timefield",
477 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
478 )
479 async def delete_wazuh_fields_and_assets(names: FieldAndAssetNames, session: AsyncSession = Depends(get_db)):
480 for field_name in names.field_names:
481 await delete_field_name(names.source, field_name, session)
482
483 await delete_asset_name(names.source, names.asset_name, session)
484
485 await delete_timefield_name(names.source, names.timefield_name, session)
486
487 await delete_alert_title_name(names.source, names.alert_title_name, session)
488
489 if names.ioc_field_names:
490 logger.info(f"Deleting IoC field names: {names.ioc_field_names}")
491 for ioc_name in names.ioc_field_names:
492 await delete_ioc_name(ioc_value=ioc_name, source=names.source, session=session)
493 logger.info(f"Field names and asset names deleted successfully for source {names.source}. Committing changes to the database")
494 await session.commit()
495
496 return {"message": "Field names and asset names deleted successfully", "success": True}
497
498
499 @incidents_db_operations_router.delete(
500 "/field_name/{field_name}/{source}",
501 deprecated=True,
502 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
503 )
504 async def delete_field_name_endpoint(field_name: str, source: str, db: AsyncSession = Depends(get_db)):
505 return await delete_field_name(source, field_name, db)
506
507
508 @incidents_db_operations_router.delete(
509 "/asset_name/{asset_name}/{source}",
510 deprecated=True,
511 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
512 )
513 async def delete_asset_name_endpoint(asset_name: str, source: str, db: AsyncSession = Depends(get_db)):
514 return await delete_asset_name(source, asset_name, db)
515
516
517 @incidents_db_operations_router.delete(
518 "/timefield_name/{timefield_name}/{source}",
519 deprecated=True,
520 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
521 )
522 async def delete_timefield_name_endpoint(timefield_name: str, source: str, db: AsyncSession = Depends(get_db)):
523 return await delete_timefield_name(source, timefield_name, db)
524
525
526 @incidents_db_operations_router.delete(
527 "/alert_title_name/{alert_title_name}/{source}",
528 deprecated=True,
529 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
530 )
531 async def delete_alert_title_name_endpoint(alert_title_name: str, source: str, db: AsyncSession = Depends(get_db)):
532 return await delete_alert_title_name(source, alert_title_name, db)
533
534
535 @incidents_db_operations_router.post(
536 "/alert",
537 response_model=Alert,
538 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
539 )
540 async def create_alert_endpoint(alert: AlertCreate, db: AsyncSession = Depends(get_db)):
541 return await create_alert(alert, db)
542
543
544 @incidents_db_operations_router.put(
545 "/alert/status",
546 response_model=AlertResponse,
547 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
548 )
549 async def update_alert_status_endpoint(alert_status: UpdateAlertStatus, db: AsyncSession = Depends(get_db)):
550 return AlertResponse(alert=await update_alert_status(alert_status, db), success=True, message="Alert status updated successfully")
551
552
553 @incidents_db_operations_router.put(
554 "/alert/escalated",
555 response_model=AlertResponse,
556 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
557 )
558 async def update_alert_escalated_endpoint(
559 escalate_alert: EscalateAlert,
560 current_user: User = Depends(AuthHandler().get_current_user),
561 db: AsyncSession = Depends(get_db),
562 ):
563 """Update alert escalated status with customer and tag access validation"""
564 logger.info(
565 f"Updating alert {escalate_alert.alert_id} escalated status for user: {current_user.username} with role_id: {current_user.role_id}",
566 )
567
568 # Get the alert first to check customer and tag access
569 alert = await get_alert_by_id(escalate_alert.alert_id, db, user=current_user)
570
571 # Check if user has access to this alert's customer
572 if not await customer_access_handler.check_customer_access(current_user, alert.customer_code, db):
573 raise HTTPException(status_code=403, detail=f"Access denied to alert {escalate_alert.alert_id} - insufficient customer permissions")
574
575 updated_alert = await update_alert_escalated(escalate_alert.alert_id, escalate_alert.escalated, db)
576 return AlertResponse(alert=updated_alert, success=True, message="Alert escalated status updated successfully")
577
578
579 @incidents_db_operations_router.post(
580 "/alert/comment",
581 response_model=CommentResponse,
582 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
583 )
584 async def create_comment_endpoint(
585 comment: CommentCreate,
586 current_user: User = Depends(AuthHandler().get_current_user),
587 db: AsyncSession = Depends(get_db),
588 ):
589 # Get the alert to check customer and tag access
590 alert = await get_alert_by_id(comment.alert_id, db, user=current_user)
591
592 # Check if user has access to this alert's customer
593 if not await customer_access_handler.check_customer_access(current_user, alert.customer_code, db):
594 raise HTTPException(status_code=403, detail=f"Access denied to alert {comment.alert_id} - insufficient customer permissions")
595
596 return CommentResponse(comment=await create_comment(comment, db), success=True, message="Comment created successfully")
597
598
599 @incidents_db_operations_router.put(
600 "/alert/comment",
601 response_model=CommentResponse,
602 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
603 )
604 async def edit_comment_endpoint(
605 comment: CommentEdit,
606 current_user: User = Depends(AuthHandler().get_current_user),
607 db: AsyncSession = Depends(get_db),
608 ):
609 # Get the alert to check customer and tag access
610 alert = await get_alert_by_id(comment.alert_id, db, user=current_user)
611
612 # Check if user has access to this alert's customer
613 if not await customer_access_handler.check_customer_access(current_user, alert.customer_code, db):
614 raise HTTPException(status_code=403, detail=f"Access denied to alert {comment.alert_id} - insufficient customer permissions")
615
616 return CommentResponse(comment=await edit_comment(comment, db), success=True, message="Comment edited successfully")
617
618
619 @incidents_db_operations_router.delete(
620 "/alert/comment/{comment_id}",
621 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
622 )
623 async def delete_comment_endpoint(
624 comment_id: int,
625 current_user: User = Depends(AuthHandler().get_current_user),
626 db: AsyncSession = Depends(get_db),
627 ):
628 # First get the comment to find the alert_id
629 result = await db.execute(select(Comment).where(Comment.id == comment_id))
630 comment = result.scalars().first()
631 if not comment:
632 raise HTTPException(status_code=404, detail="Comment not found")
633
634 # Get the alert to check customer and tag access
635 alert = await get_alert_by_id(comment.alert_id, db, user=current_user)
636
637 # Check if user has access to this alert's customer
638 if not await customer_access_handler.check_customer_access(current_user, alert.customer_code, db):
639 raise HTTPException(
640 status_code=403,
641 detail=f"Access denied to comment on alert {comment.alert_id} - insufficient customer permissions",
642 )
643
644 await delete_comment(comment_id, db)
645 return {"message": "Comment deleted successfully", "success": True}
646
647
648 @incidents_db_operations_router.post(
649 "/case/comment",
650 response_model=CaseCommentResponse,
651 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
652 )
653 async def create_case_comment_endpoint(
654 comment: CaseCommentCreate,
655 current_user: User = Depends(AuthHandler().get_current_user),
656 db: AsyncSession = Depends(get_db),
657 ):
658 # Get the case to check customer access
659 case = await get_case_by_id(comment.case_id, db)
660
661 # Check if user has access to this case's customer
662 if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
663 raise HTTPException(status_code=403, detail=f"Access denied to case {comment.case_id} - insufficient customer permissions")
664
665 created = await create_case_comment(comment, db)
666
667 from app.incidents.schema.case_templates import CaseEventType
668 from app.incidents.services.case_events import emit_case_event
669 from app.incidents.services.case_events import payload_comment
670
671 await emit_case_event(
672 session=db,
673 case_id=comment.case_id,
674 event_type=CaseEventType.COMMENT_ADDED,
675 actor=current_user.username,
676 payload=payload_comment(comment_id=created.id, snippet=created.comment),
677 commit=True,
678 )
679
680 return CaseCommentResponse(comment=created, success=True, message="Case comment created successfully")
681
682
683 @incidents_db_operations_router.put(
684 "/case/comment",
685 response_model=CaseCommentResponse,
686 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
687 )
688 async def edit_case_comment_endpoint(
689 comment: CaseCommentEdit,
690 current_user: User = Depends(AuthHandler().get_current_user),
691 db: AsyncSession = Depends(get_db),
692 ):
693 # Get the case to check customer access
694 case = await get_case_by_id(comment.case_id, db)
695
696 # Check if user has access to this case's customer
697 if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
698 raise HTTPException(status_code=403, detail=f"Access denied to case {comment.case_id} - insufficient customer permissions")
699
700 return CaseCommentResponse(comment=await edit_case_comment(comment, db), success=True, message="Case comment edited successfully")
701
702
703 @incidents_db_operations_router.delete(
704 "/case/comment/{comment_id}",
705 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
706 )
707 async def delete_case_comment_endpoint(
708 comment_id: int,
709 current_user: User = Depends(AuthHandler().get_current_user),
710 db: AsyncSession = Depends(get_db),
711 ):
712 # First get the comment to find the case_id
713 result = await db.execute(select(CaseComment).where(CaseComment.id == comment_id))
714 comment = result.scalars().first()
715 if not comment:
716 raise HTTPException(status_code=404, detail="Comment not found")
717
718 # Get the case to check customer access
719 case = await get_case_by_id(comment.case_id, db)
720
721 # Check if user has access to this case's customer
722 if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
723 raise HTTPException(
724 status_code=403,
725 detail=f"Access denied to comment on case {comment.case_id} - insufficient customer permissions",
726 )
727
728 await delete_case_comment(comment_id, db)
729 return {"message": "Case comment deleted successfully", "success": True}
730
731
732 @incidents_db_operations_router.get(
733 "/alert/available-users",
734 response_model=AvailableUsersResponse,
735 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
736 )
737 async def get_available_users(db: AsyncSession = Depends(get_db)):
738 all_users = await select_all_users()
739 return AvailableUsersResponse(
740 available_users=[user.username for user in all_users],
741 success=True,
742 message="Available users retrieved successfully",
743 )
744
745
746 @incidents_db_operations_router.put(
747 "/alert/assigned-to",
748 response_model=AlertResponse,
749 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
750 )
751 async def update_assigned_to_endpoint(assigned_to: AssignedToAlert, db: AsyncSession = Depends(get_db)):
752 all_users = await select_all_users()
753 user_names = [user.username for user in all_users]
754 if assigned_to.assigned_to not in user_names:
755 raise HTTPException(status_code=400, detail="User does not exist")
756 return AlertResponse(
757 alert=await update_alert_assigned_to(assigned_to.alert_id, assigned_to.assigned_to, db),
758 success=True,
759 message="Alert assigned to user successfully",
760 )
761
762
763 @incidents_db_operations_router.post(
764 "/alert/context",
765 response_model=AlertContextResponse,
766 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
767 )
768 async def create_alert_context_endpoint(alert_context: AlertContextCreate, db: AsyncSession = Depends(get_db)):
769 return AlertContextResponse(
770 alert_context=await create_alert_context(alert_context, db),
771 success=True,
772 message="Alert context created successfully",
773 )
774
775
776 @incidents_db_operations_router.get(
777 "/alert/context/{alert_context_id}",
778 response_model=AlertContextResponse,
779 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
780 )
781 async def get_alert_context_by_id_endpoint(alert_context_id: int, db: AsyncSession = Depends(get_db)):
782 return AlertContextResponse(
783 alert_context=await get_alert_context_by_id(alert_context_id, db),
784 success=True,
785 message="Alert context retrieved successfully",
786 )
787
788
789 @incidents_db_operations_router.post(
790 "/alert/asset",
791 response_model=AssetResponse,
792 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
793 )
794 async def create_asset_endpoint(asset: AssetCreate, db: AsyncSession = Depends(get_db)):
795 return AssetResponse(asset=await create_asset(asset, db), success=True, message="Asset created successfully")
796
797
798 @incidents_db_operations_router.post(
799 "/alert/ioc",
800 response_model=AlertIoCResponse,
801 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
802 )
803 async def create_alert_ioc_endpoint(ioc: AlertIoCCreate, db: AsyncSession = Depends(get_db)):
804 return AlertIoCResponse(alert_ioc=await create_alert_ioc(ioc, db), success=True, message="Alert IoC created successfully")
805
806
807 @incidents_db_operations_router.get(
808 "/alert/ioc/{ioc_value}",
809 response_model=AlertOutResponse,
810 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
811 )
812 async def list_alerts_by_ioc_value_endpoint(
813 ioc_value: str,
814 page: int = Query(1, ge=1),
815 page_size: int = Query(25, ge=1),
816 current_user: User = Depends(AuthHandler().get_current_user),
817 db: AsyncSession = Depends(get_db),
818 ):
819 """List alerts by IoC value with customer access filtering"""
820 logger.info(f"Listing alerts by IoC {ioc_value} for user: {current_user.username} with role_id: {current_user.role_id}")
821
822 # Get customer access filtering
823 accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
824
825 if "*" in accessible_customers:
826 # Admin/analyst - no filtering needed
827 alerts = await list_alerts_by_ioc(ioc_value, db, page, page_size)
828 total = await alerts_total_by_ioc(db, ioc_value)
829 open_alerts = await alerts_open_by_ioc(db, ioc_value)
830 in_progress = await alerts_in_progress_by_ioc(db, ioc_value)
831 closed = await alerts_closed_by_ioc(db, ioc_value)
832 else:
833 # Customer user - filter by accessible customers
834 alerts = await list_alerts_multiple_filters(
835 ioc_value=ioc_value,
836 customer_codes=accessible_customers,
837 db=db,
838 page=page,
839 page_size=page_size,
840 order="desc",
841 )
842 total = await alert_total_by_customer_codes(db, accessible_customers)
843 open_alerts = await alerts_open_by_customer_codes(db, accessible_customers)
844 in_progress = await alerts_in_progress_by_customer_codes(db, accessible_customers)
845 closed = await alerts_closed_by_customer_codes(db, accessible_customers)
846
847 return AlertOutResponse(
848 alerts=alerts,
849 total=total,
850 open=open_alerts,
851 in_progress=in_progress,
852 closed=closed,
853 success=True,
854 message="Alerts retrieved successfully",
855 )
856
857
858 @incidents_db_operations_router.delete(
859 "/alert/ioc",
860 response_model=AlertIoCResponse,
861 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
862 )
863 async def delete_alert_ioc_endpoint(ioc: AlertIoCDelete, db: AsyncSession = Depends(get_db)):
864 return AlertIoCResponse(
865 alert_ioc=await delete_alert_ioc(ioc=ioc, db=db),
866 success=True,
867 message="Alert IoC deleted successfully",
868 )
869
870
871 @incidents_db_operations_router.post(
872 "/alert/tag",
873 response_model=AlertTagResponse,
874 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
875 )
876 async def create_alert_tag_endpoint(alert_tag: AlertTagCreate, db: AsyncSession = Depends(get_db)):
877 return AlertTagResponse(alert_tag=await create_alert_tag(alert_tag, db), success=True, message="Alert tag created successfully")
878
879
880 @incidents_db_operations_router.get(
881 "/alert/tag/{tag}",
882 response_model=AlertOutResponse,
883 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
884 )
885 async def list_alerts_by_tag_endpoint(
886 tag: str,
887 page: int = Query(1, ge=1),
888 page_size: int = Query(25, ge=1),
889 customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the results to"),
890 current_user: User = Depends(AuthHandler().get_current_user),
891 db: AsyncSession = Depends(get_db),
892 ):
893 """List alerts by tag with customer access filtering"""
894 logger.info(f"Listing alerts by tag {tag} for user: {current_user.username} with role_id: {current_user.role_id}")
895
896 # Get customer access filtering (optionally narrowed to a requested subset)
897 accessible_customers = await customer_access_handler.resolve_effective_customers(current_user, customer_codes, db)
898
899 if "*" in accessible_customers:
900 # Admin/analyst - no filtering needed
901 alerts = await list_alerts_by_tag(tag, db, page, page_size)
902 total = await alerts_total_by_tag(db, tag)
903 open_alerts = await alerts_open_by_tag(db, tag)
904 in_progress = await alerts_in_progress_by_tag(db, tag)
905 closed = await alerts_closed_by_tag(db, tag)
906 else:
907 # Customer user - filter by accessible customers
908 alerts = await list_alerts_multiple_filters(
909 tags=[tag],
910 customer_codes=accessible_customers,
911 db=db,
912 page=page,
913 page_size=page_size,
914 order="desc",
915 )
916 total = await alert_total_by_customer_codes(db, accessible_customers)
917 open_alerts = await alerts_open_by_customer_codes(db, accessible_customers)
918 in_progress = await alerts_in_progress_by_customer_codes(db, accessible_customers)
919 closed = await alerts_closed_by_customer_codes(db, accessible_customers)
920
921 return AlertOutResponse(
922 alerts=alerts,
923 total=total,
924 open=open_alerts,
925 in_progress=in_progress,
926 closed=closed,
927 success=True,
928 message="Alert's tags retrieved successfully",
929 )
930
931
932 @incidents_db_operations_router.delete(
933 "/alert/tag",
934 response_model=AlertTagResponse,
935 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
936 )
937 async def delete_alert_tag_endpoint(alert_tag: AlertTagDelete, db: AsyncSession = Depends(get_db)):
938 return AlertTagResponse(
939 alert_tag=await delete_alert_tag(alert_tag.alert_id, alert_tag.tag_id, db),
940 success=True,
941 message="Alert tag deleted successfully",
942 )
943
944
945 @incidents_db_operations_router.post(
946 "/case/create",
947 response_model=CaseResponse,
948 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
949 )
950 async def create_case_endpoint(
951 case: CaseCreate,
952 template_id: Optional[int] = Query(
953 None,
954 description="Optional CaseTemplate id to apply on creation. Skips auto-selection (manual path has no alert source).",
955 ),
956 current_user: User = Depends(AuthHandler().get_current_user),
957 db: AsyncSession = Depends(get_db),
958 ):
959 created = await create_case(case, db, actor=current_user.username, template_id=template_id)
960
961 # Phase 4 audit emit
962 from app.incidents.schema.case_templates import CaseEventType
963 from app.incidents.services.case_events import emit_case_event
964
965 await emit_case_event(
966 session=db,
967 case_id=created.id,
968 event_type=CaseEventType.CASE_CREATED,
969 actor=current_user.username,
970 payload={"source": "manual", "template_id": template_id},
971 commit=True,
972 )
973
974 return CaseResponse(case=created, success=True, message="Case created successfully")
975
976
977 @incidents_db_operations_router.post(
978 "/case/alert-link",
979 response_model=CaseAlertLinkResponse,
980 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
981 )
982 async def create_case_alert_link_endpoint(
983 case_alert_link: CaseAlertLinkCreate,
984 current_user: User = Depends(AuthHandler().get_current_user),
985 db: AsyncSession = Depends(get_db),
986 ):
987 link = await create_case_alert_link(case_alert_link, db, actor=current_user.username)
988
989 from app.incidents.schema.case_templates import CaseEventType
990 from app.incidents.services.case_events import emit_case_event
991 from app.incidents.services.case_events import payload_alert_link
992
993 await emit_case_event(
994 session=db,
995 case_id=case_alert_link.case_id,
996 event_type=CaseEventType.ALERT_LINKED,
997 actor=current_user.username,
998 payload=payload_alert_link(alert_id=case_alert_link.alert_id),
999 commit=True,
1000 )
1001
1002 return CaseAlertLinkResponse(
1003 case_alert_link=link,
1004 success=True,
1005 message="Case alert link created successfully",
1006 )
1007
1008
1009 @incidents_db_operations_router.post(
1010 "/case/alert-links",
1011 response_model=CaseAlertLinksResponse,
1012 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1013 )
1014 async def create_case_alert_links_endpoint(
1015 case_alert_links: CaseAlertLinksCreate,
1016 current_user: User = Depends(AuthHandler().get_current_user),
1017 db: AsyncSession = Depends(get_db),
1018 ):
1019 links = await create_case_alert_links_bulk(case_alert_links, db, actor=current_user.username)
1020
1021 from app.incidents.schema.case_templates import CaseEventType
1022 from app.incidents.services.case_events import emit_case_event
1023 from app.incidents.services.case_events import payload_alert_links_bulk
1024
1025 # One aggregated event for the bulk operation rather than N individual
1026 # ones — keeps the timeline readable when 50 alerts are bulk-attached.
1027 await emit_case_event(
1028 session=db,
1029 case_id=case_alert_links.case_id,
1030 event_type=CaseEventType.ALERT_LINKED,
1031 actor=current_user.username,
1032 payload=payload_alert_links_bulk(alert_ids=case_alert_links.alert_ids),
1033 commit=True,
1034 )
1035
1036 return CaseAlertLinksResponse(
1037 case_alert_links=links,
1038 success=True,
1039 message="Case alert links created successfully",
1040 )
1041
1042
1043 @incidents_db_operations_router.post(
1044 "/case/alert-unlink",
1045 response_model=CaseAlertUnLinkResponse,
1046 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1047 )
1048 async def case_alert_unlink_endpoint(
1049 case_alert_link: CaseAlertUnLink,
1050 current_user: User = Depends(AuthHandler().get_current_user),
1051 db: AsyncSession = Depends(get_db),
1052 ):
1053 response = await case_alert_unlink(case_alert_link, db)
1054
1055 from app.incidents.schema.case_templates import CaseEventType
1056 from app.incidents.services.case_events import emit_case_event
1057 from app.incidents.services.case_events import payload_alert_link
1058
1059 await emit_case_event(
1060 session=db,
1061 case_id=case_alert_link.case_id,
1062 event_type=CaseEventType.ALERT_UNLINKED,
1063 actor=current_user.username,
1064 payload=payload_alert_link(
1065 alert_id=case_alert_link.alert_id,
1066 tasks_orphaned=response.tasks_orphaned,
1067 ),
1068 commit=True,
1069 )
1070
1071 return response
1072
1073
1074 @incidents_db_operations_router.post(
1075 "/case/from-alert",
1076 response_model=CaseAlertLinkResponse,
1077 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1078 )
1079 async def create_case_from_alert_endpoint(
1080 alert_id: CaseCreateFromAlert,
1081 template_id: Optional[int] = Query(
1082 None,
1083 description=(
1084 "Optional CaseTemplate id to apply on creation. When omitted, the best matching "
1085 "template is auto-selected from the alert's (customer_code, source). The materialized "
1086 "tasks are stamped with the originating alert id so the Tasks UI can group them under "
1087 "that alert. Subsequent alerts linked to the case retrigger per-alert auto-apply against "
1088 "their own source — each linked alert gets its own task batch."
1089 ),
1090 ),
1091 current_user: User = Depends(AuthHandler().get_current_user),
1092 db: AsyncSession = Depends(get_db),
1093 ):
1094 case = await create_case_from_alert(
1095 alert_id.alert_id,
1096 db,
1097 actor=current_user.username,
1098 template_id=template_id,
1099 )
1100 if case is None:
1101 return CaseResponse(case=None, success=False, message="Case not created")
1102
1103 # ``create_case_from_alert`` above already auto-applied the template
1104 # against the originating alert and stamped its id on the tasks. Skip
1105 # the per-alert auto-apply on this link so we don't double-create tasks.
1106 link = await create_case_alert_link(
1107 CaseAlertLinkCreate(case_id=case.id, alert_id=alert_id.alert_id),
1108 db,
1109 actor=current_user.username,
1110 auto_apply_template=False,
1111 )
1112
1113 # Phase 4 audit emits: case_created + alert_linked (the originating
1114 # alert is the first link). template_applied / task_added events are
1115 # already emitted by the apply_template_to_case service inside
1116 # create_case_from_alert, so we don't re-emit them here.
1117 from app.incidents.schema.case_templates import CaseEventType
1118 from app.incidents.services.case_events import emit_case_event
1119 from app.incidents.services.case_events import payload_alert_link
1120
1121 await emit_case_event(
1122 session=db,
1123 case_id=case.id,
1124 event_type=CaseEventType.CASE_CREATED,
1125 actor=current_user.username,
1126 payload={"source": "from_alert", "alert_id": alert_id.alert_id, "template_id": template_id},
1127 commit=False,
1128 )
1129 await emit_case_event(
1130 session=db,
1131 case_id=case.id,
1132 event_type=CaseEventType.ALERT_LINKED,
1133 actor=current_user.username,
1134 payload=payload_alert_link(alert_id=alert_id.alert_id),
1135 commit=True,
1136 )
1137
1138 return CaseAlertLinkResponse(
1139 case_alert_link=link,
1140 success=True,
1141 message="Case created from alert successfully",
1142 )
1143
1144
1145 @incidents_db_operations_router.get(
1146 "/alerts/filter-options",
1147 response_model=AlertFilterOptionsResponse,
1148 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1149 )
1150 async def get_alert_filter_options_endpoint(
1151 current_user: User = Depends(AuthHandler().get_current_user),
1152 db: AsyncSession = Depends(get_db),
1153 ):
1154 """Get distinct sources, assets, and tags available for alert filtering."""
1155 options = await get_alert_filter_options(current_user, db)
1156 return AlertFilterOptionsResponse(
1157 sources=options["sources"],
1158 assets=options["assets"],
1159 tags=options["tags"],
1160 success=True,
1161 message="Filter options retrieved successfully",
1162 )
1163
1164
1165 @incidents_db_operations_router.get(
1166 "/cases/filter-options",
1167 response_model=CaseFilterOptionsResponse,
1168 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1169 )
1170 async def get_case_filter_options_endpoint(
1171 current_user: User = Depends(AuthHandler().get_current_user),
1172 db: AsyncSession = Depends(get_db),
1173 ):
1174 """Get distinct case statuses and assigned_to values for filtering."""
1175 from sqlalchemy import distinct
1176
1177 accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
1178
1179 if "*" in accessible_customers:
1180 statuses_q = select(distinct(Case.case_status)).order_by(Case.case_status)
1181 assigned_q = select(distinct(Case.assigned_to)).where(Case.assigned_to.isnot(None)).order_by(Case.assigned_to)
1182 else:
1183 customer_filter = Case.customer_code.in_(accessible_customers)
1184 statuses_q = select(distinct(Case.case_status)).where(customer_filter).order_by(Case.case_status)
1185 assigned_q = select(distinct(Case.assigned_to)).where(customer_filter, Case.assigned_to.isnot(None)).order_by(Case.assigned_to)
1186
1187 statuses_result = await db.execute(statuses_q)
1188 statuses = [row[0] for row in statuses_result if row[0]]
1189
1190 assigned_result = await db.execute(assigned_q)
1191 assigned_to = [row[0] for row in assigned_result if row[0]]
1192
1193 return CaseFilterOptionsResponse(
1194 statuses=statuses,
1195 assigned_to=assigned_to,
1196 success=True,
1197 message="Case filter options retrieved successfully",
1198 )
1199
1200
1201 @incidents_db_operations_router.get(
1202 "/alerts",
1203 response_model=AlertOutResponse,
1204 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1205 )
1206 async def list_alerts_endpoint(
1207 page: int = Query(1, ge=1),
1208 page_size: int = Query(25, ge=1),
1209 order: str = Query("desc", pattern="^(asc|desc)$"),
1210 customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the results to"),
1211 current_user: User = Depends(AuthHandler().get_current_user),
1212 db: AsyncSession = Depends(get_db),
1213 ):
1214 """List alerts with automatic customer and tag filtering"""
1215 logger.info(f"Listing alerts for user: {current_user.username} with role_id: {current_user.role_id}")
1216
1217 alerts = await list_alerts_for_user(current_user, db, page, page_size, order, customer_codes=customer_codes)
1218
1219 # Get totals with both customer and tag filtering
1220 total = await alert_total_for_user(current_user, db, customer_codes=customer_codes)
1221 open_alerts = await alerts_open_for_user(current_user, db, customer_codes=customer_codes)
1222 in_progress = await alerts_in_progress_for_user(current_user, db, customer_codes=customer_codes)
1223 closed = await alerts_closed_for_user(current_user, db, customer_codes=customer_codes)
1224
1225 return AlertOutResponse(
1226 alerts=alerts,
1227 total=total,
1228 open=open_alerts,
1229 in_progress=in_progress,
1230 closed=closed,
1231 success=True,
1232 message="Alerts retrieved successfully",
1233 )
1234
1235
1236 @incidents_db_operations_router.get(
1237 "/alert/{alert_id}",
1238 response_model=AlertOutResponse,
1239 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1240 )
1241 async def get_alert_by_id_endpoint(
1242 alert_id: int,
1243 current_user: User = Depends(AuthHandler().get_current_user),
1244 db: AsyncSession = Depends(get_db),
1245 ):
1246 """Get alert by ID with customer and tag access validation"""
1247 logger.info(f"Getting alert {alert_id} for user: {current_user.username} with role_id: {current_user.role_id}")
1248
1249 # Get the alert with tag access check
1250 alert = await get_alert_by_id(alert_id, db, user=current_user)
1251
1252 # Check if user has access to this alert's customer
1253 if not await customer_access_handler.check_customer_access(current_user, alert.customer_code, db):
1254 raise HTTPException(status_code=403, detail=f"Access denied to alert {alert_id} - insufficient customer permissions")
1255
1256 return AlertOutResponse(alerts=[alert], success=True, message="Alert retrieved successfully")
1257
1258
1259 @incidents_db_operations_router.delete(
1260 "/alert/{alert_id}",
1261 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1262 )
1263 async def delete_alert_endpoint(
1264 alert_id: int,
1265 current_user: User = Depends(AuthHandler().get_current_user),
1266 db: AsyncSession = Depends(get_db),
1267 ):
1268 """Delete alert with customer and tag access validation"""
1269 logger.info(f"Deleting alert {alert_id} for user: {current_user.username} with role_id: {current_user.role_id}")
1270
1271 # Get the alert first to check customer and tag access
1272 alert = await get_alert_by_id(alert_id, db, user=current_user)
1273
1274 # Check if user has access to this alert's customer
1275 if not await customer_access_handler.check_customer_access(current_user, alert.customer_code, db):
1276 raise HTTPException(status_code=403, detail=f"Access denied to alert {alert_id} - insufficient customer permissions")
1277
1278 await is_alert_linked_to_case(alert_id, db)
1279 await delete_alert(alert_id, db)
1280 return {"message": "Alert deleted successfully", "success": True}
1281
1282
1283 @incidents_db_operations_router.delete(
1284 "/alerts",
1285 response_model=DeleteAlertsResponse,
1286 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1287 )
1288 async def delete_alerts_endpoint(request: DeleteAlertsRequest, db: AsyncSession = Depends(get_db)):
1289 """
1290 Endpoint to delete alerts.
1291
1292 This endpoint deletes alerts based on the provided list of alert IDs. If an alert is linked to a case, it will not be deleted and will be skipped.
1293
1294 Args:
1295 request (DeleteAlertsRequest): Request object containing the list of alert IDs to be deleted.
1296 db (AsyncSession, optional): Database session dependency.
1297
1298 Returns:
1299 DeleteAlertsResponse: Response object containing the status of the deletion process, including lists of successfully deleted alert IDs and those that were not deleted.
1300
1301 Raises:
1302 HTTPException: If an error occurs during the deletion process that is not related to an alert being linked to a case.
1303 """
1304 deleted_alert_ids = []
1305 not_deleted_alert_ids = []
1306 for alert_id in request.alert_ids:
1307 try:
1308 await is_alert_linked_to_case(alert_id, db)
1309 await delete_alert(alert_id, db)
1310 deleted_alert_ids.append(alert_id)
1311 except HTTPException as e:
1312 if e.status_code == 400:
1313 logger.info(f"Alert {alert_id} is linked to a case and cannot be deleted. Skipping.")
1314 not_deleted_alert_ids.append(alert_id)
1315 else:
1316 raise e
1317 return DeleteAlertsResponse(
1318 message="Alerts processed successfully",
1319 deleted_alert_ids=deleted_alert_ids,
1320 not_deleted_alert_ids=not_deleted_alert_ids,
1321 success=True,
1322 )
1323
1324
1325 @incidents_db_operations_router.delete(
1326 "/alerts/by-title/{title_filter}",
1327 response_model=DeleteAlertsResponse,
1328 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1329 )
1330 async def delete_alerts_by_title_endpoint(
1331 title_filter: str,
1332 current_user: User = Depends(AuthHandler().get_current_user),
1333 db: AsyncSession = Depends(get_db),
1334 ):
1335 """
1336 Delete alerts matching a title filter.
1337
1338 The title_filter will match any alert where the title contains the filter string.
1339 For example, filtering by "File" will match "File added to the system."
1340
1341 Args:
1342 title_filter: The string to filter alert titles by (case-insensitive partial match)
1343 current_user: Current authenticated user
1344 db: Database session
1345
1346 Returns:
1347 DeleteAlertsResponse with lists of deleted and not deleted alert IDs
1348 """
1349 logger.info(f"Deleting alerts with title filter '{title_filter}' for user: {current_user.username}")
1350
1351 # Get customer access filtering
1352 accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
1353
1354 # Build query to find matching alerts
1355 query = select(Alert).where(Alert.alert_name.ilike(f"%{title_filter}%"))
1356
1357 # Apply customer filtering if not admin/analyst
1358 if "*" not in accessible_customers:
1359 query = query.where(Alert.customer_code.in_(accessible_customers))
1360
1361 result = await db.execute(query)
1362 matching_alerts = result.scalars().all()
1363
1364 if not matching_alerts:
1365 return DeleteAlertsResponse(
1366 message=f"No alerts found matching title filter: {title_filter}",
1367 deleted_alert_ids=[],
1368 not_deleted_alert_ids=[],
1369 success=True,
1370 )
1371
1372 deleted_alert_ids = []
1373 not_deleted_alert_ids = []
1374
1375 for alert in matching_alerts:
1376 try:
1377 # Check if alert is linked to a case
1378 await is_alert_linked_to_case(alert.id, db)
1379 # Delete the alert
1380 await delete_alert(alert.id, db)
1381 deleted_alert_ids.append(alert.id)
1382 except HTTPException as e:
1383 if e.status_code == 400:
1384 logger.info(f"Alert {alert.id} is linked to a case and cannot be deleted. Skipping.")
1385 not_deleted_alert_ids.append(alert.id)
1386 else:
1387 raise e
1388
1389 return DeleteAlertsResponse(
1390 message=f"Processed {len(matching_alerts)} alerts matching '{title_filter}'. Deleted: {len(deleted_alert_ids)}, Skipped: {len(not_deleted_alert_ids)}",
1391 deleted_alert_ids=deleted_alert_ids,
1392 not_deleted_alert_ids=not_deleted_alert_ids,
1393 success=True,
1394 )
1395
1396
1397 @incidents_db_operations_router.get(
1398 "/alerts/status/{status}",
1399 response_model=AlertOutResponse,
1400 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1401 )
1402 async def list_alerts_by_status_endpoint(
1403 status: AlertStatus,
1404 page: int = Query(1, ge=1),
1405 page_size: int = Query(25, ge=1),
1406 order: str = Query("desc", pattern="^(asc|desc)$"),
1407 customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the results to"),
1408 current_user: User = Depends(AuthHandler().get_current_user),
1409 db: AsyncSession = Depends(get_db),
1410 ):
1411 """List alerts by status with customer access filtering"""
1412 if status not in AlertStatus:
1413 raise HTTPException(status_code=400, detail="Invalid status")
1414
1415 logger.info(f"Listing alerts by status {status} for user: {current_user.username} with role_id: {current_user.role_id}")
1416
1417 # Get customer access filtering (optionally narrowed to a requested subset)
1418 accessible_customers = await customer_access_handler.resolve_effective_customers(current_user, customer_codes, db)
1419
1420 if "*" in accessible_customers:
1421 # Admin/analyst - no filtering needed
1422 alerts = await list_alert_by_status(status.value, db, page=page, page_size=page_size, order=order)
1423 total = await alert_total(db)
1424 open_alerts = await alerts_open(db)
1425 in_progress = await alerts_in_progress(db)
1426 closed = await alerts_closed(db)
1427 else:
1428 # Customer user - filter by accessible customers
1429 # We need to create filtered versions of these functions or use the existing filter functionality
1430 # For now, let's use the multiple filters function with customer codes
1431 alerts = await list_alerts_multiple_filters(
1432 status=status.value,
1433 customer_codes=accessible_customers,
1434 db=db,
1435 page=page,
1436 page_size=page_size,
1437 order=order,
1438 )
1439 total = await alert_total_by_customer_codes(db, accessible_customers)
1440 open_alerts = await alerts_open_by_customer_codes(db, accessible_customers)
1441 in_progress = await alerts_in_progress_by_customer_codes(db, accessible_customers)
1442 closed = await alerts_closed_by_customer_codes(db, accessible_customers)
1443
1444 return AlertOutResponse(
1445 alerts=alerts,
1446 total=total,
1447 open=open_alerts,
1448 in_progress=in_progress,
1449 closed=closed,
1450 success=True,
1451 message="Alerts retrieved successfully",
1452 )
1453
1454
1455 @incidents_db_operations_router.get(
1456 "/alerts/assigned-to/{assigned_to}",
1457 response_model=AlertOutResponse,
1458 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1459 )
1460 async def list_alerts_by_assigned_to_endpoint(
1461 assigned_to: str,
1462 page: int = Query(1, ge=1),
1463 page_size: int = Query(25, ge=1),
1464 order: str = Query("desc", pattern="^(asc|desc)$"),
1465 current_user: User = Depends(AuthHandler().get_current_user),
1466 db: AsyncSession = Depends(get_db),
1467 ):
1468 """List alerts by assigned user with customer access filtering"""
1469 logger.info(f"Listing alerts assigned to {assigned_to} for user: {current_user.username} with role_id: {current_user.role_id}")
1470
1471 # Get customer access filtering
1472 accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
1473
1474 if "*" in accessible_customers:
1475 # Admin/analyst - no filtering needed
1476 alerts = await list_alert_by_assigned_to(assigned_to, db, page=page, page_size=page_size, order=order)
1477 total = await alerts_total_by_assigned_to(db, assigned_to)
1478 open_alerts = await alerts_open_by_assigned_to(db, assigned_to)
1479 in_progress = await alerts_in_progress_by_assigned_to(db, assigned_to)
1480 closed = await alerts_closed_by_assigned_to(db, assigned_to)
1481 else:
1482 # Customer user - filter by accessible customers
1483 alerts = await list_alerts_multiple_filters(
1484 assigned_to=assigned_to,
1485 customer_codes=accessible_customers,
1486 db=db,
1487 page=page,
1488 page_size=page_size,
1489 order=order,
1490 )
1491 total = await alert_total_by_customer_codes(db, accessible_customers)
1492 open_alerts = await alerts_open_by_customer_codes(db, accessible_customers)
1493 in_progress = await alerts_in_progress_by_customer_codes(db, accessible_customers)
1494 closed = await alerts_closed_by_customer_codes(db, accessible_customers)
1495
1496 return AlertOutResponse(
1497 alerts=alerts,
1498 total=total,
1499 open=open_alerts,
1500 in_progress=in_progress,
1501 closed=closed,
1502 success=True,
1503 message="Alerts retrieved successfully",
1504 )
1505
1506
1507 @incidents_db_operations_router.get(
1508 "/alerts/asset/{asset_name}",
1509 response_model=AlertOutResponse,
1510 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1511 )
1512 async def list_alerts_by_asset_name_endpoint(
1513 asset_name: str,
1514 page: int = Query(1, ge=1),
1515 page_size: int = Query(25, ge=1),
1516 order: str = Query("desc", pattern="^(asc|desc)$"),
1517 customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the results to"),
1518 current_user: User = Depends(AuthHandler().get_current_user),
1519 db: AsyncSession = Depends(get_db),
1520 ):
1521 """List alerts by asset name with customer access filtering"""
1522 logger.info(f"Listing alerts by asset {asset_name} for user: {current_user.username} with role_id: {current_user.role_id}")
1523
1524 # Get customer access filtering (optionally narrowed to a requested subset)
1525 accessible_customers = await customer_access_handler.resolve_effective_customers(current_user, customer_codes, db)
1526
1527 if "*" in accessible_customers:
1528 # Admin/analyst - no filtering needed
1529 alerts = await list_alerts_by_asset_name(asset_name, db, page=page, page_size=page_size, order=order)
1530 total = await alert_total_by_assest_name(db, asset_name)
1531 open_alerts = await alerts_open_by_assest_name(db, asset_name)
1532 in_progress = await alerts_in_progress_by_assest_name(db, asset_name)
1533 closed = await alerts_closed_by_asset_name(db, asset_name)
1534 else:
1535 # Customer user - filter by accessible customers
1536 alerts = await list_alerts_multiple_filters(
1537 asset_name=asset_name,
1538 customer_codes=accessible_customers,
1539 db=db,
1540 page=page,
1541 page_size=page_size,
1542 order=order,
1543 )
1544 total = await alert_total_by_customer_codes(db, accessible_customers)
1545 open_alerts = await alerts_open_by_customer_codes(db, accessible_customers)
1546 in_progress = await alerts_in_progress_by_customer_codes(db, accessible_customers)
1547 closed = await alerts_closed_by_customer_codes(db, accessible_customers)
1548
1549 return AlertOutResponse(
1550 alerts=alerts,
1551 total=total,
1552 open=open_alerts,
1553 in_progress=in_progress,
1554 closed=closed,
1555 success=True,
1556 message="Alerts retrieved successfully",
1557 )
1558
1559
1560 @incidents_db_operations_router.get(
1561 "/alerts/title/{title}",
1562 response_model=AlertOutResponse,
1563 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1564 )
1565 async def list_alerts_by_title_endpoint(
1566 title: str,
1567 page: int = Query(1, ge=1),
1568 page_size: int = Query(25, ge=1),
1569 order: str = Query("desc", pattern="^(asc|desc)$"),
1570 current_user: User = Depends(AuthHandler().get_current_user),
1571 db: AsyncSession = Depends(get_db),
1572 ):
1573 """List alerts by title with customer access filtering"""
1574 logger.info(f"Listing alerts by title {title} for user: {current_user.username} with role_id: {current_user.role_id}")
1575
1576 # Get customer access filtering
1577 accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
1578
1579 if "*" in accessible_customers:
1580 # Admin/analyst - no filtering needed
1581 alerts = await list_alerts_by_title(title, db, page=page, page_size=page_size, order=order)
1582 total = await alert_total_by_alert_title(db, title)
1583 open_alerts = await alerts_open_by_alert_title(db, title)
1584 in_progress = await alerts_in_progress_by_alert_title(db, title)
1585 closed = await alerts_closed_by_alert_title(db, title)
1586 else:
1587 # Customer user - filter by accessible customers
1588 alerts = await list_alerts_multiple_filters(
1589 alert_title=title,
1590 customer_codes=accessible_customers,
1591 db=db,
1592 page=page,
1593 page_size=page_size,
1594 order=order,
1595 )
1596 total = await alert_total_by_customer_codes(db, accessible_customers)
1597 open_alerts = await alerts_open_by_customer_codes(db, accessible_customers)
1598 in_progress = await alerts_in_progress_by_customer_codes(db, accessible_customers)
1599 closed = await alerts_closed_by_customer_codes(db, accessible_customers)
1600
1601 return AlertOutResponse(
1602 alerts=alerts,
1603 total=total,
1604 open=open_alerts,
1605 in_progress=in_progress,
1606 closed=closed,
1607 success=True,
1608 message="Alerts retrieved successfully",
1609 )
1610
1611
1612 @incidents_db_operations_router.get(
1613 "/alerts/customer/{customer_code}",
1614 response_model=AlertOutResponse,
1615 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1616 )
1617 async def list_alerts_by_customer_code_endpoint(
1618 customer_code: str,
1619 page: int = Query(1, ge=1),
1620 page_size: int = Query(25, ge=1),
1621 order: str = Query("desc", pattern="^(asc|desc)$"),
1622 current_user: User = Depends(customer_access_handler.require_customer_access()),
1623 db: AsyncSession = Depends(get_db),
1624 ):
1625 """List alerts for specific customer (with access validation)"""
1626 # Verify user has access to this specific customer
1627 if not await customer_access_handler.check_customer_access(current_user, customer_code, db):
1628 raise HTTPException(status_code=403, detail="Access denied to this customer")
1629
1630 return AlertOutResponse(
1631 alerts=await list_alerts_by_customer_code(customer_code, db, page, page_size, order),
1632 total=await alerts_total_by_customer_code(db, customer_code),
1633 open=await alerts_open_by_customer_code(db, customer_code),
1634 in_progress=await alerts_in_progress_by_customer_code(db, customer_code),
1635 closed=await alerts_closed_by_customer_code(db, customer_code),
1636 success=True,
1637 message="Alerts retrieved successfully",
1638 )
1639
1640
1641 @incidents_db_operations_router.get(
1642 "/alerts/source/{source}",
1643 response_model=AlertOutResponse,
1644 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1645 )
1646 async def list_alerts_by_source_endpoint(
1647 source: str,
1648 page: int = Query(1, ge=1),
1649 page_size: int = Query(25, ge=1),
1650 order: str = Query("desc", pattern="^(asc|desc)$"),
1651 customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the results to"),
1652 current_user: User = Depends(AuthHandler().get_current_user),
1653 db: AsyncSession = Depends(get_db),
1654 ):
1655 """List alerts by source with customer access filtering"""
1656 logger.info(f"Listing alerts by source {source} for user: {current_user.username} with role_id: {current_user.role_id}")
1657
1658 # Get customer access filtering (optionally narrowed to a requested subset)
1659 accessible_customers = await customer_access_handler.resolve_effective_customers(current_user, customer_codes, db)
1660
1661 if "*" in accessible_customers:
1662 # Admin/analyst - no filtering needed
1663 alerts = await list_alerts_by_source(source, db, page=page, page_size=page_size, order=order)
1664 total = await alerts_total_by_source(db, source)
1665 open_alerts = await alerts_open_by_source(db, source)
1666 in_progress = await alerts_in_progress_by_source(db, source)
1667 closed = await alerts_closed_by_source(db, source)
1668 else:
1669 # Customer user - filter by accessible customers
1670 alerts = await list_alerts_multiple_filters(
1671 source=source,
1672 customer_codes=accessible_customers,
1673 db=db,
1674 page=page,
1675 page_size=page_size,
1676 order=order,
1677 )
1678 total = await alert_total_by_customer_codes(db, accessible_customers)
1679 open_alerts = await alerts_open_by_customer_codes(db, accessible_customers)
1680 in_progress = await alerts_in_progress_by_customer_codes(db, accessible_customers)
1681 closed = await alerts_closed_by_customer_codes(db, accessible_customers)
1682
1683 return AlertOutResponse(
1684 alerts=alerts,
1685 total=total,
1686 open=open_alerts,
1687 in_progress=in_progress,
1688 closed=closed,
1689 success=True,
1690 message="Alerts retrieved successfully",
1691 )
1692
1693
1694 @incidents_db_operations_router.get(
1695 "/alerts/filter",
1696 response_model=AlertOutResponse,
1697 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1698 )
1699 async def list_alerts_multiple_filters_endpoint(
1700 assigned_to: Optional[str] = Query(None),
1701 alert_title: Optional[str] = Query(None),
1702 customer_code: Optional[str] = Query(None),
1703 source: Optional[str] = Query(None),
1704 asset_name: Optional[str] = Query(None),
1705 status: Optional[str] = Query(None),
1706 tags: Optional[List[str]] = Query(None),
1707 ioc_value: Optional[str] = Query(None),
1708 page: int = Query(1, ge=1),
1709 page_size: int = Query(25, ge=1),
1710 order: str = Query("desc", pattern="^(asc|desc)$"),
1711 current_user: User = Depends(AuthHandler().get_current_user),
1712 db: AsyncSession = Depends(get_db),
1713 ):
1714 """
1715 Endpoint to list alerts with multiple filters and customer/tag access control.
1716 """
1717 logger.info(f"Listing alerts with filters for user: {current_user.username} with role_id: {current_user.role_id}")
1718
1719 # Get customer access filtering
1720 accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
1721
1722 # Apply customer filtering if user is not admin/analyst
1723 if "*" not in accessible_customers:
1724 # If user provided customer_code, validate they have access to it
1725 if customer_code and customer_code not in accessible_customers:
1726 raise HTTPException(status_code=403, detail=f"Access denied to customer {customer_code}")
1727
1728 # If no customer_code specified, use the first accessible customer for single customer users
1729 if not customer_code and len(accessible_customers) == 1:
1730 customer_code = accessible_customers[0]
1731
1732 # Pass user for tag filtering
1733 alerts = await list_alerts_multiple_filters(
1734 assigned_to=assigned_to,
1735 alert_title=alert_title,
1736 customer_code=customer_code,
1737 source=source,
1738 asset_name=asset_name,
1739 status=status,
1740 tags=tags,
1741 ioc_value=ioc_value,
1742 db=db,
1743 page=page,
1744 page_size=page_size,
1745 order=order,
1746 # Constrain scoped users to their accessible customers (prevents cross-tenant
1747 # disclosure when the user has >1 customer and no explicit customer_code).
1748 customer_codes=None if "*" in accessible_customers else accessible_customers,
1749 user=current_user, # Pass user for tag filtering
1750 )
1751
1752 # Get totals with both customer and tag filtering
1753 total = await alert_total_for_user(current_user, db)
1754 open_alerts = await alerts_open_for_user(current_user, db)
1755 in_progress = await alerts_in_progress_for_user(current_user, db)
1756 closed = await alerts_closed_for_user(current_user, db)
1757
1758 # Get filtered total
1759 total_filtered = await alerts_total_multiple_filters(
1760 assigned_to=assigned_to,
1761 alert_title=alert_title,
1762 customer_code=customer_code,
1763 source=source,
1764 asset_name=asset_name,
1765 status=status,
1766 tags=tags,
1767 ioc_value=ioc_value,
1768 db=db,
1769 )
1770
1771 return AlertOutResponse(
1772 alerts=alerts,
1773 total_filtered=total_filtered,
1774 open=open_alerts,
1775 in_progress=in_progress,
1776 closed=closed,
1777 total=total,
1778 success=True,
1779 message="Alerts retrieved successfully",
1780 )
1781
1782
1783 @incidents_db_operations_router.get(
1784 "/cases",
1785 response_model=CaseOutResponse,
1786 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1787 )
1788 async def list_cases_endpoint(
1789 page: int = Query(1, ge=1),
1790 page_size: int = Query(25, ge=1),
1791 order: str = Query("desc", pattern="^(asc|desc)$"),
1792 customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the results to"),
1793 current_user: User = Depends(AuthHandler().get_current_user),
1794 db: AsyncSession = Depends(get_db),
1795 ):
1796 """List cases with automatic customer filtering and pagination"""
1797 logger.info(f"Listing cases for user: {current_user.username} with role_id: {current_user.role_id}")
1798
1799 cases = await list_cases_for_user(current_user, db, page, page_size, order, customer_codes=customer_codes)
1800
1801 total = await case_total_for_user(current_user, db, customer_codes=customer_codes)
1802 open_cases = await cases_open_for_user(current_user, db, customer_codes=customer_codes)
1803 in_progress = await cases_in_progress_for_user(current_user, db, customer_codes=customer_codes)
1804 closed = await cases_closed_for_user(current_user, db, customer_codes=customer_codes)
1805
1806 return CaseOutResponse(
1807 cases=cases,
1808 total=total,
1809 open=open_cases,
1810 in_progress=in_progress,
1811 closed=closed,
1812 success=True,
1813 message="Cases retrieved successfully",
1814 )
1815
1816
1817 @incidents_db_operations_router.put(
1818 "/case/status",
1819 # Response can be either CaseOutResponse (normal close) or
1820 # CaseCloseWarningResponse (soft warning when mandatory tasks are
1821 # incomplete). FastAPI doesn't model Union responses cleanly under
1822 # Pydantic v1, so we drop response_model and document the contract
1823 # in the docstring + responses dict.
1824 response_model=None,
1825 responses={
1826 200: {
1827 "description": (
1828 "Either the updated case or a soft-warning payload when closing a case with "
1829 "incomplete mandatory tasks. See CaseCloseWarningResponse — re-submit with "
1830 "?force=true to confirm."
1831 ),
1832 },
1833 },
1834 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1835 )
1836 async def update_case_status_endpoint(
1837 case_status: UpdateCaseStatus,
1838 force: bool = Query(
1839 False,
1840 description=(
1841 "When closing a case, set force=true to bypass the soft warning that fires when "
1842 "mandatory tasks are not all marked DONE. Has no effect for non-CLOSED transitions."
1843 ),
1844 ),
1845 current_user: User = Depends(AuthHandler().get_current_user),
1846 db: AsyncSession = Depends(get_db),
1847 ):
1848 """Update case status with customer access validation and auto-update linked alerts.
1849
1850 Phase 3 (issue #792) adds a soft-warning gate: closing a case with
1851 incomplete mandatory tasks returns a CaseCloseWarningResponse and
1852 does NOT actually close the case. The caller re-submits with
1853 force=true to override.
1854 """
1855 logger.info(f"Updating case {case_status.case_id} status to {case_status.status} for user: {current_user.username}")
1856
1857 # Get the case first to check customer access
1858 case = await get_case_by_id(case_status.case_id, db)
1859
1860 # Check if user has access to this case's customer
1861 if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
1862 raise HTTPException(status_code=403, detail=f"Access denied to case {case_status.case_id} - insufficient customer permissions")
1863
1864 # Store the old status BEFORE updating - this is the actual current status from the database
1865 old_status = case.case_status
1866
1867 # Convert new status enum to string value for comparison
1868 new_status_value = case_status.status.value if hasattr(case_status.status, "value") else str(case_status.status)
1869
1870 logger.info(f"Case status transition: {old_status} -> {new_status_value}")
1871
1872 # Phase 3 (issue #792) soft-warning gate: if the case is being closed
1873 # and any mandatory CaseTask is not DONE, return a warning payload
1874 # without performing the close. force=true overrides.
1875 if new_status_value == "CLOSED" and old_status != "CLOSED" and not force:
1876 from app.incidents.services.case_tasks import build_close_warning_response
1877 from app.incidents.services.case_tasks import get_incomplete_mandatory_tasks
1878
1879 incomplete = await get_incomplete_mandatory_tasks(case_status.case_id, db)
1880 if incomplete:
1881 logger.info(
1882 f"Case {case_status.case_id} close blocked by soft warning: " f"{len(incomplete)} mandatory task(s) not DONE",
1883 )
1884 return build_close_warning_response(incomplete)
1885
1886 try:
1887 # Get all alert IDs linked to this case BEFORE updating
1888 result = await db.execute(select(CaseAlertLink.alert_id).where(CaseAlertLink.case_id == case_status.case_id))
1889 alert_ids = [row[0] for row in result]
1890
1891 logger.info(f"Found {len(alert_ids)} alerts linked to case {case_status.case_id}")
1892
1893 # Determine what to do with linked alerts based on status transition
1894 new_alert_status = None
1895
1896 # Handle status transitions
1897 if new_status_value == "CLOSED" and (old_status != "CLOSED" or old_status is None):
1898 # Case is being closed - close all linked alerts
1899 logger.info(f"Closing {len(alert_ids)} alerts linked to case {case_status.case_id}")
1900 new_alert_status = "CLOSED"
1901
1902 elif old_status == "CLOSED" and new_status_value in ["OPEN", "IN_PROGRESS"]:
1903 # Case is being reopened from CLOSED - reopen alerts to IN_PROGRESS
1904 logger.info(f"Reopening {len(alert_ids)} alerts linked to case {case_status.case_id} to IN_PROGRESS")
1905 new_alert_status = "IN_PROGRESS"
1906
1907 elif old_status == "CLOSED" and new_status_value != "CLOSED":
1908 # Case is being reopened from CLOSED to any other status - reopen to IN_PROGRESS
1909 logger.info(f"Reopening {len(alert_ids)} alerts linked to case {case_status.case_id} to IN_PROGRESS")
1910 new_alert_status = "IN_PROGRESS"
1911
1912 else:
1913 # No alert status change needed for other transitions
1914 logger.info(f"No alert status change needed for transition from {old_status} to {new_status_value}")
1915
1916 # Update alert statuses if needed (BEFORE updating the case)
1917 if new_alert_status:
1918 closed_count = 0
1919 failed_alerts = []
1920
1921 for alert_id in alert_ids:
1922 try:
1923 logger.debug(f"Updating alert {alert_id} to {new_alert_status}")
1924 await update_alert_status(UpdateAlertStatus(alert_id=alert_id, status=new_alert_status), db)
1925 closed_count += 1
1926 except Exception as e:
1927 logger.error(f"Failed to update alert {alert_id}: {str(e)}")
1928 failed_alerts.append(alert_id)
1929
1930 logger.info(f"Successfully updated {closed_count}/{len(alert_ids)} alerts to {new_alert_status}")
1931
1932 if failed_alerts:
1933 logger.warning(f"Failed to update alerts: {failed_alerts}")
1934
1935 # NOW update the case status (after we've handled the alerts)
1936 await update_case_status(case_status, db)
1937
1938 # Commit all changes
1939 await db.commit()
1940
1941 except Exception as e:
1942 logger.error(f"Error updating case status: {str(e)}")
1943 await db.rollback()
1944 raise HTTPException(status_code=500, detail=f"Failed to update case status: {str(e)}")
1945
1946 # Phase 4 audit emit. Records the from/to status, plus a forced flag
1947 # when the soft-warning was bypassed so it shows up in the timeline.
1948 from app.incidents.schema.case_templates import CaseEventType as _ET
1949 from app.incidents.services.case_events import emit_case_event as _emit
1950 from app.incidents.services.case_events import payload_status_change as _psc
1951
1952 await _emit(
1953 session=db,
1954 case_id=case_status.case_id,
1955 event_type=_ET.CASE_STATUS_CHANGED,
1956 actor=current_user.username,
1957 payload=_psc(
1958 from_status=old_status,
1959 to_status=new_status_value,
1960 forced=(force and new_status_value == "CLOSED"),
1961 ),
1962 commit=True,
1963 )
1964
1965 # Re-fetch the case with full data structure
1966 updated_case = await get_case_by_id(case_status.case_id, db)
1967
1968 message = "Case status updated successfully"
1969 if alert_ids and new_alert_status:
1970 message += f" and {len(alert_ids)} linked alerts updated to {new_alert_status}"
1971
1972 return CaseOutResponse(cases=[updated_case], success=True, message=message)
1973
1974
1975 @incidents_db_operations_router.put(
1976 "/case/escalated",
1977 response_model=CaseOutResponse,
1978 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1979 )
1980 async def update_case_escalated_endpoint(
1981 escalate_case: EscalateCase,
1982 current_user: User = Depends(AuthHandler().get_current_user),
1983 db: AsyncSession = Depends(get_db),
1984 ):
1985 """Update case escalated status with customer access validation"""
1986 logger.info(
1987 f"Updating case {escalate_case.case_id} escalated status for user: {current_user.username} with role_id: {current_user.role_id}",
1988 )
1989
1990 # Get the case first to check customer access
1991 case = await get_case_by_id(escalate_case.case_id, db)
1992
1993 # Check if user has access to this case's customer
1994 if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
1995 raise HTTPException(status_code=403, detail=f"Access denied to case {escalate_case.case_id} - insufficient customer permissions")
1996
1997 # Update the case escalated status
1998 await update_case_escalated(escalate_case.case_id, escalate_case.escalated, db)
1999
2000 # Phase 4 audit emit
2001 from app.incidents.schema.case_templates import CaseEventType
2002 from app.incidents.services.case_events import emit_case_event
2003 from app.incidents.services.case_events import payload_escalation
2004
2005 await emit_case_event(
2006 session=db,
2007 case_id=escalate_case.case_id,
2008 event_type=CaseEventType.CASE_ESCALATED,
2009 actor=current_user.username,
2010 payload=payload_escalation(escalated=escalate_case.escalated),
2011 commit=True,
2012 )
2013
2014 # Re-fetch the case with full data structure
2015 updated_case = await get_case_by_id(escalate_case.case_id, db)
2016 return CaseOutResponse(cases=[updated_case], success=True, message="Case escalated status updated successfully")
2017
2018
2019 @incidents_db_operations_router.put(
2020 "/case/assigned-to",
2021 response_model=CaseOutResponse,
2022 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2023 )
2024 async def update_case_assigned_to_endpoint(
2025 assigned_to: AssignedToCase,
2026 current_user: User = Depends(AuthHandler().get_current_user),
2027 db: AsyncSession = Depends(get_db),
2028 ):
2029 """Update case assigned_to with customer access validation"""
2030 logger.info(f"Updating case {assigned_to.case_id} assigned_to for user: {current_user.username} with role_id: {current_user.role_id}")
2031
2032 # Get the case first to check customer access
2033 case = await get_case_by_id(assigned_to.case_id, db)
2034
2035 # Check if user has access to this case's customer
2036 if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
2037 raise HTTPException(status_code=403, detail=f"Access denied to case {assigned_to.case_id} - insufficient customer permissions")
2038
2039 all_users = await select_all_users()
2040 user_names = [user.username for user in all_users]
2041 if assigned_to.assigned_to not in user_names:
2042 raise HTTPException(status_code=400, detail="User does not exist")
2043
2044 # Capture previous assignee BEFORE the mutation so the audit payload is accurate.
2045 previous_assignee = case.assigned_to
2046
2047 # Update the case assigned_to
2048 await update_case_assigned_to(assigned_to.case_id, assigned_to.assigned_to, db)
2049
2050 # Phase 4 audit emit
2051 from app.incidents.schema.case_templates import CaseEventType
2052 from app.incidents.services.case_events import emit_case_event
2053 from app.incidents.services.case_events import payload_assignment
2054
2055 await emit_case_event(
2056 session=db,
2057 case_id=assigned_to.case_id,
2058 event_type=CaseEventType.CASE_ASSIGNED,
2059 actor=current_user.username,
2060 payload=payload_assignment(from_assignee=previous_assignee, to_assignee=assigned_to.assigned_to),
2061 commit=True,
2062 )
2063
2064 # Re-fetch the case with full data structure
2065 updated_case = await get_case_by_id(assigned_to.case_id, db)
2066 return CaseOutResponse(
2067 cases=[updated_case],
2068 success=True,
2069 message="Case assigned to user successfully",
2070 )
2071
2072
2073 @incidents_db_operations_router.put(
2074 "/case/customer-code",
2075 response_model=CaseOutResponse,
2076 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2077 )
2078 async def update_case_customer_code_endpoint(
2079 case_id: int,
2080 customer_code: str,
2081 current_user: User = Depends(AuthHandler().get_current_user),
2082 db: AsyncSession = Depends(get_db),
2083 ):
2084 """Update case customer_code with customer access validation"""
2085 logger.info(f"Updating case {case_id} customer_code for user: {current_user.username} with role_id: {current_user.role_id}")
2086
2087 # Get the case first to check current customer access
2088 case = await get_case_by_id(case_id, db)
2089
2090 # Check if user has access to the current case's customer
2091 if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
2092 raise HTTPException(status_code=403, detail=f"Access denied to case {case_id} - insufficient customer permissions")
2093
2094 # Also check if user has access to the new customer code (for non-admin users)
2095 accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
2096 if "*" not in accessible_customers and customer_code not in accessible_customers:
2097 raise HTTPException(status_code=403, detail=f"Access denied - cannot assign case to customer {customer_code}")
2098
2099 # Update the case customer code
2100 await update_case_customer_code(case_id, customer_code, db)
2101
2102 # Re-fetch the case with full data structure
2103 updated_case = await get_case_by_id(case_id, db)
2104 return CaseOutResponse(
2105 cases=[updated_case],
2106 success=True,
2107 message="Case customer code updated successfully",
2108 )
2109
2110
2111 @incidents_db_operations_router.delete(
2112 "/case/{case_id}",
2113 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2114 )
2115 async def delete_case_endpoint(
2116 case_id: int,
2117 current_user: User = Depends(AuthHandler().get_current_user),
2118 db: AsyncSession = Depends(get_db),
2119 ):
2120 """Delete case with customer access validation"""
2121 logger.info(f"Deleting case {case_id} for user: {current_user.username} with role_id: {current_user.role_id}")
2122
2123 # Get the case first to check customer access
2124 case = await get_case_by_id(case_id, db)
2125
2126 # Check if user has access to this case's customer
2127 if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
2128 raise HTTPException(status_code=403, detail=f"Access denied to case {case_id} - insufficient customer permissions")
2129
2130 await delete_case(case_id, db)
2131 return {"message": "Case deleted successfully", "success": True}
2132
2133
2134 @incidents_db_operations_router.get(
2135 "/case/status/{status}",
2136 response_model=CaseOutResponse,
2137 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2138 )
2139 async def list_cases_by_status_endpoint(
2140 status: AlertStatus,
2141 page: int = Query(1, ge=1),
2142 page_size: int = Query(25, ge=1),
2143 order: str = Query("desc", pattern="^(asc|desc)$"),
2144 customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the results to"),
2145 current_user: User = Depends(AuthHandler().get_current_user),
2146 db: AsyncSession = Depends(get_db),
2147 ):
2148 """List cases by status with customer access filtering and pagination"""
2149 if status not in AlertStatus:
2150 raise HTTPException(status_code=400, detail="Invalid status")
2151
2152 logger.info(f"Listing cases by status {status} for user: {current_user.username} with role_id: {current_user.role_id}")
2153
2154 # Get customer access filtering
2155 accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
2156
2157 if "*" in accessible_customers:
2158 # Admin/analyst - no filtering needed
2159 cases = await list_cases_by_status(status.value, db, page=page, page_size=page_size, order=order)
2160 else:
2161 # Customer user - get paginated cases and filter by status
2162 all_user_cases = await list_cases_for_user(current_user, db, page, page_size, order, customer_codes=customer_codes)
2163 cases = [case for case in all_user_cases if case.case_status == status.value]
2164
2165 total = await case_total_for_user(current_user, db, customer_codes=customer_codes)
2166 open_cases = await cases_open_for_user(current_user, db, customer_codes=customer_codes)
2167 in_progress = await cases_in_progress_for_user(current_user, db, customer_codes=customer_codes)
2168 closed = await cases_closed_for_user(current_user, db, customer_codes=customer_codes)
2169
2170 return CaseOutResponse(
2171 cases=cases,
2172 total=total,
2173 open=open_cases,
2174 in_progress=in_progress,
2175 closed=closed,
2176 success=True,
2177 message="Cases retrieved successfully",
2178 )
2179
2180
2181 @incidents_db_operations_router.get(
2182 "/case/assigned-to/{assigned_to}",
2183 response_model=CaseOutResponse,
2184 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2185 )
2186 async def list_cases_by_assigned_to_endpoint(
2187 assigned_to: str,
2188 customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the results to"),
2189 current_user: User = Depends(AuthHandler().get_current_user),
2190 db: AsyncSession = Depends(get_db),
2191 ):
2192 """List cases by assigned user with customer access filtering"""
2193 logger.info(f"Listing cases assigned to {assigned_to} for user: {current_user.username} with role_id: {current_user.role_id}")
2194
2195 # Get customer access filtering
2196 accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
2197
2198 if "*" in accessible_customers:
2199 # Admin/analyst - no filtering needed
2200 cases = await list_cases_by_assigned_to(assigned_to, db)
2201 else:
2202 # Customer user - filter by accessible customers
2203 all_user_cases = await list_cases_for_user(current_user, db, customer_codes=customer_codes)
2204 cases = [case for case in all_user_cases if case.assigned_to == assigned_to]
2205
2206 return CaseOutResponse(cases=cases, success=True, message="Cases retrieved successfully")
2207
2208
2209 @incidents_db_operations_router.get(
2210 "/case/asset/{asset_name}",
2211 response_model=CaseOutResponse,
2212 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2213 )
2214 async def list_cases_by_asset_name_endpoint(
2215 asset_name: str,
2216 current_user: User = Depends(AuthHandler().get_current_user),
2217 db: AsyncSession = Depends(get_db),
2218 ):
2219 """List cases by asset name with customer access filtering"""
2220 logger.info(f"Listing cases by asset {asset_name} for user: {current_user.username} with role_id: {current_user.role_id}")
2221
2222 # Get customer access filtering
2223 accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
2224
2225 if "*" in accessible_customers:
2226 # Admin/analyst - no filtering needed
2227 cases = await list_cases_by_asset_name(asset_name, db)
2228 else:
2229 # Customer user - filter by accessible customers and asset name
2230 all_user_cases = await list_cases_for_user(current_user, db)
2231 cases = []
2232 for case in all_user_cases:
2233 # Check if any alert in the case has the specified asset name
2234 for alert in case.alerts:
2235 if alert.assets and any(asset.asset_name == asset_name for asset in alert.assets):
2236 cases.append(case)
2237 break
2238
2239 return CaseOutResponse(cases=cases, success=True, message="Cases retrieved successfully")
2240
2241
2242 @incidents_db_operations_router.get(
2243 "/case/customer/{customer_code}",
2244 response_model=CaseOutResponse,
2245 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2246 )
2247 async def list_cases_by_customer_code_endpoint(
2248 customer_code: str,
2249 current_user: User = Depends(customer_access_handler.require_customer_access()),
2250 db: AsyncSession = Depends(get_db),
2251 ):
2252 """List cases for specific customer (with access validation)"""
2253 logger.info(f"Listing cases for customer {customer_code} for user: {current_user.username} with role_id: {current_user.role_id}")
2254
2255 # Verify user has access to this specific customer
2256 if not await customer_access_handler.check_customer_access(current_user, customer_code, db):
2257 raise HTTPException(status_code=403, detail="Access denied to this customer")
2258
2259 return CaseOutResponse(cases=await list_cases_by_customer_code(customer_code, db), success=True, message="Cases retrieved successfully")
2260
2261
2262 @incidents_db_operations_router.get(
2263 "/case/data-store",
2264 response_model=ListCaseDataStoreResponse,
2265 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2266 )
2267 async def list_all_case_data_store_files_endpoint(db: AsyncSession = Depends(get_db)):
2268 logger.info("Listing all files in the data store")
2269 return ListCaseDataStoreResponse(case_data_store=await list_all_files(db), success=True, message="Files retrieved successfully")
2270
2271
2272 @incidents_db_operations_router.get(
2273 "/case/data-store/{case_id}",
2274 response_model=ListCaseDataStoreResponse,
2275 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2276 )
2277 async def list_case_data_store_files_endpoint(
2278 case_id: int,
2279 current_user: User = Depends(AuthHandler().get_current_user),
2280 db: AsyncSession = Depends(get_db),
2281 ):
2282 """List case data store files with customer access validation"""
2283 logger.info(f"Listing files for case {case_id} for user: {current_user.username} with role_id: {current_user.role_id}")
2284
2285 # Get the case first to check customer access
2286 case = await get_case_by_id(case_id, db)
2287
2288 # Check if user has access to this case's customer
2289 if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
2290 raise HTTPException(status_code=403, detail=f"Access denied to case {case_id} - insufficient customer permissions")
2291
2292 return ListCaseDataStoreResponse(
2293 case_data_store=await list_files_by_case_id(case_id, db),
2294 success=True,
2295 message="Files retrieved successfully",
2296 )
2297
2298
2299 @incidents_db_operations_router.get(
2300 "/case/data-store/download/{case_id}/{file_name}",
2301 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2302 )
2303 async def download_case_data_store_file_endpoint(
2304 case_id: int,
2305 file_name: str,
2306 current_user: User = Depends(AuthHandler().get_current_user),
2307 db: AsyncSession = Depends(get_db),
2308 ) -> StreamingResponse:
2309 """Download case data store file with customer access validation"""
2310 logger.info(f"Downloading file {file_name} from case {case_id} for user: {current_user.username} with role_id: {current_user.role_id}")
2311
2312 # Get the case first to check customer access
2313 case = await get_case_by_id(case_id, db)
2314
2315 # Check if user has access to this case's customer
2316 if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
2317 raise HTTPException(status_code=403, detail=f"Access denied to case {case_id} - insufficient customer permissions")
2318
2319 file_bytes, file_content_type = await download_file_from_case(case_id, file_name, db)
2320 logger.info(f"Streaming file {file_name} from case {case_id}")
2321 output = io.BytesIO(file_bytes)
2322 output.seek(0)
2323
2324 return StreamingResponse(output, media_type=file_content_type, headers={"Content-Disposition": f"attachment; filename={file_name}"})
2325
2326
2327 @incidents_db_operations_router.post(
2328 "/case/data-store/upload",
2329 response_model=CaseDataStoreResponse,
2330 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2331 )
2332 async def upload_case_data_store_endpoint(
2333 case_id: int,
2334 file: UploadFile = File(...),
2335 current_user: User = Depends(AuthHandler().get_current_user),
2336 db: AsyncSession = Depends(get_db),
2337 ):
2338 """Upload file to case data store with customer access validation"""
2339 logger.info(f"Uploading file {file.filename} to case {case_id} for user: {current_user.username} with role_id: {current_user.role_id}")
2340
2341 # Get the case first to check customer access
2342 case = await get_case_by_id(case_id, db)
2343
2344 # Check if user has access to this case's customer
2345 if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
2346 raise HTTPException(status_code=403, detail=f"Access denied to case {case_id} - insufficient customer permissions")
2347
2348 if await file_exists(case_id, file.filename, db):
2349 raise HTTPException(status_code=400, detail="File name already exists for this case")
2350
2351 return CaseDataStoreResponse(
2352 case_data_store=await upload_file_to_case(case_id, file, db),
2353 success=True,
2354 message="File uploaded successfully",
2355 )
2356
2357
2358 @incidents_db_operations_router.delete(
2359 "/case/data-store/{case_id}/{file_name}",
2360 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2361 )
2362 async def delete_case_data_store_file_endpoint(
2363 case_id: int,
2364 file_name: str,
2365 current_user: User = Depends(AuthHandler().get_current_user),
2366 db: AsyncSession = Depends(get_db),
2367 ):
2368 """Delete case data store file with customer access validation"""
2369 logger.info(f"Deleting file {file_name} from case {case_id} for user: {current_user.username} with role_id: {current_user.role_id}")
2370
2371 # Get the case first to check customer access
2372 case = await get_case_by_id(case_id, db)
2373
2374 # Check if user has access to this case's customer
2375 if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
2376 raise HTTPException(status_code=403, detail=f"Access denied to case {case_id} - insufficient customer permissions")
2377
2378 await delete_file_from_case(case_id, file_name, db)
2379 return {"message": "File deleted successfully", "success": True}
2380
2381
2382 @incidents_db_operations_router.get(
2383 "/case/{case_id}",
2384 response_model=CaseOutResponse,
2385 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2386 )
2387 async def get_case_by_id_endpoint(
2388 case_id: int,
2389 current_user: User = Depends(AuthHandler().get_current_user),
2390 db: AsyncSession = Depends(get_db),
2391 ):
2392 """Get case by ID with customer access validation"""
2393 logger.info(f"Getting case {case_id} for user: {current_user.username} with role_id: {current_user.role_id}")
2394
2395 # Get the case first
2396 case = await get_case_by_id(case_id, db)
2397
2398 # Check if user has access to this case's customer
2399 if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
2400 raise HTTPException(status_code=403, detail=f"Access denied to case {case_id} - insufficient customer permissions")
2401
2402 return CaseOutResponse(cases=[case], success=True, message="Case retrieved successfully")
2403
2404
2405 @incidents_db_operations_router.post(
2406 "/case/notification",
2407 response_model=CaseNotificationResponse,
2408 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2409 )
2410 async def create_case_notification_endpoint(
2411 request: CaseNotificationCreate,
2412 current_user: User = Depends(AuthHandler().get_current_user),
2413 db: AsyncSession = Depends(get_db),
2414 ):
2415 """
2416 Create case notification with customer access validation.
2417
2418 This function collects the case details and then invokes the create_case_notification function to create a new case notification within the Shuffle Workflow.
2419
2420 Args:
2421 request (CaseNotificationCreate): The request object containing the case details.
2422 current_user (User): Current authenticated user.
2423 db (AsyncSession, optional): The database session dependency.
2424
2425 Returns:
2426 CaseNotificationResponse: The response object containing the created case notification.
2427 """
2428 logger.info(
2429 f"Creating case notification for case {request.case_id} for user: {current_user.username} with role_id: {current_user.role_id}",
2430 )
2431
2432 case_details = await get_case_by_id(request.case_id, db)
2433
2434 # Check if user has access to this case's customer
2435 if not await customer_access_handler.check_customer_access(current_user, case_details.customer_code, db):
2436 raise HTTPException(status_code=403, detail=f"Access denied to case {request.case_id} - insufficient customer permissions")
2437
2438 case_notification_payload = CreatedCaseNotificationPayload(
2439 case_name=case_details.case_name,
2440 case_description=case_details.case_description,
2441 case_creation_time=case_details.case_creation_time,
2442 alerts=[
2443 CreatedAlertPayload(
2444 alert_context_payload=(await get_alert_context_by_id(alert.assets[0].alert_context_id, db)).context
2445 if alert.assets
2446 else None, # Populate with actual alert context data
2447 asset_payload=alert.assets[0].asset_name if alert.assets else "", # Populate with actual asset data
2448 timefield_payload="", # Populate with actual timefield data
2449 alert_title_payload=alert.alert_name, # Populate with actual alert title data
2450 ioc_payload={ioc.value: ioc.type for ioc in alert.iocs} if alert.iocs else {}, # Populate with actual IoC data if available
2451 source=alert.source,
2452 )
2453 for alert in case_details.alerts
2454 ],
2455 )
2456
2457 logger.info(f"Creating case notification for case {case_notification_payload}")
2458 await handle_customer_notifications_case(customer_code=case_details.customer_code, case_payload=case_notification_payload, session=db)
2459 await increment_case_notification_count(request.case_id, db)
2460 return CaseNotificationResponse(success=True, message="Case notification created successfully")
2461
2462
2463 @incidents_db_operations_router.get(
2464 "/case-report-template",
2465 response_model=CaseReportTemplateDataStoreListResponse,
2466 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2467 )
2468 async def list_case_report_template_data_store_files_endpoint(db: AsyncSession = Depends(get_db)):
2469 logger.info("Listing all files in the data store")
2470 return CaseReportTemplateDataStoreListResponse(
2471 case_report_template_data_store=await list_case_report_template_data_store_files(),
2472 success=True,
2473 message="Files retrieved successfully",
2474 )
2475
2476
2477 @incidents_db_operations_router.get(
2478 "/case-report-template/do-default-template-exists",
2479 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2480 )
2481 async def check_default_case_report_template_exists_endpoint(db: AsyncSession = Depends(get_db)):
2482 """
2483 Endpoint to check if any of the default case report template files exist in the data store.
2484
2485 If any of them do, return True, else return False.
2486
2487 Returns:
2488 - success (bool): Indicates if the operation was successful.
2489 - message (str): Success message.
2490 - default_template_exists (bool): Indicates if any of the default case report template files exist in the data store.
2491 """
2492 for template in DefaultReportTemplateFileNames:
2493 if await report_template_exists(template.value, db):
2494 return {"success": True, "message": "Default case report template exists", "default_template_exists": True}
2495
2496 return {"success": True, "message": "No default case report templates exist", "default_template_exists": False}
2497
2498
2499 @incidents_db_operations_router.post(
2500 "/case-report-template/default-template",
2501 response_model=CaseReportTemplateDataStoreListResponse,
2502 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2503 )
2504 async def create_default_case_report_template_endpoint(db: AsyncSession = Depends(get_db)):
2505 """
2506 Create a default case report template in the data store.
2507
2508 Returns:
2509 CaseReportTemplateDataStoreListResponse: The response containing the created case report template data store.
2510
2511 Raises:
2512 None
2513 """
2514 logger.info("Creating default file in the data store")
2515 return CaseReportTemplateDataStoreListResponse(
2516 case_report_template_data_store=await upload_report_template_to_data_store(db),
2517 success=True,
2518 message="Default file created successfully",
2519 )
2520
2521
2522 @incidents_db_operations_router.get(
2523 "/case-report-template/download/{file_name}",
2524 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2525 )
2526 async def download_case_report_template_endpoint(file_name: str, db: AsyncSession = Depends(get_db)) -> StreamingResponse:
2527 file_bytes, file_content_type = await download_report_template(file_name, db)
2528 logger.info(f"Streaming file {file_name}")
2529 output = io.BytesIO(file_bytes)
2530 output.seek(0)
2531
2532 return StreamingResponse(output, media_type=file_content_type, headers={"Content-Disposition": f"attachment; filename={file_name}"})
2533
2534
2535 @incidents_db_operations_router.post(
2536 "/case-report-template/upload",
2537 response_model=CaseReportTemplateDataStoreResponse,
2538 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2539 )
2540 async def upload_case_report_template_endpoint(
2541 file: UploadFile = File(...),
2542 db: AsyncSession = Depends(get_db),
2543 ):
2544 # Check if the file type is a .docx or .html
2545 mime_type, _ = mimetypes.guess_type(file.filename)
2546 allowed_mime_types = ["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "text/html"] # .docx # .html
2547 if mime_type not in allowed_mime_types:
2548 raise HTTPException(status_code=400, detail="Invalid file type. Only .docx and .html files are allowed.")
2549
2550 if await report_template_exists(file.filename, db):
2551 raise HTTPException(status_code=400, detail="File name already exists for this template")
2552
2553 return CaseReportTemplateDataStoreResponse(
2554 case_report_template_data_store=await upload_report_template(file, db),
2555 success=True,
2556 message="File uploaded successfully",
2557 )
2558
2559
2560 @incidents_db_operations_router.delete(
2561 "/case-report-template/{file_name}",
2562 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2563 )
2564 async def delete_case_report_template_endpoint(file_name: str, db: AsyncSession = Depends(get_db)):
2565 await delete_report_template(file_name, db)
2566 return {"message": "File deleted successfully", "success": True}
2567
2568
2569 # ============================================================================
2570 # Case Tasks (Phase 3, issue #792)
2571 #
2572 # Customer portal users have read-only visibility on tasks (GET allowed),
2573 # but creation, status updates, and deletion are restricted to admin/analyst.
2574 # Customer access is enforced per-case using customer_access_handler.
2575 # ============================================================================
2576
2577 # Authorization handles imported lazily here to avoid a circular import: the
2578 # case_tasks service imports from this services/db_operations module too.
2579 _admin_analyst_dep = Security(AuthHandler().require_any_scope("admin", "analyst"))
2580 _all_scopes_dep = Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))
2581
2582
2583 async def _ensure_case_access(case_id: int, current_user: User, db: AsyncSession) -> None:
2584 """Shared helper: 404 if case missing, 403 if user lacks customer access."""
2585 case = await get_case_by_id(case_id, db)
2586 if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
2587 raise HTTPException(
2588 status_code=403,
2589 detail=f"Access denied to case {case_id} - insufficient customer permissions",
2590 )
2591
2592
2593 @incidents_db_operations_router.get(
2594 "/case/{case_id}/tasks",
2595 description="List CaseTask rows for a case. Visible to admin, analyst, and customer_user (read-only).",
2596 dependencies=[_all_scopes_dep],
2597 )
2598 async def list_case_tasks_endpoint(
2599 case_id: int,
2600 current_user: User = Depends(AuthHandler().get_current_user),
2601 db: AsyncSession = Depends(get_db),
2602 ):
2603 from app.incidents.services.case_tasks import list_case_tasks
2604
2605 await _ensure_case_access(case_id, current_user, db)
2606 return await list_case_tasks(case_id, db)
2607
2608
2609 @incidents_db_operations_router.post(
2610 "/case/{case_id}/tasks",
2611 description="Add a custom task to a case during investigation. Admin/analyst only.",
2612 dependencies=[_admin_analyst_dep],
2613 )
2614 async def add_case_task_endpoint(
2615 case_id: int,
2616 request: CaseTaskCreate,
2617 current_user: User = Depends(AuthHandler().get_current_user),
2618 db: AsyncSession = Depends(get_db),
2619 ):
2620 from app.incidents.services.case_tasks import add_case_task
2621
2622 await _ensure_case_access(case_id, current_user, db)
2623 return await add_case_task(case_id, request, current_user.username, db)
2624
2625
2626 @incidents_db_operations_router.patch(
2627 "/case/tasks/{task_id}",
2628 description=(
2629 "Update a CaseTask: change status (TODO/DONE/NOT_NECESSARY) and/or attach an evidence "
2630 "comment. NOT_NECESSARY is rejected for mandatory tasks. Admin/analyst only."
2631 ),
2632 dependencies=[_admin_analyst_dep],
2633 )
2634 async def update_case_task_endpoint(
2635 task_id: int,
2636 request: CaseTaskUpdate,
2637 current_user: User = Depends(AuthHandler().get_current_user),
2638 db: AsyncSession = Depends(get_db),
2639 ):
2640 from app.incidents.models import CaseTask
2641 from app.incidents.services.case_tasks import update_case_task
2642
2643 # Resolve task -> case -> customer access. We do this here rather than
2644 # in the service so service layer stays auth-agnostic.
2645 result = await db.execute(select(CaseTask).where(CaseTask.id == task_id))
2646 task_row = result.scalar_one_or_none()
2647 if task_row is None:
2648 raise HTTPException(status_code=404, detail=f"Case task {task_id} not found")
2649 await _ensure_case_access(task_row.case_id, current_user, db)
2650
2651 return await update_case_task(task_id, request, current_user.username, db)
2652
2653
2654 @incidents_db_operations_router.delete(
2655 "/case/tasks/{task_id}",
2656 description="Delete a CaseTask (template-derived or custom). Admin/analyst only.",
2657 dependencies=[_admin_analyst_dep],
2658 )
2659 async def delete_case_task_endpoint(
2660 task_id: int,
2661 current_user: User = Depends(AuthHandler().get_current_user),
2662 db: AsyncSession = Depends(get_db),
2663 ):
2664 from app.incidents.models import CaseTask
2665 from app.incidents.services.case_tasks import delete_case_task
2666
2667 result = await db.execute(select(CaseTask).where(CaseTask.id == task_id))
2668 task_row = result.scalar_one_or_none()
2669 if task_row is None:
2670 raise HTTPException(status_code=404, detail=f"Case task {task_id} not found")
2671 await _ensure_case_access(task_row.case_id, current_user, db)
2672
2673 return await delete_case_task(task_id, db)
2674
2675
2676 @incidents_db_operations_router.post(
2677 "/case/{case_id}/apply-template/{template_id}",
2678 description=(
2679 "Manually apply a CaseTemplate to an existing case (snapshot-copies its tasks). "
2680 "Adds to existing tasks rather than replacing them — the analyst can apply multiple "
2681 "templates over the life of an investigation. When ``alert_id`` is given, the "
2682 "materialized tasks are stamped with that alert id so they group under that alert "
2683 "in the Tasks UI; the alert must already be linked to the case. Admin/analyst only."
2684 ),
2685 dependencies=[_admin_analyst_dep],
2686 )
2687 async def apply_template_to_case_endpoint(
2688 case_id: int,
2689 template_id: int,
2690 alert_id: Optional[int] = Query(
2691 None,
2692 description=(
2693 "Optional linked alert id to scope the materialized tasks to. Tasks without an "
2694 "alert_id appear in the case-wide / general group."
2695 ),
2696 ),
2697 current_user: User = Depends(AuthHandler().get_current_user),
2698 db: AsyncSession = Depends(get_db),
2699 ):
2700 from app.incidents.services.case_tasks import apply_template_to_case
2701 from app.incidents.services.case_tasks import is_alert_linked_to_case
2702
2703 await _ensure_case_access(case_id, current_user, db)
2704 if alert_id is not None and not await is_alert_linked_to_case(case_id, alert_id, db):
2705 raise HTTPException(
2706 status_code=400,
2707 detail=(
2708 f"Alert id={alert_id} is not linked to case id={case_id}; " "link the alert first or omit alert_id to apply case-wide."
2709 ),
2710 )
2711 new_tasks = await apply_template_to_case(
2712 case_id=case_id,
2713 template_id=template_id,
2714 actor=current_user.username,
2715 session=db,
2716 alert_id=alert_id,
2717 commit=True,
2718 )
2719 return {
2720 "success": True,
2721 "message": f"Applied template id={template_id} to case id={case_id}: {len(new_tasks)} task(s) added",
2722 "tasks_added": len(new_tasks),
2723 }
2724
2725
2726 # ============================================================================
2727 # Case Timeline (Phase 4, issue #792)
2728 #
2729 # Append-only audit log of every meaningful case mutation. Visible to admin,
2730 # analyst, and customer_user (read-only) — same scope as the case itself.
2731 # ============================================================================
2732
2733
2734 @incidents_db_operations_router.get(
2735 "/case/{case_id}/timeline",
2736 description=(
2737 "Return the case timeline (append-only audit log of mutations). "
2738 "Most-recent-first, paginated via limit/offset. Visible to admin, "
2739 "analyst, and customer_user (read-only)."
2740 ),
2741 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2742 )
2743 async def get_case_timeline_endpoint(
2744 case_id: int,
2745 limit: int = Query(500, ge=1, le=2000, description="Max events to return"),
2746 offset: int = Query(0, ge=0, description="Skip this many events from the top"),
2747 current_user: User = Depends(AuthHandler().get_current_user),
2748 db: AsyncSession = Depends(get_db),
2749 ):
2750 from app.incidents.services.case_events import list_case_events
2751
2752 await _ensure_case_access(case_id, current_user, db)
2753 return await list_case_events(case_id, db, limit=limit, offset=offset)