@cryptotaxi247 / CoPilot / commits / c18cf359

feat: Implement Case Template Library functionality (#880)

* feat: Implement Case Template Library functionality - Added endpoints for listing, refreshing, and importing case template library entries. - Introduced new service layer for fetching and caching YAML-defined playbooks from GitHub. - Created new Pydantic models for library entries and responses. - Developed frontend components for displaying and importing library entries, including modals and lists. - Enhanced existing case template management with library integration, ensuring seamless user experience for admins and analysts. * feat: Add subproject worktree for dreamy-elgamal * fix(template_library): remove GitHub token authorization from headers * precommit-fixes * fix(template_library): remove GitHub token authorization from headers * precommit-fixes * feat(case-tasks): add nullable alert_id FK to CaseTask Lets tasks be grouped under their originating alert within a case so multi-alert investigations can show per-alert task batches. NULL = case-wide general task. The column is indexed since the UI will group/filter by it. On alert-unlink the row is orphaned (alert_id set NULL) so task history survives — same snapshot-preserving spirit as template_task_id. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(alert-id): add alert_id column and foreign key to CaseTask * feat(case-tasks): per-alert auto-apply + orphan-on-unlink Case tasks now associate with the alert that triggered them so multi-alert investigations can show per-alert task batches. Replaces the first-alert-wins auto-apply rule from #827 with per-alert: each alert linked to a case (single or bulk) runs template matching against its own source and stamps the materialized CaseTask rows with that alert's id. Unlinking an alert orphans its tasks (alert_id set NULL, rows preserved) rather than deleting them, so analyst evidence on those tasks survives. CaseTasksList groups the tab by alert with a case-wide / general bucket for orphans + nulls; the custom-task form gains an optional alert picker. Manual apply-template endpoint accepts ?alert_id= for analyst-driven per-alert applies. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(incidents): clean case_task + case_event on case delete; orphan tasks on alert delete PR #827 added the case_task and case_event tables but didn't wire their case_id FKs into delete_case, so deleting a case that had ever recorded a timeline event (i.e. all of them) failed with an IntegrityError. delete_case now removes those rows before the case itself. delete_alert had a parallel issue introduced by this branch's new case_task.alert_id FK — deleting an alert that had stamped tasks would fail. Matches the orphan-on-unlink design: alert_id is nulled (tasks survive as case-wide) rather than deleted, so analyst evidence isn't lost. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(case-templates): add match_field / match_value columns for conditional auto-apply CaseTemplate gains two nullable columns describing an optional equality match against the originating Wazuh document. When both are set, auto-apply fetches the raw event from the asset's (index_name, index_id) and only applies the template when document[match_field] == match_value. Lets templates target specific event types (e.g., Sysmon EID 1) rather than the whole source. Both null = unconditional template, behaves exactly as before under the existing customer/source tier picker. SQLModel only; service-layer wiring and YAML library support follow in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(case-templates): add match_field and match_value columns to incident_management_case_template * feat(case-templates): conditional auto-apply via match_field / match_value Templates can now declare a single equality condition on a flat top-level field of the originating Wazuh document (e.g., data_win_system_eventID == 1). The new pick_templates_for_alert two-stage picker evaluates field-match templates by fetching the raw event once via the alert's first asset's (index_name, index_id); matches layer additively. Zero matches or a fetch failure falls back to the existing customer/source tier picker so the auto-apply path stays resilient. Library YAML gains an optional match: { field, value } block parsed in _normalize_entry and persisted at import. Template editor surfaces a "Conditional auto-apply" section with a both-or-neither guard; library cards and the import preview show the condition as a chip / info cell. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(case-templates): exclude conditional templates from legacy tier fallback pick_template_for_case (the customer/source tier picker used as the fallback when no field-match templates fire) was selecting templates with a match block, letting a conditional template silently apply to alerts that didn't match its condition. Concrete repro: a Sysmon EID 1 template (match: eventID == "1") would correctly reject an EID 3 alert in the field-match stage, then be re-picked by the fallback's source=wazuh tier and apply anyway. A template with a match_field is saying "fire only when this matches" — it must not participate in the unconditional fallback. Adds match_field IS NULL to every tier query. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * precommit-fixes --------- Co-authored-by: taylorwalton <taylor.walton@socfortress.co> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Amine Moussa committed May 18, 2026 at 21:54 UTC c18cf3595a8c5b9a64d4298fad8c86a5cd8efffa
23 files changed +1933 -92
backend/alembic/versions/9c815bd72392_add_alert_id_column_for_tasks.py new
+35
@@ -0,0 +1,35 @@
1 +"""Add alert_id column for tasks
2 +
3 +Revision ID: 9c815bd72392
4 +Revises: 0912fd37e41c
5 +Create Date: 2026-05-18 12:05:21.941709
6 +
7 +"""
8 +from typing import Sequence
9 +from typing import Union
10 +
11 +import sqlalchemy as sa
12 +
13 +from alembic import op
14 +
15 +# revision identifiers, used by Alembic.
16 +revision: str = "9c815bd72392"
17 +down_revision: Union[str, None] = "0912fd37e41c"
18 +branch_labels: Union[str, Sequence[str], None] = None
19 +depends_on: Union[str, Sequence[str], None] = None
20 +
21 +
22 +def upgrade() -> None:
23 + # ### commands auto generated by Alembic - please adjust! ###
24 + op.add_column("incident_management_case_task", sa.Column("alert_id", sa.Integer(), nullable=True))
25 + op.create_index(op.f("ix_incident_management_case_task_alert_id"), "incident_management_case_task", ["alert_id"], unique=False)
26 + op.create_foreign_key(None, "incident_management_case_task", "incident_management_alert", ["alert_id"], ["id"])
27 + # ### end Alembic commands ###
28 +
29 +
30 +def downgrade() -> None:
31 + # ### commands auto generated by Alembic - please adjust! ###
32 + op.drop_constraint(None, "incident_management_case_task", type_="foreignkey")
33 + op.drop_index(op.f("ix_incident_management_case_task_alert_id"), table_name="incident_management_case_task")
34 + op.drop_column("incident_management_case_task", "alert_id")
35 + # ### end Alembic commands ###
backend/alembic/versions/a1960bcb4526_add_match_field_and_match_value_column_.py new
+33
@@ -0,0 +1,33 @@
1 +"""Add match_field and match_value column for tasks
2 +
3 +Revision ID: a1960bcb4526
4 +Revises: 9c815bd72392
5 +Create Date: 2026-05-18 14:51:11.405635
6 +
7 +"""
8 +from typing import Sequence
9 +from typing import Union
10 +
11 +import sqlalchemy as sa
12 +
13 +from alembic import op
14 +
15 +# revision identifiers, used by Alembic.
16 +revision: str = "a1960bcb4526"
17 +down_revision: Union[str, None] = "9c815bd72392"
18 +branch_labels: Union[str, Sequence[str], None] = None
19 +depends_on: Union[str, Sequence[str], None] = None
20 +
21 +
22 +def upgrade() -> None:
23 + # ### commands auto generated by Alembic - please adjust! ###
24 + op.add_column("incident_management_case_template", sa.Column("match_field", sa.String(length=255), nullable=True))
25 + op.add_column("incident_management_case_template", sa.Column("match_value", sa.Text(), nullable=True))
26 + # ### end Alembic commands ###
27 +
28 +
29 +def downgrade() -> None:
30 + # ### commands auto generated by Alembic - please adjust! ###
31 + op.drop_column("incident_management_case_template", "match_value")
32 + op.drop_column("incident_management_case_template", "match_field")
33 + # ### end Alembic commands ###
backend/app/incidents/models.py
+31
@@ -317,6 +317,27 @@ class CaseTemplate(SQLModel, table=True):
317 nullable=False,
318 description="Default template for its (customer_code, source) scope. Used as the final fallback in selection.",
319 )
320 + match_field: Optional[str] = Field(
321 + default=None,
322 + max_length=255,
323 + nullable=True,
324 + description=(
325 + "Optional conditional auto-apply: name of a flat top-level field on the originating Wazuh "
326 + "document (e.g., 'data_win_system_eventID'). When both match_field and match_value are set, "
327 + "auto-apply fetches the raw event via the asset's (index_name, index_id) and applies this "
328 + "template only when document[match_field] == match_value. Both null = unconditional template "
329 + "(legacy customer/source tier picker)."
330 + ),
331 + )
332 + match_value: Optional[str] = Field(
333 + default=None,
334 + sa_column=Column(Text, nullable=True),
335 + description=(
336 + "Optional conditional auto-apply: the string value compared (equality) against the raw "
337 + "document field. Stored as text since Wazuh field values are heterogeneous; numeric fields "
338 + "like eventID arrive as quoted strings already ('1' not 1)."
339 + ),
340 + )
341 created_by: str = Field(max_length=100, nullable=False, description="User who created this template")
342 created_at: datetime = Field(default_factory=datetime.utcnow)
343 updated_at: datetime = Field(default_factory=datetime.utcnow)
@@ -362,6 +383,16 @@ class CaseTask(SQLModel, table=True):
383
384 id: Optional[int] = Field(default=None, primary_key=True)
385 case_id: int = Field(foreign_key="incident_management_case.id", nullable=False)
386 + alert_id: Optional[int] = Field(
387 + default=None,
388 + foreign_key="incident_management_alert.id",
389 + nullable=True,
390 + index=True,
391 + description=(
392 + "Originating alert this task batch was materialized for. NULL = case-wide / general task. "
393 + "Set to NULL ('orphaned') when the alert is unlinked from the case so task history survives."
394 + ),
395 + )
396 template_task_id: Optional[int] = Field(
397 default=None,
398 foreign_key="incident_management_case_template_task.id",
backend/app/incidents/routes/case_templates.py
+150
@@ -12,13 +12,20 @@ from typing import Optional
12
13 from fastapi import APIRouter
14 from fastapi import Depends
15 +from fastapi import HTTPException
16 from fastapi import Query
17 from fastapi import Security
18 +from sqlalchemy import select
19 from sqlalchemy.ext.asyncio import AsyncSession
20
21 from app.auth.utils import AuthHandler
22 from app.db.db_session import get_db
23 +from app.incidents.models import CaseTemplate
24 from app.incidents.schema.case_templates import CaseTemplateCreate
25 +from app.incidents.schema.case_templates import CaseTemplateLibraryEntry
26 +from app.incidents.schema.case_templates import CaseTemplateLibraryListResponse
27 +from app.incidents.schema.case_templates import CaseTemplateLibraryRefreshResponse
28 +from app.incidents.schema.case_templates import CaseTemplateLibraryTask
29 from app.incidents.schema.case_templates import CaseTemplateListResponse
30 from app.incidents.schema.case_templates import CaseTemplateOperationResponse
31 from app.incidents.schema.case_templates import CaseTemplateTaskCreate
@@ -26,6 +33,7 @@ from app.incidents.schema.case_templates import CaseTemplateTaskOperationRespons
33 from app.incidents.schema.case_templates import CaseTemplateTaskUpdate
34 from app.incidents.schema.case_templates import CaseTemplateUpdate
35 from app.incidents.services import case_templates as service
36 +from app.incidents.services import template_library
37
38 # Scope guard applied to every route on this router. Returns the username,
39 # which we use as the audit actor for create operations.
@@ -82,6 +90,148 @@ async def create_case_template(
90 return await service.create_template(request=request, actor=actor, session=db)
91
92
93 +# ---------------------------------------------------------------------------
94 +# Case Template Library — read-only catalog of playbooks pulled from
95 +# https://github.com/socfortress/CoPilot-Case-Templates. The Library tab in
96 +# the admin UI calls these endpoints. Importing an entry creates a normal
97 +# CaseTemplate row via the existing ``create_template`` service.
98 +#
99 +# IMPORTANT: these MUST be declared before the ``/{template_id}`` route below.
100 +# FastAPI matches routes in registration order; if ``/{template_id}`` is first
101 +# it would swallow ``/library`` and try to coerce "library" to int, returning
102 +# HTTP 422 "Input is not a valid integer."
103 +# ---------------------------------------------------------------------------
104 +
105 +
106 +def _library_entry_to_response(entry: dict) -> CaseTemplateLibraryEntry:
107 + """Convert a parsed-and-normalised library entry dict into its API shape."""
108 + return CaseTemplateLibraryEntry(
109 + key=entry["key"],
110 + name=entry["name"],
111 + description=entry.get("description"),
112 + source=entry.get("source"),
113 + match_field=entry.get("match_field"),
114 + match_value=entry.get("match_value"),
115 + tags=entry.get("tags", {}),
116 + tasks=[CaseTemplateLibraryTask(**t) for t in entry.get("tasks", [])],
117 + file_path=entry.get("_file_path"),
118 + )
119 +
120 +
121 +@case_templates_router.get(
122 + "/library",
123 + response_model=CaseTemplateLibraryListResponse,
124 + description=(
125 + "List investigation-playbook entries available in the Case-Templates "
126 + "library repo on GitHub. Read-only; nothing is persisted until an "
127 + "admin clicks Import."
128 + ),
129 +)
130 +async def list_library_entries_endpoint() -> CaseTemplateLibraryListResponse:
131 + try:
132 + entries = await template_library.list_library_entries()
133 + return CaseTemplateLibraryListResponse(
134 + entries=[_library_entry_to_response(e) for e in entries],
135 + invalid_paths=template_library.template_library_cache.invalid_paths,
136 + last_refresh=template_library.template_library_cache.last_refresh,
137 + success=True,
138 + message=f"Retrieved {len(entries)} library entr(ies)",
139 + )
140 + except Exception as e:
141 + return CaseTemplateLibraryListResponse(
142 + entries=[],
143 + invalid_paths=[],
144 + last_refresh=template_library.template_library_cache.last_refresh,
145 + success=False,
146 + message=f"Failed to load case-template library: {e}",
147 + )
148 +
149 +
150 +@case_templates_router.post(
151 + "/library/refresh",
152 + response_model=CaseTemplateLibraryRefreshResponse,
153 + description="Force a re-fetch of the Case-Templates library repo (bypasses the 30-minute cache).",
154 +)
155 +async def refresh_library_endpoint() -> CaseTemplateLibraryRefreshResponse:
156 + try:
157 + result = await template_library.refresh_library()
158 + return CaseTemplateLibraryRefreshResponse(
159 + loaded=result["loaded"],
160 + invalid_paths=result["invalid_paths"],
161 + last_refresh=result["last_refresh"],
162 + success=True,
163 + message=f"Library refreshed: {result['loaded']} entr(ies) loaded, {len(result['invalid_paths'])} skipped",
164 + )
165 + except Exception as e:
166 + return CaseTemplateLibraryRefreshResponse(
167 + loaded=0,
168 + invalid_paths=[],
169 + last_refresh=template_library.template_library_cache.last_refresh,
170 + success=False,
171 + message=f"Failed to refresh case-template library: {e}",
172 + )
173 +
174 +
175 +@case_templates_router.post(
176 + "/library/{key}/import",
177 + response_model=CaseTemplateOperationResponse,
178 + description=(
179 + "Import a library entry as a new CaseTemplate row. Imports as a "
180 + "**global** template (no customer_code, no source-scope) by default. "
181 + "If a CaseTemplate already exists with the same name, returns HTTP 409 "
182 + "— admins should rename or delete the existing one before re-importing."
183 + ),
184 +)
185 +async def import_library_entry_endpoint(
186 + key: str,
187 + db: AsyncSession = Depends(get_db),
188 + actor: str = Security(_require_admin_or_analyst),
189 +) -> CaseTemplateOperationResponse:
190 + entry = await template_library.get_library_entry(key)
191 + if entry is None:
192 + raise HTTPException(
193 + status_code=404,
194 + detail=f"Library entry '{key}' not found. Try POST /library/refresh if you just pushed it.",
195 + )
196 +
197 + existing = await db.execute(select(CaseTemplate).where(CaseTemplate.name == entry["name"]))
198 + if existing.scalars().first() is not None:
199 + raise HTTPException(
200 + status_code=409,
201 + detail=(
202 + f"A case template named '{entry['name']}' already exists. "
203 + "Rename or delete the existing template before re-importing this entry."
204 + ),
205 + )
206 +
207 + payload = CaseTemplateCreate(
208 + name=entry["name"],
209 + description=entry.get("description"),
210 + customer_code=None,
211 + source=entry.get("source"),
212 + is_default=False,
213 + match_field=entry.get("match_field"),
214 + match_value=entry.get("match_value"),
215 + tasks=[
216 + CaseTemplateTaskCreate(
217 + title=t["title"],
218 + description=t.get("description"),
219 + guidelines=t.get("guidelines"),
220 + mandatory=t.get("mandatory", False),
221 + order_index=t["order_index"],
222 + )
223 + for t in entry.get("tasks", [])
224 + ],
225 + )
226 + return await service.create_template(request=payload, actor=actor, session=db)
227 +
228 +
229 +# ---------------------------------------------------------------------------
230 +# Wildcard /{template_id} routes — must be declared AFTER the static
231 +# /library routes above for the same reason FastAPI route ordering matters.
232 +# ---------------------------------------------------------------------------
233 +
234 +
235 @case_templates_router.get(
236 "/{template_id}",
237 response_model=CaseTemplateOperationResponse,
backend/app/incidents/routes/db_operations.py
+38 -7
@@ -983,7 +983,7 @@ async def create_case_alert_link_endpoint(
983 current_user: User = Depends(AuthHandler().get_current_user),
984 db: AsyncSession = Depends(get_db),
985 ):
986 - link = await create_case_alert_link(case_alert_link, db)
986 + link = await create_case_alert_link(case_alert_link, db, actor=current_user.username)
987
988 from app.incidents.schema.case_templates import CaseEventType
989 from app.incidents.services.case_events import emit_case_event
@@ -1015,7 +1015,7 @@ async def create_case_alert_links_endpoint(
1015 current_user: User = Depends(AuthHandler().get_current_user),
1016 db: AsyncSession = Depends(get_db),
1017 ):
1018 - links = await create_case_alert_links_bulk(case_alert_links, db)
1018 + links = await create_case_alert_links_bulk(case_alert_links, db, actor=current_user.username)
1019
1020 from app.incidents.schema.case_templates import CaseEventType
1021 from app.incidents.services.case_events import emit_case_event
@@ -1060,7 +1060,10 @@ async def case_alert_unlink_endpoint(
1060 case_id=case_alert_link.case_id,
1061 event_type=CaseEventType.ALERT_UNLINKED,
1062 actor=current_user.username,
1063 - payload=payload_alert_link(alert_id=case_alert_link.alert_id),
1063 + payload=payload_alert_link(
1064 + alert_id=case_alert_link.alert_id,
1065 + tasks_orphaned=response.tasks_orphaned,
1066 + ),
1067 commit=True,
1068 )
1069
@@ -1078,8 +1081,10 @@ async def create_case_from_alert_endpoint(
1081 None,
1082 description=(
1083 "Optional CaseTemplate id to apply on creation. When omitted, the best matching "
1081 - "template is auto-selected from the alert's (customer_code, source). First-alert-wins "
1082 - "semantics: subsequent alerts linked to the case do not retrigger template selection."
1084 + "template is auto-selected from the alert's (customer_code, source). The materialized "
1085 + "tasks are stamped with the originating alert id so the Tasks UI can group them under "
1086 + "that alert. Subsequent alerts linked to the case retrigger per-alert auto-apply against "
1087 + "their own source — each linked alert gets its own task batch."
1088 ),
1089 ),
1090 current_user: User = Depends(AuthHandler().get_current_user),
@@ -1094,7 +1099,15 @@ async def create_case_from_alert_endpoint(
1099 if case is None:
1100 return CaseResponse(case=None, success=False, message="Case not created")
1101
1097 - link = await create_case_alert_link(CaseAlertLinkCreate(case_id=case.id, alert_id=alert_id.alert_id), db)
1102 + # ``create_case_from_alert`` above already auto-applied the template
1103 + # against the originating alert and stamped its id on the tasks. Skip
1104 + # the per-alert auto-apply on this link so we don't double-create tasks.
1105 + link = await create_case_alert_link(
1106 + CaseAlertLinkCreate(case_id=case.id, alert_id=alert_id.alert_id),
1107 + db,
1108 + actor=current_user.username,
1109 + auto_apply_template=False,
1110 + )
1111
1112 # Phase 4 audit emits: case_created + alert_linked (the originating
1113 # alert is the first link). template_applied / task_added events are
@@ -2654,24 +2667,42 @@ async def delete_case_task_endpoint(
2667 description=(
2668 "Manually apply a CaseTemplate to an existing case (snapshot-copies its tasks). "
2669 "Adds to existing tasks rather than replacing them — the analyst can apply multiple "
2657 - "templates over the life of an investigation. Admin/analyst only."
2670 + "templates over the life of an investigation. When ``alert_id`` is given, the "
2671 + "materialized tasks are stamped with that alert id so they group under that alert "
2672 + "in the Tasks UI; the alert must already be linked to the case. Admin/analyst only."
2673 ),
2674 dependencies=[_admin_analyst_dep],
2675 )
2676 async def apply_template_to_case_endpoint(
2677 case_id: int,
2678 template_id: int,
2679 + alert_id: Optional[int] = Query(
2680 + None,
2681 + description=(
2682 + "Optional linked alert id to scope the materialized tasks to. Tasks without an "
2683 + "alert_id appear in the case-wide / general group."
2684 + ),
2685 + ),
2686 current_user: User = Depends(AuthHandler().get_current_user),
2687 db: AsyncSession = Depends(get_db),
2688 ):
2689 from app.incidents.services.case_tasks import apply_template_to_case
2690 + from app.incidents.services.case_tasks import is_alert_linked_to_case
2691
2692 await _ensure_case_access(case_id, current_user, db)
2693 + if alert_id is not None and not await is_alert_linked_to_case(case_id, alert_id, db):
2694 + raise HTTPException(
2695 + status_code=400,
2696 + detail=(
2697 + 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."
2698 + ),
2699 + )
2700 new_tasks = await apply_template_to_case(
2701 case_id=case_id,
2702 template_id=template_id,
2703 actor=current_user.username,
2704 session=db,
2705 + alert_id=alert_id,
2706 commit=True,
2707 )
2708 return {
backend/app/incidents/schema/case_templates.py
+94
@@ -110,6 +110,19 @@ class CaseTemplateCreate(BaseModel):
110 False,
111 description="If true, this template is the fallback within its (customer_code, source) scope.",
112 )
113 + match_field: Optional[str] = Field(
114 + None,
115 + max_length=255,
116 + description=(
117 + "Optional conditional auto-apply field name on the originating Wazuh document "
118 + "(e.g., 'data_win_system_eventID'). Both match_field and match_value must be set "
119 + "together — providing one without the other is rejected on create/update."
120 + ),
121 + )
122 + match_value: Optional[str] = Field(
123 + None,
124 + description="Equality value compared against document[match_field] at auto-apply time.",
125 + )
126 tasks: List[CaseTemplateTaskCreate] = Field(
127 default_factory=list,
128 description="Initial task list. More can be added later via the task endpoints.",
@@ -125,6 +138,10 @@ class CaseTemplateUpdate(BaseModel):
138 customer_code: Optional[str] = Field(None, max_length=50)
139 source: Optional[str] = Field(None, max_length=50)
140 is_default: Optional[bool] = None
141 + # Explicit None clears the field (when present in the request); omitted field
142 + # leaves the existing value alone. Handled in the service via __fields_set__.
143 + match_field: Optional[str] = Field(None, max_length=255)
144 + match_value: Optional[str] = None
145
146
147 class CaseTemplateResponse(BaseModel):
@@ -134,6 +151,8 @@ class CaseTemplateResponse(BaseModel):
151 customer_code: Optional[str] = None
152 source: Optional[str] = None
153 is_default: bool
154 + match_field: Optional[str] = None
155 + match_value: Optional[str] = None
156 created_by: str
157 created_at: datetime
158 updated_at: datetime
@@ -172,6 +191,13 @@ class CaseTaskCreate(BaseModel):
191 guidelines: Optional[str] = None
192 mandatory: bool = False
193 order_index: int = Field(0, ge=0)
194 + alert_id: Optional[int] = Field(
195 + None,
196 + description=(
197 + "Optional originating alert. When set, the alert must be linked to the case "
198 + "or the request is rejected. Omit (or pass null) to create a case-wide / general task."
199 + ),
200 + )
201
202
203 class CaseTaskUpdate(BaseModel):
@@ -199,6 +225,7 @@ class CaseTaskUpdate(BaseModel):
225 class CaseTaskResponse(BaseModel):
226 id: int
227 case_id: int
228 + alert_id: Optional[int] = None
229 template_task_id: Optional[int] = None
230 title: str
231 description: Optional[str] = None
@@ -268,3 +295,70 @@ class CaseTimelineResponse(BaseModel):
295 events: List[CaseEventResponse] = Field(default_factory=list)
296 success: bool
297 message: str
298 +
299 +
300 +# ---------------------------------------------------------------------------
301 +# Case Template Library — read-only catalog of playbooks pulled from
302 +# https://github.com/socfortress/CoPilot-Case-Templates.
303 +#
304 +# These models mirror the YAML schema documented in that repo's ``SCHEMA.md``.
305 +# A LibraryEntry is *not* a CaseTemplate row — it's the YAML view of a
306 +# playbook. Admins import an entry via ``POST /library/{key}/import`` which
307 +# in turn calls the existing ``create_template`` service, materialising a
308 +# normal CaseTemplate (+ task) row set.
309 +# ---------------------------------------------------------------------------
310 +
311 +
312 +class CaseTemplateLibraryTask(BaseModel):
313 + """One task in a library entry. Mirrors ``CaseTemplateTaskCreate`` plus
314 + explicit ``order_index`` (always present after the loader normalises)."""
315 +
316 + title: str = Field(..., max_length=500)
317 + description: Optional[str] = None
318 + guidelines: Optional[str] = None
319 + mandatory: bool = False
320 + order_index: int = Field(..., ge=0)
321 +
322 +
323 +class CaseTemplateLibraryEntry(BaseModel):
324 + """A single playbook surfaced from the Library repo. Display-only on its
325 + own; becomes a CaseTemplate row when imported."""
326 +
327 + key: str = Field(..., description="Stable identifier from the YAML; used for collision detection on import")
328 + name: str = Field(..., max_length=255)
329 + description: Optional[str] = None
330 + source: Optional[str] = Field(None, max_length=50)
331 + match_field: Optional[str] = Field(
332 + None,
333 + max_length=255,
334 + description="Optional conditional auto-apply field (from the YAML ``match.field``). Persisted on import.",
335 + )
336 + match_value: Optional[str] = Field(
337 + None,
338 + description="Optional conditional auto-apply value (from the YAML ``match.value``). Persisted on import.",
339 + )
340 + tags: Dict[str, Any] = Field(default_factory=dict, description="Library-only metadata; not persisted into the DB")
341 + tasks: List[CaseTemplateLibraryTask] = Field(default_factory=list)
342 + file_path: Optional[str] = Field(None, description="Path of the source YAML within the repo (for display only)")
343 +
344 +
345 +class CaseTemplateLibraryListResponse(BaseModel):
346 + entries: List[CaseTemplateLibraryEntry] = Field(default_factory=list)
347 + invalid_paths: List[str] = Field(
348 + default_factory=list,
349 + description="Repo paths that failed validation during the last refresh; surfaced so admins can fix upstream YAML.",
350 + )
351 + last_refresh: Optional[datetime] = Field(
352 + None,
353 + description="When the cache was last refreshed; null if the cache hasn't loaded yet.",
354 + )
355 + success: bool
356 + message: str
357 +
358 +
359 +class CaseTemplateLibraryRefreshResponse(BaseModel):
360 + loaded: int
361 + invalid_paths: List[str] = Field(default_factory=list)
362 + last_refresh: Optional[datetime] = None
363 + success: bool
364 + message: str
backend/app/incidents/schema/db_operations.py
+1
@@ -208,6 +208,7 @@ class CaseAlertLinkResponse(BaseModel):
208 class CaseAlertUnLinkResponse(BaseModel):
209 success: bool
210 message: str
211 + tasks_orphaned: int = 0
212
213
214 class CaseAlertLinksResponse(BaseModel):
backend/app/incidents/services/case_events.py
+7 -2
@@ -138,8 +138,13 @@ def payload_escalation(escalated: bool) -> Dict[str, Any]:
138 return {"escalated": escalated}
139
140
141 -def payload_alert_link(alert_id: int) -> Dict[str, Any]:
142 - return {"alert_id": alert_id}
141 +def payload_alert_link(alert_id: int, **extra: Any) -> Dict[str, Any]:
142 + """Used for both ``alert_linked`` and ``alert_unlinked``. The unlink path
143 + passes ``tasks_orphaned=<count>`` so the timeline can show how many
144 + tasks survived as case-wide tasks after the alert was detached."""
145 + out: Dict[str, Any] = {"alert_id": alert_id}
146 + out.update(extra)
147 + return out
148
149
150 def payload_alert_links_bulk(alert_ids: List[int]) -> Dict[str, Any]:
backend/app/incidents/services/case_tasks.py
+230 -33
@@ -15,16 +15,20 @@ Authorization is enforced at the route layer; this module is auth-agnostic.
15 """
16
17 from datetime import datetime
18 +from typing import Any
19 +from typing import Dict
20 from typing import List
21 from typing import Optional
22 from typing import Tuple
23
24 from loguru import logger
25 from sqlalchemy import select
26 +from sqlalchemy import update
27 from sqlalchemy.ext.asyncio import AsyncSession
28 from sqlalchemy.orm import selectinload
29
30 from app.incidents.models import Alert
31 +from app.incidents.models import Asset
32 from app.incidents.models import Case
33 from app.incidents.models import CaseAlertLink
34 from app.incidents.models import CaseTask
@@ -47,6 +51,7 @@ def _case_task_to_response(task: CaseTask) -> CaseTaskResponse:
51 return CaseTaskResponse(
52 id=task.id,
53 case_id=task.case_id,
54 + alert_id=task.alert_id,
55 template_task_id=task.template_task_id,
56 title=task.title,
57 description=task.description,
@@ -83,6 +88,12 @@ async def pick_template_for_case(
88 3. ``source`` only (customer_code IS NULL), prefer is_default
89 4. Global default (both NULL, is_default=True)
90
91 + **Conditional templates are excluded from every tier.** A template with a
92 + ``match_field`` set is saying "fire only when this condition matches" — it
93 + participates in selection exclusively via ``pick_templates_for_alert``. If
94 + its condition is rejected, it must not silently re-enter via the fallback
95 + tier; that would defeat the whole point of having a condition.
96 +
97 Returns the template with tasks eagerly loaded, or None if no match.
98 """
99
@@ -90,6 +101,7 @@ async def pick_template_for_case(
101 stmt = (
102 select(CaseTemplate)
103 .options(selectinload(CaseTemplate.tasks))
104 + .where(CaseTemplate.match_field.is_(None))
105 .where(customer_filter)
106 .where(source_filter)
107 .order_by(CaseTemplate.is_default.desc(), CaseTemplate.created_at.desc())
@@ -135,6 +147,119 @@ async def pick_template_for_case(
147 return None
148
149
150 +# ---------------------------------------------------------------------------
151 +# Conditional template selection (field-match against the raw Wazuh document)
152 +# ---------------------------------------------------------------------------
153 +
154 +
155 +async def _fetch_raw_event_for_alert(
156 + alert: Alert,
157 + session: AsyncSession,
158 +) -> Optional[Dict[str, Any]]:
159 + """
160 + Return the raw Wazuh document for this alert's first asset, or None if
161 + there is no asset or the OpenSearch fetch raises.
162 +
163 + Picks the lowest-id asset deterministically; the original-event coordinates
164 + are typically the same across an alert's assets, but if they diverge we
165 + prefer the asset that landed first. Talks to the Wazuh indexer directly
166 + rather than going through ``fetch_alert_details`` because we don't want
167 + its syslog_type validation gate — for field-match we only need a key/value
168 + bag.
169 + """
170 + asset_stmt = select(Asset).where(Asset.alert_linked == alert.id).order_by(Asset.id.asc()).limit(1)
171 + asset = (await session.execute(asset_stmt)).scalars().first()
172 + if asset is None:
173 + logger.info(
174 + f"pick_templates_for_alert: alert id={alert.id} has no asset row; " "field-match templates are skipped for this alert.",
175 + )
176 + return None
177 +
178 + try:
179 + from app.connectors.wazuh_indexer.utils.universal import (
180 + create_wazuh_indexer_client_async,
181 + )
182 +
183 + es_client = await create_wazuh_indexer_client_async("Wazuh-Indexer")
184 + doc = await es_client.get(index=asset.index_name, id=asset.index_id)
185 + return doc.get("_source") or {}
186 + except Exception as e:
187 + logger.warning(
188 + f"pick_templates_for_alert: raw-event fetch failed for alert id={alert.id} "
189 + f"(index={asset.index_name}, id={asset.index_id}): {e}. "
190 + "Falling back to non-field-match templates.",
191 + )
192 + return None
193 +
194 +
195 +async def pick_templates_for_alert(
196 + case: Case,
197 + alert: Alert,
198 + session: AsyncSession,
199 +) -> List[CaseTemplate]:
200 + """
201 + Return every CaseTemplate that should auto-apply for this (case, alert).
202 +
203 + Two-stage selection:
204 +
205 + 1. **Field-match templates** (both ``match_field`` and ``match_value``
206 + set) scoped to this case's customer + alert's source are evaluated
207 + against the raw Wazuh document fetched via the alert's first asset.
208 + Every template whose ``document[match_field] == match_value`` is
209 + included — they layer additively.
210 + 2. **Fallback.** If zero field-match templates fired (no candidates, no
211 + matches, or the raw-event fetch raised), use the existing single-
212 + template tier picker (``customer+source > customer > source > global
213 + default``) and return its result as a one-element list.
214 +
215 + This split is deliberate: a generic "wazuh global" template shouldn't
216 + drown a specific "sysmon event 1" one. Returns an empty list if nothing
217 + applies.
218 + """
219 + field_match_stmt = (
220 + select(CaseTemplate)
221 + .options(selectinload(CaseTemplate.tasks))
222 + .where(CaseTemplate.match_field.is_not(None))
223 + .where(CaseTemplate.match_value.is_not(None))
224 + .where(
225 + (CaseTemplate.customer_code == case.customer_code) | (CaseTemplate.customer_code.is_(None)),
226 + )
227 + .where(
228 + (CaseTemplate.source == alert.source) | (CaseTemplate.source.is_(None)),
229 + )
230 + )
231 + candidates = list((await session.execute(field_match_stmt)).scalars().all())
232 +
233 + matched: List[CaseTemplate] = []
234 + if candidates:
235 + raw_event = await _fetch_raw_event_for_alert(alert, session)
236 + if raw_event is not None:
237 + for tmpl in candidates:
238 + doc_value = raw_event.get(tmpl.match_field)
239 + if doc_value is None:
240 + continue
241 + # Wazuh top-level values arrive as strings already (numeric
242 + # fields like data_win_system_eventID are quoted: "1"). Coerce
243 + # defensively so a numeric value in the document still matches
244 + # the string in match_value.
245 + if str(doc_value) == tmpl.match_value:
246 + matched.append(tmpl)
247 +
248 + if matched:
249 + logger.info(
250 + f"pick_templates_for_alert: {len(matched)} field-match template(s) fired "
251 + f"for case id={case.id} alert id={alert.id}: {[t.id for t in matched]}",
252 + )
253 + return matched
254 +
255 + fallback = await pick_template_for_case(
256 + customer_code=case.customer_code,
257 + source=alert.source,
258 + session=session,
259 + )
260 + return [fallback] if fallback is not None else []
261 +
262 +
263 # ---------------------------------------------------------------------------
264 # Template application (snapshot copy)
265 # ---------------------------------------------------------------------------
@@ -146,6 +271,7 @@ async def apply_template_to_case(
271 actor: str,
272 session: AsyncSession,
273 *,
274 + alert_id: Optional[int] = None,
275 commit: bool = True,
276 ) -> List[CaseTask]:
277 """
@@ -156,6 +282,12 @@ async def apply_template_to_case(
282 mutate the CaseTask rows. ``template_task_id`` is preserved as a soft
283 link for analytics / future "this came from template X" UI hints.
284
285 + When ``alert_id`` is provided, every materialized CaseTask row is
286 + stamped with that alert id (so the Tasks UI can group tasks under
287 + their originating alert). The caller is responsible for ensuring the
288 + alert is linked to the case; this function trusts the caller. ``None``
289 + produces case-wide / general tasks.
290 +
291 Set ``commit=False`` when calling from inside another transaction
292 (e.g., immediately after a Case is created in the same flow); the
293 caller is then responsible for the commit.
@@ -178,6 +310,7 @@ async def apply_template_to_case(
310 for tmpl_task in sorted(template.tasks, key=lambda t: (t.order_index, t.id)):
311 case_task = CaseTask(
312 case_id=case_id,
313 + alert_id=alert_id,
314 template_task_id=tmpl_task.id,
315 title=tmpl_task.title,
316 description=tmpl_task.description,
@@ -203,16 +336,20 @@ async def apply_template_to_case(
336
337 await session.flush() # ensure case_task.id is populated for the audit payloads
338
339 + template_applied_payload = payload_template_applied(
340 + template_id=template.id,
341 + template_name=template.name,
342 + tasks_added=len(new_tasks),
343 + )
344 + if alert_id is not None:
345 + template_applied_payload["alert_id"] = alert_id
346 +
347 await emit_case_event(
348 session=session,
349 case_id=case_id,
350 event_type=CaseEventType.TEMPLATE_APPLIED,
351 actor=actor,
211 - payload=payload_template_applied(
212 - template_id=template.id,
213 - template_name=template.name,
214 - tasks_added=len(new_tasks),
215 - ),
352 + payload=template_applied_payload,
353 commit=False,
354 )
355 for ct in new_tasks:
@@ -227,6 +364,7 @@ async def apply_template_to_case(
364 mandatory=ct.mandatory,
365 source="template",
366 template_id=template.id,
367 + alert_id=alert_id,
368 ),
369 commit=False,
370 )
@@ -248,37 +386,35 @@ async def apply_template_to_case(
386
387 async def auto_apply_template_for_new_case(
388 case: Case,
389 + alert: Alert,
390 actor: str,
391 session: AsyncSession,
253 - *,
254 - source_hint: Optional[str] = None,
255 -) -> Optional[Tuple[CaseTemplate, List[CaseTask]]]:
256 - """
257 - Convenience wrapper used from ``create_case_from_alert`` and
258 - ``create_case``. Picks the best matching template using the case's
259 - customer_code and an optional ``source_hint`` (the originating
260 - alert's source — first-alert-wins per Phase 3 design decision).
261 -
262 - Returns (template, tasks) on success, or None if no template
263 - matched. ``commit=False`` so the caller's transaction stays in
264 - control; caller commits after this returns.
392 +) -> List[Tuple[CaseTemplate, List[CaseTask]]]:
393 """
266 - template = await pick_template_for_case(
267 - customer_code=case.customer_code,
268 - source=source_hint,
269 - session=session,
270 - )
271 - if template is None:
272 - return None
394 + Run the per-alert auto-apply selection for a case and apply every
395 + template the new ``pick_templates_for_alert`` returns.
396
274 - tasks = await apply_template_to_case(
275 - case_id=case.id,
276 - template_id=template.id,
277 - actor=actor,
278 - session=session,
279 - commit=False,
280 - )
281 - return template, tasks
397 + Field-match templates (when present and the raw event matches) layer
398 + additively; otherwise the legacy tier picker contributes one fallback
399 + template. All materialized CaseTask rows are stamped with ``alert.id``
400 + so they group under the originating alert in the UI.
401 +
402 + Returns the list of (template, tasks) pairs applied. ``commit=False`` on
403 + each apply call — the caller's transaction stays in control.
404 + """
405 + templates = await pick_templates_for_alert(case=case, alert=alert, session=session)
406 + applied: List[Tuple[CaseTemplate, List[CaseTask]]] = []
407 + for template in templates:
408 + tasks = await apply_template_to_case(
409 + case_id=case.id,
410 + template_id=template.id,
411 + actor=actor,
412 + session=session,
413 + alert_id=alert.id,
414 + commit=False,
415 + )
416 + applied.append((template, tasks))
417 + return applied
418
419
420 # ---------------------------------------------------------------------------
@@ -305,13 +441,29 @@ async def list_case_tasks(case_id: int, session: AsyncSession) -> CaseTaskListRe
441 )
442
443
444 +async def is_alert_linked_to_case(
445 + case_id: int,
446 + alert_id: int,
447 + session: AsyncSession,
448 +) -> bool:
449 + """True iff a CaseAlertLink row exists for this (case, alert) pair."""
450 + stmt = select(CaseAlertLink.alert_id).where(CaseAlertLink.case_id == case_id).where(CaseAlertLink.alert_id == alert_id).limit(1)
451 + result = await session.execute(stmt)
452 + return result.scalar_one_or_none() is not None
453 +
454 +
455 async def add_case_task(
456 case_id: int,
457 request: CaseTaskCreate,
458 actor: str,
459 session: AsyncSession,
460 ) -> CaseTaskOperationResponse:
314 - """Add a custom task to a case mid-investigation. template_task_id is NULL."""
461 + """Add a custom task to a case mid-investigation. template_task_id is NULL.
462 +
463 + When ``request.alert_id`` is provided, validates the alert is currently
464 + linked to the case — analysts shouldn't be able to attach tasks to alerts
465 + that aren't part of the case. Otherwise the task is created as case-wide.
466 + """
467 try:
468 case_result = await session.execute(select(Case).where(Case.id == case_id))
469 if case_result.scalar_one_or_none() is None:
@@ -321,8 +473,20 @@ async def add_case_task(
473 message=f"Case id={case_id} not found",
474 )
475
476 + if request.alert_id is not None:
477 + if not await is_alert_linked_to_case(case_id, request.alert_id, session):
478 + return CaseTaskOperationResponse(
479 + task=None,
480 + success=False,
481 + message=(
482 + f"Alert id={request.alert_id} is not linked to case id={case_id}; "
483 + "link the alert first or omit alert_id for a case-wide task."
484 + ),
485 + )
486 +
487 task = CaseTask(
488 case_id=case_id,
489 + alert_id=request.alert_id,
490 template_task_id=None,
491 title=request.title,
492 description=request.description,
@@ -348,6 +512,7 @@ async def add_case_task(
512 title=task.title,
513 mandatory=task.mandatory,
514 source="custom",
515 + alert_id=task.alert_id,
516 ),
517 commit=False,
518 )
@@ -557,6 +722,38 @@ def build_close_warning_response(incomplete: List[CaseTask]) -> CaseCloseWarning
722 # ---------------------------------------------------------------------------
723
724
725 +async def orphan_tasks_for_alert(
726 + case_id: int,
727 + alert_id: int,
728 + session: AsyncSession,
729 + *,
730 + commit: bool = True,
731 +) -> int:
732 + """
733 + Set ``alert_id = NULL`` on every CaseTask in this case that was attached
734 + to the unlinked alert. Tasks survive (snapshot-preserving semantics) and
735 + become case-wide / general tasks.
736 +
737 + Returns the number of rows affected so the caller can include it in the
738 + timeline payload for the unlink event.
739 + """
740 + stmt = (
741 + update(CaseTask)
742 + .where(CaseTask.case_id == case_id)
743 + .where(CaseTask.alert_id == alert_id)
744 + .values(alert_id=None, updated_at=datetime.utcnow())
745 + )
746 + result = await session.execute(stmt)
747 + affected = result.rowcount or 0
748 + if commit:
749 + await session.commit()
750 + if affected:
751 + logger.info(
752 + f"Orphaned {affected} task(s) on case id={case_id} when alert id={alert_id} was unlinked",
753 + )
754 + return affected
755 +
756 +
757 async def get_first_alert_source_for_case(
758 case_id: int,
759 session: AsyncSession,
backend/app/incidents/services/case_templates.py
+27
@@ -61,6 +61,8 @@ def _template_to_response(template: CaseTemplate) -> CaseTemplateResponse:
61 customer_code=template.customer_code,
62 source=template.source,
63 is_default=template.is_default,
64 + match_field=template.match_field,
65 + match_value=template.match_value,
66 created_by=template.created_by,
67 created_at=template.created_at,
68 updated_at=template.updated_at,
@@ -68,6 +70,16 @@ def _template_to_response(template: CaseTemplate) -> CaseTemplateResponse:
70 )
71
72
73 +def _validate_match_pair(match_field: Optional[str], match_value: Optional[str]) -> None:
74 + """Both-or-neither rule: a half-set match would silently never trigger,
75 + which is a foot-gun. Reject the request rather than persist an inert state."""
76 + if (match_field is None) != (match_value is None):
77 + raise ValueError(
78 + "match_field and match_value must both be set or both be null. "
79 + "Set both to enable conditional auto-apply, or both to null for an unconditional template.",
80 + )
81 +
82 +
83 async def _load_template_with_tasks(
84 template_id: int,
85 session: AsyncSession,
@@ -118,6 +130,8 @@ async def create_template(
130 logger.info(f"Creating case template '{request.name}' by {actor}")
131
132 try:
133 + _validate_match_pair(request.match_field, request.match_value)
134 +
135 if request.is_default:
136 await _enforce_single_default(
137 customer_code=request.customer_code,
@@ -132,6 +146,8 @@ async def create_template(
146 customer_code=request.customer_code,
147 source=request.source,
148 is_default=request.is_default,
149 + match_field=request.match_field,
150 + match_value=request.match_value,
151 created_by=actor,
152 )
153 session.add(template)
@@ -279,6 +295,17 @@ async def update_template(
295 session=session,
296 )
297
298 + # Match-pair edits: a partial update can touch one, both, or neither
299 + # field. Compute the post-update state and validate the pair before
300 + # writing — protects against ending up with field-without-value or
301 + # value-without-field.
302 + if "match_field" in fields_set or "match_value" in fields_set:
303 + new_field = request.match_field if "match_field" in fields_set else template.match_field
304 + new_value = request.match_value if "match_value" in fields_set else template.match_value
305 + _validate_match_pair(new_field, new_value)
306 + template.match_field = new_field
307 + template.match_value = new_value
308 +
309 template.updated_at = datetime.utcnow()
310 session.add(template)
311 await session.commit()
backend/app/incidents/services/db_operations.py
+130 -12
@@ -16,6 +16,7 @@ from sqlalchemy import delete
16 from sqlalchemy import desc
17 from sqlalchemy import distinct
18 from sqlalchemy import func
19 +from sqlalchemy import update
20 from sqlalchemy.exc import IntegrityError
21 from sqlalchemy.ext.asyncio import AsyncSession
22 from sqlalchemy.future import select
@@ -42,7 +43,9 @@ from app.incidents.models import Case
43 from app.incidents.models import CaseAlertLink
44 from app.incidents.models import CaseComment
45 from app.incidents.models import CaseDataStore
46 +from app.incidents.models import CaseEvent
47 from app.incidents.models import CaseReportTemplateDataStore
48 +from app.incidents.models import CaseTask
49 from app.incidents.models import Comment
50 from app.incidents.models import CustomerCodeFieldName
51 from app.incidents.models import FieldName
@@ -1467,12 +1470,14 @@ async def create_case_from_alert(
1470 Create a Case from an Alert and (Phase 3, issue #792) auto-apply a
1471 matching CaseTemplate.
1472
1470 - Template selection (when ``template_id`` is not supplied):
1471 - first-alert-wins — pick by (alert.customer_code, alert.source)
1472 - with the priority order documented in
1473 - ``app.incidents.services.case_tasks.pick_template_for_case``.
1474 - If no template matches, no tasks are created and the case is
1475 - returned unchanged.
1473 + Template selection (when ``template_id`` is not supplied): pick by
1474 + ``(alert.customer_code, alert.source)`` using the priority order in
1475 + ``app.incidents.services.case_tasks.pick_template_for_case``. The
1476 + materialized tasks are stamped with the originating alert's id so
1477 + the Tasks UI can group them under that alert. Subsequent alerts
1478 + linked to this case retrigger per-alert auto-apply against their
1479 + own source. If no template matches, no tasks are created and the
1480 + case is returned unchanged.
1481
1482 ``actor`` is the username performing the action; used as
1483 ``CaseTask.created_by`` for snapshot rows. Defaults to "system" when
@@ -1498,6 +1503,8 @@ async def create_case_from_alert(
1503
1504 # Apply a case template (Phase 3, issue #792). Imported lazily to
1505 # avoid a circular import — case_tasks pulls from this module too.
1506 + # The originating alert id is stamped on the materialized tasks so
1507 + # the Tasks UI can group them under that alert.
1508 from app.incidents.services.case_tasks import apply_template_to_case
1509 from app.incidents.services.case_tasks import auto_apply_template_for_new_case
1510
@@ -1508,14 +1515,15 @@ async def create_case_from_alert(
1515 template_id=template_id,
1516 actor=actor_name,
1517 session=db,
1518 + alert_id=alert.id,
1519 commit=False,
1520 )
1521 else:
1522 await auto_apply_template_for_new_case(
1523 case=case,
1524 + alert=alert,
1525 actor=actor_name,
1526 session=db,
1518 - source_hint=alert.source,
1527 )
1528
1529 await db.commit()
@@ -1525,7 +1533,28 @@ async def create_case_from_alert(
1533 return case
1534
1535
1528 -async def create_case_alert_link(case_alert_link: CaseAlertLinkCreate, db: AsyncSession) -> CaseAlertLink:
1536 +async def create_case_alert_link(
1537 + case_alert_link: CaseAlertLinkCreate,
1538 + db: AsyncSession,
1539 + *,
1540 + actor: Optional[str] = None,
1541 + auto_apply_template: bool = True,
1542 +) -> CaseAlertLink:
1543 + """
1544 + Link an alert to a case.
1545 +
1546 + When ``auto_apply_template`` is True (the default for analyst-driven
1547 + linking), the per-alert auto-apply rule runs: a matching CaseTemplate
1548 + is picked against the case's customer_code and the linked alert's
1549 + source, and any materialized tasks are stamped with that alert's id
1550 + so the Tasks UI can group them under their originating alert.
1551 +
1552 + Pass ``auto_apply_template=False`` when the caller has already handled
1553 + template application for this alert (e.g., ``/case/from-alert`` runs
1554 + ``create_case_from_alert`` first, which does its own apply against the
1555 + originating alert — re-applying on the subsequent link would double the
1556 + tasks).
1557 + """
1558 # Check if the case exists
1559 result = await db.execute(select(Case).where(Case.id == case_alert_link.case_id))
1560 case = result.scalars().first()
@@ -1541,13 +1570,34 @@ async def create_case_alert_link(case_alert_link: CaseAlertLinkCreate, db: Async
1570 db_case_alert_link = CaseAlertLink(**case_alert_link.model_dump())
1571 db.add(db_case_alert_link)
1572 try:
1573 + await db.flush()
1574 + if auto_apply_template:
1575 + from app.incidents.services.case_tasks import (
1576 + auto_apply_template_for_new_case,
1577 + )
1578 +
1579 + await auto_apply_template_for_new_case(
1580 + case=case,
1581 + alert=alert,
1582 + actor=actor or "system",
1583 + session=db,
1584 + )
1585 await db.commit()
1586 except IntegrityError:
1587 + await db.rollback()
1588 raise HTTPException(status_code=400, detail="Case alert link already exists")
1589 return db_case_alert_link
1590
1591
1592 async def case_alert_unlink(case_alert_unlink: CaseAlertUnLink, db: AsyncSession) -> CaseAlertUnLinkResponse:
1593 + """
1594 + Unlink an alert from a case.
1595 +
1596 + Any CaseTask rows that were stamped with this alert id are *orphaned*
1597 + (alert_id set NULL) rather than deleted — analysts already accumulated
1598 + investigation evidence on them, and the snapshot-preserving spirit of
1599 + the case-templates feature says that history outlives the link.
1600 + """
1601 result = await db.execute(
1602 select(CaseAlertLink).where(
1603 (CaseAlertLink.case_id == case_alert_unlink.case_id) & (CaseAlertLink.alert_id == case_alert_unlink.alert_id),
@@ -1561,16 +1611,68 @@ async def case_alert_unlink(case_alert_unlink: CaseAlertUnLink, db: AsyncSession
1611 (CaseAlertLink.case_id == case_alert_unlink.case_id) & (CaseAlertLink.alert_id == case_alert_unlink.alert_id),
1612 ),
1613 )
1614 +
1615 + from app.incidents.services.case_tasks import orphan_tasks_for_alert
1616 +
1617 + orphaned = await orphan_tasks_for_alert(
1618 + case_id=case_alert_unlink.case_id,
1619 + alert_id=case_alert_unlink.alert_id,
1620 + session=db,
1621 + commit=False,
1622 + )
1623 await db.commit()
1565 - return CaseAlertUnLinkResponse(success=True, message="Case alert link deleted successfully")
1624 + return CaseAlertUnLinkResponse(
1625 + success=True,
1626 + message=(
1627 + f"Case alert link deleted successfully; {orphaned} task(s) orphaned" if orphaned else "Case alert link deleted successfully"
1628 + ),
1629 + tasks_orphaned=orphaned,
1630 + )
1631 +
1632
1633 +async def create_case_alert_links_bulk(
1634 + case_alert_links: CaseAlertLinksCreate,
1635 + db: AsyncSession,
1636 + *,
1637 + actor: Optional[str] = None,
1638 + auto_apply_template: bool = True,
1639 +) -> List[CaseAlertLink]:
1640 + """
1641 + Bulk-link multiple alerts to a single case.
1642
1568 -async def create_case_alert_links_bulk(case_alert_links: CaseAlertLinksCreate, db: AsyncSession) -> List[CaseAlertLink]:
1643 + Per-alert auto-apply runs once per linked alert (same matching rules as
1644 + the single-link path). 50 alerts of the same source → 50 task batches;
1645 + that's the model. Pass ``auto_apply_template=False`` to skip.
1646 + """
1647 db_case_alert_links = [CaseAlertLink(case_id=case_alert_links.case_id, alert_id=alert_id) for alert_id in case_alert_links.alert_ids]
1648 db.add_all(db_case_alert_links)
1649 try:
1650 + await db.flush()
1651 + if auto_apply_template and case_alert_links.alert_ids:
1652 + from app.incidents.services.case_tasks import (
1653 + auto_apply_template_for_new_case,
1654 + )
1655 +
1656 + case_result = await db.execute(select(Case).where(Case.id == case_alert_links.case_id))
1657 + case = case_result.scalars().first()
1658 + if case is None:
1659 + raise HTTPException(status_code=404, detail="Case not found")
1660 +
1661 + alerts_result = await db.execute(
1662 + select(Alert).where(Alert.id.in_(case_alert_links.alert_ids)),
1663 + )
1664 + alerts = alerts_result.scalars().all()
1665 + actor_name = actor or "system"
1666 + for alert in alerts:
1667 + await auto_apply_template_for_new_case(
1668 + case=case,
1669 + alert=alert,
1670 + actor=actor_name,
1671 + session=db,
1672 + )
1673 await db.commit()
1674 except IntegrityError:
1675 + await db.rollback()
1676 raise HTTPException(status_code=400, detail="Case alert links already exist")
1677 return db_case_alert_links
1678
@@ -2806,6 +2908,13 @@ async def delete_alert(alert_id: int, db: AsyncSession):
2908 await delete_iocs(alert_id, db)
2909 await db.execute(delete(ThresholdAlertMetadata).where(ThresholdAlertMetadata.alert_id == alert_id))
2910
2911 + # Orphan any case tasks that were stamped with this alert id (matches the
2912 + # alert-unlink behavior — tasks survive as case-wide so investigation
2913 + # evidence isn't lost when an alert is purged). Spans all cases.
2914 + await db.execute(
2915 + update(CaseTask).where(CaseTask.alert_id == alert_id).values(alert_id=None, updated_at=datetime.utcnow()),
2916 + )
2917 +
2918 await db.execute(delete(Alert).where(Alert.id == alert.id))
2919
2920 try:
@@ -2832,7 +2941,16 @@ async def delete_case(case_id: int, db: AsyncSession):
2941 logger.info(f"Deleting case alert links for case {case_id}")
2942 await db.execute(delete(CaseAlertLink).where(CaseAlertLink.case_id == case_id))
2943
2835 - # 3. Delete all data store files associated with the case
2944 + # 3. Delete case tasks and their audit-log events (both FK to Case).
2945 + # Tasks first to avoid leaving dangling task_id references in payloads,
2946 + # though the events table doesn't FK to case_task directly so order is
2947 + # only a logical preference, not a correctness requirement.
2948 + logger.info(f"Deleting case tasks for case {case_id}")
2949 + await db.execute(delete(CaseTask).where(CaseTask.case_id == case_id))
2950 + logger.info(f"Deleting case timeline events for case {case_id}")
2951 + await db.execute(delete(CaseEvent).where(CaseEvent.case_id == case_id))
2952 +
2953 + # 4. Delete all data store files associated with the case
2954 logger.info(f"Deleting data store files for case {case_id}")
2955 files = await list_files_by_case_id(case_id, db)
2956 for file in files:
@@ -2841,7 +2959,7 @@ async def delete_case(case_id: int, db: AsyncSession):
2959 except Exception as e:
2960 logger.warning(f"Failed to delete file {file.file_name} from case {case_id}: {e}")
2961
2844 - # 4. Finally delete the case itself
2962 + # 5. Finally delete the case itself
2963 logger.info(f"Deleting case {case_id}")
2964 await db.execute(delete(Case).where(Case.id == case_id))
2965
backend/app/incidents/services/template_library.py new
+331
@@ -0,0 +1,331 @@
1 +"""
2 +Service layer for the CoPilot Case-Template **Library** — a read-only catalog
3 +of investigation playbooks authored as YAML in
4 +https://github.com/socfortress/CoPilot-Case-Templates.
5 +
6 +The Library is *not* a second template-management system. It is a fetcher +
7 +parser + cache that surfaces YAML-defined playbooks to the admin UI so they
8 +can be imported into the existing ``CaseTemplate`` tables via
9 +``create_template``. Once imported, an entry becomes a normal DB row and is
10 +managed through the existing CRUD endpoints — edits in the UI never flow
11 +back to GitHub, and changes pushed to GitHub never retroactively update
12 +already-imported templates.
13 +
14 +Conventions mirrored from
15 +``app.integrations.copilot_searches.services.copilot_searches.RulesCache``:
16 +- In-process cache with a TTL (30 minutes by default).
17 +- ``asyncio.Lock`` around the refresh so concurrent requests don't fire
18 + duplicate fetches.
19 +- Best-effort YAML validation per file; one bad file does not fail the
20 + whole library.
21 +- ``GITHUB_TOKEN`` env var (if present) is sent as a Bearer header to dodge
22 + GitHub's 60/hr unauthenticated rate limit. Same env var the CoPilot
23 + Searches loader uses.
24 +
25 +Library YAML schema (see ``SCHEMA.md`` in the playbook repo):
26 +
27 + key: str (required, unique across the repo)
28 + name: str (required, <= 255 chars)
29 + description: str (optional)
30 + source: str (optional, <= 50 chars)
31 + match: (optional; both keys required when the block is present)
32 + field: str (e.g., "data_win_system_eventID")
33 + value: str (equality target; stored as-is)
34 + tags: dict (optional, library-only metadata, not persisted)
35 + tasks:
36 + - title: str (required, <= 500 chars)
37 + description: str (optional)
38 + guidelines: str (optional)
39 + mandatory: bool (optional, default False)
40 + order_index: int (required, >= 0)
41 +"""
42 +
43 +import asyncio
44 +from datetime import datetime
45 +from datetime import timedelta
46 +from typing import Any
47 +from typing import Dict
48 +from typing import List
49 +from typing import Optional
50 +
51 +import httpx
52 +import yaml
53 +from loguru import logger
54 +
55 +# Where the playbook YAMLs live. Owned by SOCFortress; PR-able by anyone with
56 +# repo access. CoPilot only ever reads from this repo — never writes.
57 +GITHUB_REPO = "socfortress/CoPilot-Case-Templates"
58 +GITHUB_BRANCH = "main"
59 +GITHUB_API_BASE = "https://api.github.com"
60 +GITHUB_RAW_BASE = "https://raw.githubusercontent.com"
61 +
62 +# Only files matching this prefix-set are treated as library entries. Future
63 +# domain folders (e.g. "linux/", "office365/") can be added here without code
64 +# changes elsewhere.
65 +LIBRARY_PATH_PREFIXES = ("sysmon/",)
66 +
67 +# In-memory cache TTL. Matches the CoPilot Searches cache so the operator's
68 +# mental model for "how stale can this be" is the same.
69 +CACHE_TTL_MINUTES = 30
70 +
71 +
72 +def _github_headers() -> Dict[str, str]:
73 + """
74 + Headers sent on every GitHub call. The ``Authorization`` header is added
75 + only when ``GITHUB_TOKEN`` is set — its absence falls back to the 60/hr
76 + unauthenticated quota, which is enough for casual use but trips quickly
77 + on shared dev environments. Same convention as the CoPilot Searches loader.
78 + """
79 + headers = {"Accept": "application/vnd.github+json"}
80 + return headers
81 +
82 +
83 +class TemplateLibraryCache:
84 + """In-memory cache of parsed YAML library entries.
85 +
86 + The cache populates lazily on first access (``ensure_loaded``) and refreshes
87 + after the TTL expires or when ``refresh`` is invoked explicitly (e.g. via
88 + the ``POST /library/refresh`` admin endpoint).
89 + """
90 +
91 + def __init__(self) -> None:
92 + self._entries: Dict[str, Dict[str, Any]] = {} # key -> parsed dict
93 + self._invalid_paths: List[str] = [] # paths skipped during last refresh
94 + self._last_refresh: Optional[datetime] = None
95 + self._lock = asyncio.Lock()
96 +
97 + # ----- lifecycle -----
98 +
99 + @property
100 + def is_stale(self) -> bool:
101 + if self._last_refresh is None:
102 + return True
103 + return datetime.utcnow() - self._last_refresh > timedelta(minutes=CACHE_TTL_MINUTES)
104 +
105 + async def ensure_loaded(self) -> None:
106 + if self.is_stale:
107 + await self.refresh()
108 +
109 + async def refresh(self) -> int:
110 + """
111 + Re-fetch the repo tree and every YAML under a recognised prefix.
112 +
113 + Returns the number of valid entries loaded. Bad YAML files are logged
114 + and skipped (their paths are recorded in ``invalid_paths``); the
115 + whole-library refresh never raises on per-file parse errors so a
116 + single malformed file in the upstream repo doesn't take the feature
117 + down. Network failures DO bubble up — those should reach the operator.
118 + """
119 + async with self._lock:
120 + logger.info(
121 + f"Refreshing case-template library from github.com/{GITHUB_REPO}@{GITHUB_BRANCH}",
122 + )
123 +
124 + entries: Dict[str, Dict[str, Any]] = {}
125 + invalid: List[str] = []
126 +
127 + async with httpx.AsyncClient(timeout=30.0, headers=_github_headers()) as client:
128 + tree_url = f"{GITHUB_API_BASE}/repos/{GITHUB_REPO}/git/trees/{GITHUB_BRANCH}?recursive=1"
129 + response = await client.get(tree_url)
130 + response.raise_for_status()
131 + tree = response.json()
132 +
133 + yaml_paths = [
134 + item["path"]
135 + for item in tree.get("tree", [])
136 + if item.get("type") == "blob"
137 + and item.get("path", "").endswith((".yaml", ".yml"))
138 + and any(item["path"].startswith(p) for p in LIBRARY_PATH_PREFIXES)
139 + ]
140 + logger.info(f"Library tree has {len(yaml_paths)} YAML file(s) to parse")
141 +
142 + # Fan out the per-file fetches; gather lets one slow file
143 + # block the others without serialising the whole pull.
144 + tasks = [self._fetch_and_parse(client, p) for p in yaml_paths]
145 + results = await asyncio.gather(*tasks, return_exceptions=True)
146 +
147 + for path, result in zip(yaml_paths, results):
148 + if isinstance(result, Exception):
149 + logger.warning(f"Library: failed to fetch/parse {path}: {result}")
150 + invalid.append(path)
151 + continue
152 + if result is None:
153 + invalid.append(path)
154 + continue
155 +
156 + entry = result
157 + key = entry.get("key")
158 + if not key:
159 + logger.warning(f"Library: {path} missing 'key' field, skipping")
160 + invalid.append(path)
161 + continue
162 + if key in entries:
163 + logger.warning(
164 + f"Library: duplicate key '{key}' in {path} " f"(already used by {entries[key].get('_file_path')}), skipping",
165 + )
166 + invalid.append(path)
167 + continue
168 + entry["_file_path"] = path
169 + entries[key] = entry
170 +
171 + self._entries = entries
172 + self._invalid_paths = invalid
173 + self._last_refresh = datetime.utcnow()
174 + logger.info(
175 + f"Library loaded: {len(entries)} valid entr(ies), {len(invalid)} skipped",
176 + )
177 + return len(entries)
178 +
179 + async def _fetch_and_parse(
180 + self,
181 + client: httpx.AsyncClient,
182 + path: str,
183 + ) -> Optional[Dict[str, Any]]:
184 + """Fetch one YAML file, parse it, and run light shape validation."""
185 + raw_url = f"{GITHUB_RAW_BASE}/{GITHUB_REPO}/{GITHUB_BRANCH}/{path}"
186 + response = await client.get(raw_url)
187 + response.raise_for_status()
188 + try:
189 + data = yaml.safe_load(response.text)
190 + except yaml.YAMLError as e:
191 + logger.warning(f"Library: YAML parse error in {path}: {e}")
192 + return None
193 +
194 + if not isinstance(data, dict):
195 + logger.warning(f"Library: {path} top-level is not a mapping, skipping")
196 + return None
197 +
198 + return _normalize_entry(data)
199 +
200 + # ----- read accessors -----
201 +
202 + @property
203 + def entries(self) -> Dict[str, Dict[str, Any]]:
204 + return self._entries
205 +
206 + @property
207 + def invalid_paths(self) -> List[str]:
208 + return list(self._invalid_paths)
209 +
210 + @property
211 + def last_refresh(self) -> Optional[datetime]:
212 + return self._last_refresh
213 +
214 + def get_entry(self, key: str) -> Optional[Dict[str, Any]]:
215 + return self._entries.get(key)
216 +
217 +
218 +# ---------------------------------------------------------------------------
219 +# Normalisation + validation helpers
220 +# ---------------------------------------------------------------------------
221 +
222 +
223 +def _normalize_entry(data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
224 + """
225 + Coerce a raw YAML dict into the canonical library-entry shape. Returns
226 + ``None`` if the entry is missing required fields, so the caller can skip
227 + it cleanly. Optional fields are normalised to consistent defaults so the
228 + downstream Pydantic response model never sees ``None``-vs-missing
229 + ambiguity.
230 +
231 + Required: ``key``, ``name``, ``tasks`` (may be empty).
232 + """
233 + key = data.get("key")
234 + name = data.get("name")
235 + if not isinstance(key, str) or not key.strip():
236 + return None
237 + if not isinstance(name, str) or not name.strip():
238 + return None
239 +
240 + raw_tasks = data.get("tasks") or []
241 + if not isinstance(raw_tasks, list):
242 + return None
243 +
244 + normalised_tasks: List[Dict[str, Any]] = []
245 + for raw in raw_tasks:
246 + if not isinstance(raw, dict):
247 + continue
248 + title = raw.get("title")
249 + order_index = raw.get("order_index")
250 + if not isinstance(title, str) or not title.strip():
251 + continue
252 + if not isinstance(order_index, int) or order_index < 0:
253 + continue
254 + normalised_tasks.append(
255 + {
256 + "title": title.strip(),
257 + "description": raw.get("description") or None,
258 + "guidelines": raw.get("guidelines") or None,
259 + "mandatory": bool(raw.get("mandatory", False)),
260 + "order_index": order_index,
261 + },
262 + )
263 +
264 + # Sort tasks by their declared order_index up front so import order is
265 + # deterministic regardless of how the YAML happened to list them.
266 + normalised_tasks.sort(key=lambda t: t["order_index"])
267 +
268 + tags = data.get("tags")
269 + if not isinstance(tags, dict):
270 + tags = {}
271 +
272 + # Optional ``match:`` block for conditional auto-apply. Both ``field`` and
273 + # ``value`` must be present (and non-empty strings) or the block is
274 + # ignored — half-set conditions never trigger, so we'd rather drop the
275 + # block and log than persist a misleading template.
276 + match_field: Optional[str] = None
277 + match_value: Optional[str] = None
278 + match_block = data.get("match")
279 + if isinstance(match_block, dict):
280 + raw_field = match_block.get("field")
281 + raw_value = match_block.get("value")
282 + if isinstance(raw_field, str) and raw_field.strip() and raw_value is not None:
283 + match_field = raw_field.strip()
284 + # Coerce to string so YAML-typed values (ints, bools) survive the
285 + # round-trip the same way they arrive from OpenSearch — Wazuh
286 + # field values come back as strings ("1" not 1).
287 + match_value = str(raw_value)
288 +
289 + return {
290 + "key": key.strip(),
291 + "name": name.strip(),
292 + "description": data.get("description") or None,
293 + "source": data.get("source") or None,
294 + "match_field": match_field,
295 + "match_value": match_value,
296 + "tags": tags,
297 + "tasks": normalised_tasks,
298 + }
299 +
300 +
301 +# ---------------------------------------------------------------------------
302 +# Module-level singleton + service functions consumed by the routes.
303 +# ---------------------------------------------------------------------------
304 +
305 +template_library_cache = TemplateLibraryCache()
306 +
307 +
308 +async def list_library_entries() -> List[Dict[str, Any]]:
309 + """Return all library entries currently in cache. Loads on first call."""
310 + await template_library_cache.ensure_loaded()
311 + # Sort for a stable display order in the UI; primary key on `key`.
312 + return sorted(template_library_cache.entries.values(), key=lambda e: e["key"])
313 +
314 +
315 +async def get_library_entry(key: str) -> Optional[Dict[str, Any]]:
316 + """Fetch one library entry by its YAML ``key``. Loads on first call."""
317 + await template_library_cache.ensure_loaded()
318 + return template_library_cache.get_entry(key)
319 +
320 +
321 +async def refresh_library() -> Dict[str, Any]:
322 + """
323 + Force a re-fetch of the library repo. Returns a small status payload that
324 + the route layer wraps as the HTTP response.
325 + """
326 + count = await template_library_cache.refresh()
327 + return {
328 + "loaded": count,
329 + "invalid_paths": template_library_cache.invalid_paths,
330 + "last_refresh": template_library_cache.last_refresh,
331 + }
customer-portal/src/types/caseTemplates.ts
+1
@@ -20,6 +20,7 @@ export type CaseEventType =
20 export interface CaseTask {
21 id: number
22 case_id: number
23 + alert_id?: number | null
24 template_task_id?: number | null
25 title: string
26 description?: string | null
frontend/src/api/endpoints/incidentManagement/caseTemplates.ts
+31 -2
@@ -6,6 +6,8 @@ import type {
6 CaseTaskUpdatePayload,
7 CaseTemplate,
8 CaseTemplateCreatePayload,
9 + CaseTemplateLibraryListResponse,
10 + CaseTemplateLibraryRefreshResponse,
11 CaseTemplateTask,
12 CaseTemplateTaskCreatePayload,
13 CaseTemplateTaskUpdatePayload,
@@ -108,9 +110,13 @@ export default {
110 `/incidents/db_operations/case/tasks/${taskId}`
111 )
112 },
111 - applyTemplateToCase(caseId: number, templateId: number) {
113 + applyTemplateToCase(caseId: number, templateId: number, alertId?: number | null) {
114 + const params: Record<string, number> = {}
115 + if (alertId !== undefined && alertId !== null) params.alert_id = alertId
116 return HttpClient.post<FlaskBaseResponse & { tasks_added: number }>(
113 - `/incidents/db_operations/case/${caseId}/apply-template/${templateId}`
117 + `/incidents/db_operations/case/${caseId}/apply-template/${templateId}`,
118 + undefined,
119 + { params }
120 )
121 },
122
@@ -122,5 +128,28 @@ export default {
128 `/incidents/db_operations/case/${caseId}/timeline`,
129 { params: { limit, offset } }
130 )
131 + },
132 +
133 + // ---------------------------------------------------------------------------
134 + // Case Template Library (admin/analyst only — backend gates by scope)
135 + // Read-only catalog of YAML playbooks from
136 + // https://github.com/socfortress/CoPilot-Case-Templates.
137 + // ---------------------------------------------------------------------------
138 + getLibrary() {
139 + return HttpClient.get<FlaskBaseResponse & CaseTemplateLibraryListResponse>(
140 + `/incidents/case_templates/library`
141 + )
142 + },
143 + refreshLibrary() {
144 + return HttpClient.post<FlaskBaseResponse & CaseTemplateLibraryRefreshResponse>(
145 + `/incidents/case_templates/library/refresh`
146 + )
147 + },
148 + importLibraryEntry(key: string) {
149 + // Backend returns the standard CaseTemplateOperationResponse on success,
150 + // or HTTP 409 if a CaseTemplate with the same name already exists.
151 + return HttpClient.post<FlaskBaseResponse & { template: CaseTemplate | null }>(
152 + `/incidents/case_templates/library/${encodeURIComponent(key)}/import`
153 + )
154 }
155 }
frontend/src/components/incidentManagement/caseTemplates/CaseTemplateEditor.vue
+63 -4
@@ -42,6 +42,38 @@
42 <n-checkbox v-model:checked="form.is_default">Default for this (customer, source) scope</n-checkbox>
43 </n-form-item>
44
45 + <!--
46 + Conditional auto-apply. Both inputs must be filled (or both empty) — the
47 + backend rejects half-set pairs because a partial condition would silently
48 + never trigger. Example: field "data_win_system_eventID", value "1" applies
49 + this template only to Sysmon Event ID 1 events.
50 + -->
51 + <n-card size="small" title="Conditional auto-apply (optional)">
52 + <template #header-extra>
53 + <div v-if="matchHalfSet" class="text-warning text-xs">
54 + Both field and value are required
55 + </div>
56 + </template>
57 + <p class="text-secondary mb-2 text-xs">
58 + When set, auto-apply only fires if the originating Wazuh document has
59 + <code>{{ form.match_field || "<field>" }}</code>
60 + equal to
61 + <code>{{ form.match_value || "<value>" }}</code>
62 + . Leave blank for an unconditional template.
63 + </p>
64 + <div class="grid grid-cols-1 gap-3 @md:grid-cols-2">
65 + <n-form-item label="Match field" path="match_field" :show-feedback="false">
66 + <n-input
67 + v-model:value="form.match_field"
68 + placeholder="e.g., data_win_system_eventID"
69 + />
70 + </n-form-item>
71 + <n-form-item label="Match value" path="match_value" :show-feedback="false">
72 + <n-input v-model:value="form.match_value" placeholder="e.g., 1" />
73 + </n-form-item>
74 + </div>
75 + </n-card>
76 +
77 <n-card size="small" title="Tasks" content-class="flex flex-col gap-3">
78 <template #header-extra>
79 <div
@@ -158,6 +190,8 @@ interface FormModel {
190 customer_code: string | null
191 source: string | null
192 is_default: boolean
193 + match_field: string | null
194 + match_value: string | null
195 }
196
197 const props = defineProps<{
@@ -189,7 +223,15 @@ const form = ref<FormModel>({
223 description: null,
224 customer_code: null,
225 source: null,
192 - is_default: false
226 + is_default: false,
227 + match_field: null,
228 + match_value: null
229 +})
230 +
231 +const matchHalfSet = computed(() => {
232 + const hasField = !!form.value.match_field?.trim()
233 + const hasValue = !!form.value.match_value?.trim()
234 + return hasField !== hasValue
235 })
236 const formRules: FormRules = {
237 name: { required: true, message: "Name is required", trigger: "blur" }
@@ -210,7 +252,9 @@ function loadFromTemplate(t: CaseTemplate | null) {
252 description: t.description ?? "",
253 customer_code: t.customer_code ?? "",
254 source: t.source ?? "",
213 - is_default: t.is_default
255 + is_default: t.is_default,
256 + match_field: t.match_field ?? null,
257 + match_value: t.match_value ?? null
258 }
259 tasks.value = (t.tasks ?? []).map(task => ({
260 _key: nextKey(),
@@ -222,7 +266,15 @@ function loadFromTemplate(t: CaseTemplate | null) {
266 order_index: task.order_index
267 }))
268 } else {
225 - form.value = { name: null, description: null, customer_code: null, source: null, is_default: false }
269 + form.value = {
270 + name: null,
271 + description: null,
272 + customer_code: null,
273 + source: null,
274 + is_default: false,
275 + match_field: null,
276 + match_value: null
277 + }
278 tasks.value = [
279 {
280 _key: nextKey(),
@@ -338,6 +390,11 @@ async function handleSave() {
390 return
391 }
392
393 + if (matchHalfSet.value) {
394 + message.warning("Match field and match value must both be set or both be empty.")
395 + return
396 + }
397 +
398 saving.value = true
399
400 const payload = {
@@ -345,7 +402,9 @@ async function handleSave() {
402 description: form.value.description || null,
403 customer_code: form.value.customer_code || null,
404 source: form.value.source || null,
348 - is_default: form.value.is_default
405 + is_default: form.value.is_default,
406 + match_field: form.value.match_field?.trim() || null,
407 + match_value: form.value.match_value?.trim() || null
408 }
409
410 try {
frontend/src/components/incidentManagement/caseTemplates/CaseTemplateLibraryImportModal.vue new
+226
@@ -0,0 +1,226 @@
1 +<template>
2 + <n-modal
3 + v-model:show="showLocal"
4 + preset="card"
5 + display-directive="show"
6 + :title="entry ? `Import — ${entry.name}` : 'Import library entry'"
7 + :style="{ maxWidth: 'min(640px, 92vw)' }"
8 + segmented
9 + >
10 + <template v-if="entry">
11 + <div class="flex flex-col gap-3">
12 + <n-alert v-if="!result" type="info" :show-icon="false">
13 + This will create a new
14 + <strong>global</strong>
15 + case template (no customer or source scope). If you want a customer-specific version,
16 + edit the template after import.
17 + </n-alert>
18 +
19 + <div v-if="!result" class="flex flex-col gap-3">
20 + <div class="grid grid-cols-2 gap-2">
21 + <div class="info-cell">
22 + <div class="info-label">Key</div>
23 + <code class="text-xs">{{ entry.key }}</code>
24 + </div>
25 + <div class="info-cell">
26 + <div class="info-label">Source</div>
27 + <code v-if="entry.source" class="text-xs">{{ entry.source }}</code>
28 + <span v-else class="text-tertiary text-xs">—</span>
29 + </div>
30 + </div>
31 +
32 + <div
33 + v-if="entry.match_field && entry.match_value"
34 + class="info-cell"
35 + style="grid-column: 1 / -1;"
36 + >
37 + <div class="info-label">Conditional auto-apply</div>
38 + <div class="text-xs">
39 + Only fires when
40 + <code>{{ entry.match_field }}</code>
41 + ==
42 + <code>{{ entry.match_value }}</code>
43 + on the originating Wazuh document.
44 + </div>
45 + </div>
46 +
47 + <div v-if="entry.description" class="text-secondary text-sm">
48 + {{ entry.description }}
49 + </div>
50 +
51 + <div class="flex flex-col gap-2">
52 + <div class="text-secondary text-xs uppercase tracking-wide">
53 + Tasks ({{ entry.tasks.length }})
54 + </div>
55 + <div class="task-list">
56 + <div
57 + v-for="(t, idx) of entry.tasks"
58 + :key="`${entry.key}-${idx}`"
59 + class="task-row"
60 + >
61 + <div class="task-index">{{ t.order_index }}</div>
62 + <div class="flex min-w-0 flex-col gap-1">
63 + <div class="flex items-center gap-2">
64 + <div class="task-title">{{ t.title }}</div>
65 + <Badge v-if="t.mandatory" color="warning" size="small">
66 + <template #value>mandatory</template>
67 + </Badge>
68 + </div>
69 + <div v-if="t.description" class="text-secondary text-xs">
70 + {{ t.description }}
71 + </div>
72 + </div>
73 + </div>
74 + </div>
75 + </div>
76 +
77 + <div class="flex justify-end gap-2">
78 + <n-button size="small" quaternary :disabled="submitting" @click="close">
79 + Cancel
80 + </n-button>
81 + <n-button size="small" type="primary" :loading="submitting" @click="submit">
82 + Import as global template
83 + </n-button>
84 + </div>
85 + </div>
86 +
87 + <!-- Success view -->
88 + <div v-else class="flex flex-col gap-3">
89 + <n-alert type="success" :show-icon="false">
90 + <template #header>Template imported</template>
91 + <div class="text-sm">
92 + <strong>{{ result.name }}</strong>
93 + is now in your Templates list. You can apply it to any case from the case
94 + detail page, or edit/customize it from the Templates tab.
95 + </div>
96 + </n-alert>
97 + <div class="flex justify-end">
98 + <n-button size="small" type="primary" @click="close">Done</n-button>
99 + </div>
100 + </div>
101 + </div>
102 + </template>
103 + </n-modal>
104 +</template>
105 +
106 +<script setup lang="ts">
107 +import type {
108 + CaseTemplate,
109 + CaseTemplateLibraryEntry
110 +} from "@/types/incidentManagement/caseTemplates.d"
111 +import { NAlert, NButton, NModal, useMessage } from "naive-ui"
112 +import { computed, ref, watch } from "vue"
113 +import Api from "@/api"
114 +import Badge from "@/components/common/Badge.vue"
115 +
116 +const props = defineProps<{
117 + show: boolean
118 + entry: CaseTemplateLibraryEntry | null
119 +}>()
120 +
121 +const emit = defineEmits<{
122 + (e: "update:show", value: boolean): void
123 + (e: "imported", template: CaseTemplate): void
124 +}>()
125 +
126 +const message = useMessage()
127 +
128 +const showLocal = computed({
129 + get: () => props.show,
130 + set: v => emit("update:show", v)
131 +})
132 +
133 +const submitting = ref(false)
134 +const result = ref<CaseTemplate | null>(null)
135 +
136 +async function submit() {
137 + if (!props.entry) return
138 + submitting.value = true
139 + try {
140 + const res = await Api.incidentManagement.caseTemplates.importLibraryEntry(props.entry.key)
141 + if (res.data?.success && res.data.template) {
142 + result.value = res.data.template
143 + emit("imported", res.data.template)
144 + message.success(`Imported '${res.data.template.name}'`)
145 + } else {
146 + message.warning(res.data?.message || "Failed to import library entry")
147 + }
148 + } catch (err: any) {
149 + // Backend returns HTTP 409 on name collision with a useful detail message.
150 + const status = err.response?.status
151 + const detail =
152 + err.response?.data?.detail ||
153 + err.response?.data?.message ||
154 + err.message ||
155 + "Failed to import library entry"
156 + if (status === 409) {
157 + message.warning(detail)
158 + } else {
159 + message.error(detail)
160 + }
161 + } finally {
162 + submitting.value = false
163 + }
164 +}
165 +
166 +function close() {
167 + showLocal.value = false
168 +}
169 +
170 +// Reset result whenever the modal opens fresh OR the selected entry changes.
171 +watch(
172 + () => [props.show, props.entry?.key] as const,
173 + ([open]) => {
174 + if (open) result.value = null
175 + }
176 +)
177 +</script>
178 +
179 +<style scoped lang="scss">
180 +.info-cell {
181 + display: flex;
182 + flex-direction: column;
183 + gap: 4px;
184 + padding: 8px 10px;
185 + background: var(--bg-secondary-color);
186 + border: 1px solid var(--border-color);
187 + border-radius: var(--border-radius);
188 +}
189 +.info-label {
190 + font-size: 0.7rem;
191 + text-transform: uppercase;
192 + letter-spacing: 0.04em;
193 + color: var(--fg-tertiary-color);
194 +}
195 +
196 +.task-list {
197 + display: flex;
198 + flex-direction: column;
199 + border: 1px solid var(--border-color);
200 + border-radius: var(--border-radius);
201 + background: var(--bg-default-color);
202 + max-height: 320px;
203 + overflow-y: auto;
204 +}
205 +.task-row {
206 + display: flex;
207 + align-items: flex-start;
208 + gap: 10px;
209 + padding: 8px 12px;
210 +}
211 +.task-row + .task-row {
212 + border-top: 1px solid var(--border-color);
213 +}
214 +.task-index {
215 + font-family: var(--font-family-mono, monospace);
216 + font-size: 0.75rem;
217 + color: var(--fg-tertiary-color);
218 + min-width: 22px;
219 + text-align: right;
220 + padding-top: 2px;
221 +}
222 +.task-title {
223 + font-size: 0.85rem;
224 + color: var(--fg-default-color);
225 +}
226 +</style>
frontend/src/components/incidentManagement/caseTemplates/CaseTemplatesLibrary.vue new
+264
@@ -0,0 +1,264 @@
1 +<template>
2 + <div class="case-templates-library flex flex-col gap-4">
3 + <!-- Header / actions -->
4 + <div class="flex flex-col gap-2">
5 + <div class="flex flex-wrap items-center justify-between gap-3">
6 + <div class="flex items-center gap-3">
7 + <h2>Template Library</h2>
8 + <a
9 + :href="REPO_URL"
10 + target="_blank"
11 + rel="noopener"
12 + class="text-secondary text-xs"
13 + title="Source repository on GitHub"
14 + >
15 + {{ REPO_NAME }} ↗
16 + </a>
17 + </div>
18 + <div class="flex items-center gap-2">
19 + <div v-if="lastRefresh" class="text-tertiary text-xs">
20 + Cached
21 + {{ formatDate(lastRefresh, dFormats.datetimesec) }}
22 + </div>
23 + <n-button size="small" secondary :loading="refreshing" @click="refresh">
24 + <template #icon><Icon name="carbon:renew" /></template>
25 + Refresh
26 + </n-button>
27 + </div>
28 + </div>
29 + <p class="text-secondary text-sm">
30 + Read-only catalog of investigation playbooks. Click
31 + <strong>Import</strong>
32 + to materialise a playbook as a normal case template you can apply to cases. Edits made
33 + in CoPilot after import don't flow back to the source repo, and changes pushed to the
34 + repo don't retroactively update already-imported templates.
35 + </p>
36 +
37 + <n-alert v-if="invalidPaths.length" type="warning" :show-icon="false">
38 + <template #header>
39 + {{ invalidPaths.length }} library file(s) failed validation
40 + </template>
41 + <div class="text-xs">
42 + <code v-for="p of invalidPaths" :key="p" class="mr-2">{{ p }}</code>
43 + </div>
44 + </n-alert>
45 + </div>
46 +
47 + <!-- Filter -->
48 + <n-input
49 + v-model:value="search"
50 + size="small"
51 + placeholder="Search by name, description, or source"
52 + clearable
53 + class="max-w-96"
54 + >
55 + <template #prefix><Icon name="carbon:search" /></template>
56 + </n-input>
57 +
58 + <n-spin :show="loading">
59 + <div
60 + v-if="filteredEntries.length"
61 + class="grid grid-cols-1 gap-3 @2xl:grid-cols-2 @5xl:grid-cols-3"
62 + >
63 + <div v-for="entry of filteredEntries" :key="entry.key" class="library-card">
64 + <div class="library-card-header flex items-start justify-between gap-2">
65 + <div class="flex min-w-0 flex-col">
66 + <div class="library-card-name truncate">{{ entry.name }}</div>
67 + <code v-if="entry.source" class="text-tertiary text-xs">{{ entry.source }}</code>
68 + </div>
69 + <n-button
70 + size="small"
71 + type="primary"
72 + secondary
73 + :disabled="importingKey !== null"
74 + :loading="importingKey === entry.key"
75 + @click="openImport(entry)"
76 + >
77 + <template #icon><Icon name="carbon:download" /></template>
78 + Import
79 + </n-button>
80 + </div>
81 +
82 + <p v-if="entry.description" class="text-secondary line-clamp-3 text-sm">
83 + {{ entry.description }}
84 + </p>
85 +
86 + <div class="library-card-meta flex flex-wrap items-center gap-2 text-xs">
87 + <Badge type="splitted">
88 + <template #label>Tasks</template>
89 + <template #value>{{ entry.tasks.length }}</template>
90 + </Badge>
91 + <Badge v-if="mandatoryCount(entry) > 0" color="warning" type="splitted" bright>
92 + <template #label>Mandatory</template>
93 + <template #value>{{ mandatoryCount(entry) }}</template>
94 + </Badge>
95 + <n-tag
96 + v-if="entry.match_field && entry.match_value"
97 + size="tiny"
98 + :bordered="false"
99 + type="success"
100 + :title="`Conditional: applies when ${entry.match_field} == ${entry.match_value}`"
101 + >
102 + {{ entry.match_field }} == {{ entry.match_value }}
103 + </n-tag>
104 + <n-tag
105 + v-for="tactic of mitreTactics(entry)"
106 + :key="tactic"
107 + size="tiny"
108 + :bordered="false"
109 + >
110 + {{ tactic }}
111 + </n-tag>
112 + </div>
113 + </div>
114 + </div>
115 +
116 + <n-empty
117 + v-else-if="!loading && entries.length === 0"
118 + description="No library entries found. The repo may be empty or unreachable."
119 + class="h-40 justify-center"
120 + >
121 + <template #extra>
122 + <n-button size="small" @click="refresh">Retry</n-button>
123 + </template>
124 + </n-empty>
125 +
126 + <n-empty
127 + v-else-if="!loading"
128 + description="No entries match your search."
129 + class="h-32 justify-center"
130 + />
131 + </n-spin>
132 +
133 + <CaseTemplateLibraryImportModal
134 + v-model:show="showImport"
135 + :entry="selectedEntry"
136 + @imported="onImported"
137 + />
138 + </div>
139 +</template>
140 +
141 +<script setup lang="ts">
142 +import type { CaseTemplateLibraryEntry } from "@/types/incidentManagement/caseTemplates.d"
143 +import { NAlert, NButton, NEmpty, NInput, NSpin, NTag, useMessage } from "naive-ui"
144 +import { computed, onBeforeMount, ref } from "vue"
145 +import Api from "@/api"
146 +import Badge from "@/components/common/Badge.vue"
147 +import Icon from "@/components/common/Icon.vue"
148 +import { useSettingsStore } from "@/stores/settings"
149 +import { formatDate } from "@/utils/format"
150 +import CaseTemplateLibraryImportModal from "./CaseTemplateLibraryImportModal.vue"
151 +
152 +const REPO_NAME = "socfortress/CoPilot-Case-Templates"
153 +const REPO_URL = `https://github.com/${REPO_NAME}`
154 +
155 +const message = useMessage()
156 +const dFormats = useSettingsStore().dateFormat
157 +
158 +const emit = defineEmits<{
159 + (e: "imported"): void
160 +}>()
161 +
162 +const entries = ref<CaseTemplateLibraryEntry[]>([])
163 +const invalidPaths = ref<string[]>([])
164 +const lastRefresh = ref<string | null>(null)
165 +const loading = ref(false)
166 +const refreshing = ref(false)
167 +const importingKey = ref<string | null>(null)
168 +const search = ref<string | null>(null)
169 +
170 +const showImport = ref(false)
171 +const selectedEntry = ref<CaseTemplateLibraryEntry | null>(null)
172 +
173 +const filteredEntries = computed(() => {
174 + const q = (search.value || "").trim().toLowerCase()
175 + if (!q) return entries.value
176 + return entries.value.filter(e => {
177 + const haystack = `${e.name} ${e.description ?? ""} ${e.source ?? ""}`.toLowerCase()
178 + return haystack.includes(q)
179 + })
180 +})
181 +
182 +function mandatoryCount(entry: CaseTemplateLibraryEntry): number {
183 + return entry.tasks.filter(t => t.mandatory).length
184 +}
185 +
186 +function mitreTactics(entry: CaseTemplateLibraryEntry): string[] {
187 + const raw = entry.tags?.mitre_tactics
188 + if (!Array.isArray(raw)) return []
189 + return raw.filter((t): t is string => typeof t === "string")
190 +}
191 +
192 +function load() {
193 + loading.value = true
194 + Api.incidentManagement.caseTemplates
195 + .getLibrary()
196 + .then(res => {
197 + entries.value = res.data.entries || []
198 + invalidPaths.value = res.data.invalid_paths || []
199 + lastRefresh.value = res.data.last_refresh || null
200 + if (!res.data.success) {
201 + message.warning(res.data.message || "Failed to load case-template library")
202 + }
203 + })
204 + .catch(err => {
205 + message.error(err.response?.data?.message || "Failed to load case-template library")
206 + })
207 + .finally(() => {
208 + loading.value = false
209 + })
210 +}
211 +
212 +async function refresh() {
213 + refreshing.value = true
214 + try {
215 + const res = await Api.incidentManagement.caseTemplates.refreshLibrary()
216 + if (res.data.success) {
217 + message.success(res.data.message)
218 + } else {
219 + message.warning(res.data.message)
220 + }
221 + load()
222 + } catch (err: any) {
223 + message.error(err.response?.data?.message || "Failed to refresh case-template library")
224 + } finally {
225 + refreshing.value = false
226 + }
227 +}
228 +
229 +function openImport(entry: CaseTemplateLibraryEntry) {
230 + selectedEntry.value = entry
231 + showImport.value = true
232 +}
233 +
234 +function onImported() {
235 + // Bubble up so the parent (CaseTemplatesList) can refresh its own list
236 + // and switch the user back to the Templates tab.
237 + emit("imported")
238 +}
239 +
240 +onBeforeMount(load)
241 +</script>
242 +
243 +<style scoped lang="scss">
244 +.library-card {
245 + display: flex;
246 + flex-direction: column;
247 + gap: 10px;
248 + padding: 12px;
249 + border: 1px solid var(--border-color);
250 + border-radius: var(--border-radius);
251 + background: var(--bg-default-color);
252 + transition: border-color 0.12s, box-shadow 0.12s;
253 +}
254 +.library-card:hover {
255 + border-color: rgba(var(--primary-color-rgb) / 0.5);
256 +}
257 +.library-card-name {
258 + font-weight: 600;
259 + color: var(--fg-default-color);
260 +}
261 +.library-card-meta {
262 + margin-top: auto;
263 +}
264 +</style>
frontend/src/components/incidentManagement/caseTemplates/CaseTemplatesList.vue
+45 -12
@@ -1,22 +1,26 @@
1 <template>
2 <div class="case-templates-list flex flex-col gap-4">
3 - <!-- Header / actions -->
3 + <!-- Page header — kept outside the tabs so context is always visible -->
4 <div class="flex flex-col gap-2">
5 - <div class="flex items-center gap-4">
6 - <h2>Case Templates</h2>
7 - <n-button size="small" secondary type="primary" @click="openCreate">
8 - <template #icon><Icon name="carbon:add" /></template>
9 - New template
10 - </n-button>
11 - </div>
5 + <h2>Case Templates</h2>
6 <p>
7 Reusable investigation playbooks. Templates are matched to new cases by customer + alert source on case
8 creation, with priority customer+source &gt; customer-only &gt; source-only &gt; global default.
9 </p>
10 </div>
11
18 - <!-- Filters -->
19 - <div class="@container mt-4 grid grid-cols-12 items-center gap-3">
12 + <n-tabs v-model:value="activeTab" type="line" animated>
13 + <n-tab-pane name="templates" tab="Templates">
14 + <div class="flex flex-col gap-4">
15 + <div>
16 + <n-button size="small" secondary type="primary" @click="openCreate">
17 + <template #icon><Icon name="carbon:add" /></template>
18 + New template
19 + </n-button>
20 + </div>
21 +
22 + <!-- Filters -->
23 + <div class="@container mt-2 grid grid-cols-12 items-center gap-3">
24 <n-input
25 v-model:value="search"
26 size="small"
@@ -53,7 +57,14 @@
57 </n-checkbox>
58 </div>
59
56 - <n-data-table :columns :data="filteredRows" :loading size="small" />
60 + <n-data-table :columns :data="filteredRows" :loading size="small" />
61 + </div>
62 + </n-tab-pane>
63 +
64 + <n-tab-pane name="library" tab="Library">
65 + <CaseTemplatesLibrary @imported="onLibraryImported" />
66 + </n-tab-pane>
67 + </n-tabs>
68
69 <!-- Editor modal -->
70 <n-modal
@@ -75,7 +86,19 @@ import type { Customer } from "@/types/customers"
86 import type { CaseTemplate } from "@/types/incidentManagement/caseTemplates.d"
87 import type { SourceName } from "@/types/incidentManagement/sources"
88 import { useDebounceFn } from "@vueuse/core"
78 -import { NButton, NCheckbox, NDataTable, NInput, NModal, NSelect, NTag, useDialog, useMessage } from "naive-ui"
89 +import {
90 + NButton,
91 + NCheckbox,
92 + NDataTable,
93 + NInput,
94 + NModal,
95 + NSelect,
96 + NTabPane,
97 + NTabs,
98 + NTag,
99 + useDialog,
100 + useMessage
101 +} from "naive-ui"
102 import { computed, onBeforeMount, ref, watch } from "vue"
103 import Api from "@/api"
104 import Icon from "@/components/common/Icon.vue"
@@ -83,6 +106,7 @@ import { useSettingsStore } from "@/stores/settings"
106 import { getApiErrorMessage } from "@/utils"
107 import { formatDate } from "@/utils/format"
108 import CaseTemplateEditor from "./CaseTemplateEditor.vue"
109 +import CaseTemplatesLibrary from "./CaseTemplatesLibrary.vue"
110
111 const message = useMessage()
112 const dialog = useDialog()
@@ -109,6 +133,15 @@ const sourcesOptions = computed(() => configuredSourcesList.value.map(o => ({ la
133 const showEditor = ref(false)
134 const editing = ref<CaseTemplate | null>(null)
135
136 +const activeTab = ref<"templates" | "library">("templates")
137 +
138 +function onLibraryImported() {
139 + // After a library import lands, jump the user back to the Templates tab and
140 + // refetch so the freshly-imported row appears immediately.
141 + activeTab.value = "templates"
142 + fetchTemplates()
143 +}
144 +
145 function renderCustomerCode(customerCode: string | null | undefined) {
146 if (customerCode) {
147 return <span class="font-mono">{customerCode}</span>
frontend/src/components/incidentManagement/cases/CaseDetails.vue
+1
@@ -36,6 +36,7 @@
36 :case-id="caseEntity.id"
37 :customer-code="caseEntity.customer_code"
38 :can-edit="canEditTasks"
39 + :linked-alerts="caseEntity.alerts || []"
40 />
41 </div>
42 </n-tab-pane>
frontend/src/components/incidentManagement/cases/CaseTasks/CaseTasksCreateForm.vue
+36 -6
@@ -3,6 +3,19 @@
3 <n-form-item label="Title" path="title">
4 <n-input v-model:value="addForm.title" placeholder="What needs to be done?" />
5 </n-form-item>
6 + <n-form-item
7 + v-if="alertOptions.length"
8 + label="Attach to alert (optional)"
9 + path="alertId"
10 + >
11 + <n-select
12 + v-model:value="addForm.alertId"
13 + :options="alertOptions"
14 + placeholder="Case-wide / general (no alert)"
15 + clearable
16 + :consistent-menu-width="false"
17 + />
18 + </n-form-item>
19 <n-form-item path="mandatory" :show-label="false">
20 <n-checkbox v-model:checked="addForm.mandatory">Mandatory (blocks close-with-warning)</n-checkbox>
21 </n-form-item>
@@ -28,13 +41,15 @@
41 <script setup lang="ts">
42 import type { FormInst, FormRules } from "naive-ui"
43 import type { ApiError } from "@/types/common"
31 -import { NButton, NCheckbox, NForm, NFormItem, NInput, useMessage } from "naive-ui"
44 +import type { Alert } from "@/types/incidentManagement/alerts.d"
45 +import { NButton, NCheckbox, NForm, NFormItem, NInput, NSelect, useMessage } from "naive-ui"
46 import { computed, ref } from "vue"
47 import Api from "@/api"
48 import { getApiErrorMessage } from "@/utils"
49
36 -const { caseId } = defineProps<{
50 +const { caseId, linkedAlerts } = defineProps<{
51 caseId: number
52 + linkedAlerts?: Alert[]
53 }>()
54
55 const emit = defineEmits<{
@@ -45,23 +60,37 @@ const message = useMessage()
60
61 const submitting = ref(false)
62 const addFormRef = ref<FormInst | null>(null)
48 -const addForm = ref({
63 +const addForm = ref<{
64 + title: string
65 + description: string
66 + guidelines: string
67 + mandatory: boolean
68 + alertId: number | null
69 +}>({
70 title: "",
71 description: "",
72 guidelines: "",
52 - mandatory: false
73 + mandatory: false,
74 + alertId: null
75 })
76
77 const addFormRules: FormRules = {
78 title: { required: true, message: "Title is required", trigger: "blur" }
79 }
80
81 +const alertOptions = computed(() =>
82 + (linkedAlerts || []).map(a => ({
83 + label: `#${a.id} — ${a.alert_name} (${a.source})`,
84 + value: a.id
85 + }))
86 +)
87 +
88 const isValid = computed(() => {
89 return addForm.value.title.trim() !== ""
90 })
91
92 function resetForm() {
64 - addForm.value = { title: "", description: "", guidelines: "", mandatory: false }
93 + addForm.value = { title: "", description: "", guidelines: "", mandatory: false, alertId: null }
94 }
95
96 async function submitAddTask() {
@@ -77,7 +106,8 @@ async function submitAddTask() {
106 title: addForm.value.title,
107 description: addForm.value.description || null,
108 guidelines: addForm.value.guidelines || null,
80 - mandatory: addForm.value.mandatory
109 + mandatory: addForm.value.mandatory,
110 + alert_id: addForm.value.alertId
111 })
112 if (res.data.success && res.data.task) {
113 resetForm()
frontend/src/components/incidentManagement/cases/CaseTasks/CaseTasksList.vue
+95 -13
@@ -1,18 +1,49 @@
1 <template>
2 <div class="case-tasks-list flex flex-col gap-4">
3 - <CaseTasksToolbar :case-id :customer-code :can-edit :tasks @updated="fetchTasks" />
3 + <CaseTasksToolbar
4 + :case-id
5 + :customer-code
6 + :can-edit
7 + :tasks
8 + :linked-alerts
9 + @updated="fetchTasks"
10 + />
11
12 <n-spin :show="loading">
6 - <div v-if="tasks.length" class="flex flex-col gap-3">
7 - <CaseTaskItem
8 - v-for="task in tasks"
9 - :key="task.id"
10 - :task
11 - :case-id
12 - :can-edit
13 - @deleted="fetchTasks"
14 - @updated="handleTaskUpdated"
15 - />
13 + <div v-if="tasks.length" class="flex flex-col gap-4">
14 + <!--
15 + One group per originating alert plus a "Case-wide" bucket for
16 + orphaned / never-attached tasks (alert_id IS NULL). Group order:
17 + alerts in their linked order, case-wide last.
18 + -->
19 + <div v-for="group in groups" :key="group.key" class="task-group flex flex-col gap-2">
20 + <div class="task-group-header flex flex-wrap items-center gap-2">
21 + <template v-if="group.alert">
22 + <span class="task-group-title">{{ group.alert.alert_name }}</span>
23 + <code class="text-tertiary text-xs">{{ group.alert.source }}</code>
24 + <n-tag size="tiny" :bordered="false">alert #{{ group.alert.id }}</n-tag>
25 + </template>
26 + <template v-else>
27 + <span class="task-group-title">Case-wide / general</span>
28 + <n-tag size="tiny" :bordered="false" type="info">
29 + not attached to any alert
30 + </n-tag>
31 + </template>
32 + <n-tag size="tiny" :bordered="false">{{ group.tasks.length }} task(s)</n-tag>
33 + </div>
34 +
35 + <div class="flex flex-col gap-3 pl-2">
36 + <CaseTaskItem
37 + v-for="task in group.tasks"
38 + :key="task.id"
39 + :task
40 + :case-id
41 + :can-edit
42 + @deleted="fetchTasks"
43 + @updated="handleTaskUpdated"
44 + />
45 + </div>
46 + </div>
47 </div>
48 <n-empty v-else-if="!loading" description="No tasks on this case" class="h-32 justify-center" />
49 </n-spin>
@@ -20,9 +51,10 @@
51 </template>
52
53 <script setup lang="ts">
54 +import type { Alert } from "@/types/incidentManagement/alerts.d"
55 import type { CaseTask } from "@/types/incidentManagement/caseTemplates.d"
24 -import { NEmpty, NSpin, useMessage } from "naive-ui"
25 -import { onBeforeMount, ref } from "vue"
56 +import { NEmpty, NSpin, NTag, useMessage } from "naive-ui"
57 +import { computed, onBeforeMount, ref } from "vue"
58 import Api from "@/api"
59 import CaseTaskItem from "./CaseTaskItem.vue"
60 import CaseTasksToolbar from "./CaseTasksToolbar.vue"
@@ -31,12 +63,49 @@ const props = defineProps<{
63 caseId: number
64 customerCode?: string | null
65 canEdit: boolean
66 + linkedAlerts?: Alert[]
67 }>()
68
69 const message = useMessage()
70 const tasks = ref<CaseTask[]>([])
71 const loading = ref(false)
72
73 +interface TaskGroup {
74 + key: string
75 + alert: Alert | null
76 + tasks: CaseTask[]
77 +}
78 +
79 +const groups = computed<TaskGroup[]>(() => {
80 + // Index tasks by alert_id so we can attach them to their alert group; tasks
81 + // whose alert_id no longer matches a linked alert (orphans) fall through
82 + // to the case-wide bucket alongside genuine alert_id=null tasks.
83 + const linkedAlerts = props.linkedAlerts || []
84 + const linkedIds = new Set(linkedAlerts.map(a => a.id))
85 + const byAlert: Record<number, CaseTask[]> = {}
86 + const caseWide: CaseTask[] = []
87 +
88 + for (const t of tasks.value) {
89 + if (t.alert_id != null && linkedIds.has(t.alert_id)) {
90 + if (!byAlert[t.alert_id]) byAlert[t.alert_id] = []
91 + byAlert[t.alert_id].push(t)
92 + } else {
93 + caseWide.push(t)
94 + }
95 + }
96 +
97 + const out: TaskGroup[] = []
98 + for (const a of linkedAlerts) {
99 + const groupTasks = byAlert[a.id] || []
100 + if (!groupTasks.length) continue
101 + out.push({ key: `alert-${a.id}`, alert: a, tasks: groupTasks })
102 + }
103 + if (caseWide.length) {
104 + out.push({ key: "case-wide", alert: null, tasks: caseWide })
105 + }
106 + return out
107 +})
108 +
109 function fetchTasks() {
110 loading.value = true
111 Api.incidentManagement.caseTemplates
@@ -62,3 +131,16 @@ function handleTaskUpdated(task: CaseTask) {
131
132 onBeforeMount(fetchTasks)
133 </script>
134 +
135 +<style scoped lang="scss">
136 +.task-group-header {
137 + padding: 6px 8px;
138 + background: var(--bg-secondary-color);
139 + border: 1px solid var(--border-color);
140 + border-radius: var(--border-radius);
141 +}
142 +.task-group-title {
143 + font-weight: 600;
144 + color: var(--fg-default-color);
145 +}
146 +</style>
frontend/src/components/incidentManagement/cases/CaseTasks/CaseTasksToolbar.vue
+7 -1
@@ -44,7 +44,11 @@
44 title="Add custom task"
45 :style="{ maxWidth: 'min(600px, 90vw)', overflow: 'hidden' }"
46 >
47 - <CaseTasksCreateForm :case-id @success="handleAddTaskSuccess" />
47 + <CaseTasksCreateForm
48 + :case-id
49 + :linked-alerts="linkedAlerts || []"
50 + @success="handleAddTaskSuccess"
51 + />
52 </n-modal>
53
54 <!-- Apply template modal -->
@@ -61,6 +65,7 @@
65 </template>
66
67 <script setup lang="ts">
68 +import type { Alert } from "@/types/incidentManagement/alerts.d"
69 import type { CaseTask } from "@/types/incidentManagement/caseTemplates.d"
70 import { NButton, NModal } from "naive-ui"
71 import { computed, ref } from "vue"
@@ -74,6 +79,7 @@ const props = defineProps<{
79 customerCode?: string | null
80 canEdit: boolean
81 tasks: CaseTask[]
82 + linkedAlerts?: Alert[]
83 }>()
84
85 const emit = defineEmits<{
frontend/src/types/incidentManagement/caseTemplates.d.ts
+57
@@ -55,6 +55,11 @@ export interface CaseTemplate {
55 customer_code?: string | null
56 source?: string | null
57 is_default: boolean
58 + // Optional conditional auto-apply: both fields must be set together.
59 + // When set, auto-apply runs only if document[match_field] == match_value
60 + // on the originating Wazuh event.
61 + match_field?: string | null
62 + match_value?: string | null
63 created_by: string
64 created_at: string
65 updated_at: string
@@ -67,6 +72,8 @@ export interface CaseTemplateCreatePayload {
72 customer_code?: string | null
73 source?: string | null
74 is_default?: boolean
75 + match_field?: string | null
76 + match_value?: string | null
77 tasks?: CaseTemplateTaskCreatePayload[]
78 }
79
@@ -76,6 +83,8 @@ export interface CaseTemplateUpdatePayload {
83 customer_code?: string | null
84 source?: string | null
85 is_default?: boolean
86 + match_field?: string | null
87 + match_value?: string | null
88 }
89
90 // ----- CaseTask (per-case instance) -----
@@ -83,6 +92,7 @@ export interface CaseTemplateUpdatePayload {
92 export interface CaseTask {
93 id: number
94 case_id: number
95 + alert_id?: number | null
96 template_task_id?: number | null
97 title: string
98 description?: string | null
@@ -104,6 +114,9 @@ export interface CaseTaskCreatePayload {
114 guidelines?: string | null
115 mandatory?: boolean
116 order_index?: number
117 + // When set, the alert must already be linked to the case or the backend
118 + // rejects with 400. Omit / null for case-wide / general tasks.
119 + alert_id?: number | null
120 }
121
122 export interface CaseTaskUpdatePayload {
@@ -130,3 +143,47 @@ export interface CaseEvent {
143 timestamp: string
144 payload?: Record<string, unknown> | null
145 }
146 +
147 +// ----- Case Template Library -----
148 +// Mirrors the backend's CaseTemplateLibraryEntry / CaseTemplateLibraryListResponse
149 +// / CaseTemplateLibraryRefreshResponse from backend/app/incidents/schema/case_templates.py.
150 +//
151 +// A LibraryEntry is the YAML view of a playbook hosted in
152 +// https://github.com/socfortress/CoPilot-Case-Templates. It becomes a real
153 +// CaseTemplate row only on import.
154 +
155 +export interface CaseTemplateLibraryTask {
156 + title: string
157 + description?: string | null
158 + guidelines?: string | null
159 + mandatory: boolean
160 + order_index: number
161 +}
162 +
163 +export interface CaseTemplateLibraryEntry {
164 + key: string
165 + name: string
166 + description?: string | null
167 + source?: string | null
168 + match_field?: string | null
169 + match_value?: string | null
170 + tags: Record<string, unknown>
171 + tasks: CaseTemplateLibraryTask[]
172 + file_path?: string | null
173 +}
174 +
175 +export interface CaseTemplateLibraryListResponse {
176 + entries: CaseTemplateLibraryEntry[]
177 + invalid_paths: string[]
178 + last_refresh: string | null
179 + success: boolean
180 + message: string
181 +}
182 +
183 +export interface CaseTemplateLibraryRefreshResponse {
184 + loaded: number
185 + invalid_paths: string[]
186 + last_refresh: string | null
187 + success: boolean
188 + message: string
189 +}