diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml
index 6f47ba1..bd6be77 100644
--- a/ansible/deploy_aw_server.yml
+++ b/ansible/deploy_aw_server.yml
@@ -305,6 +305,9 @@
AW_DLP_POLICY_ENGINE_BIND_HOST={{ aw_dlp_policy_engine_bind_host }}
AW_DLP_POLICY_ENGINE_PORT={{ aw_dlp_policy_engine_port }}
AW_DLP_POLICY_ENGINE_DB_PATH={{ aw_dlp_policy_engine_db_path }}
+ AW_DLP_CASE_BIND_HOST={{ aw_dlp_case_bind_host }}
+ AW_DLP_CASE_PORT={{ aw_dlp_case_port }}
+ AW_DLP_CASE_DB_PATH={{ aw_dlp_case_db_path }}
XDG_DATA_HOME={{ aw_server_data_dir }}/.local/share
XDG_CONFIG_HOME={{ aw_server_data_dir }}/.config
@@ -478,6 +481,59 @@
mode: "0644"
when: aw_dlp_integrations_enabled | default(true) | bool
+ - name: Создать каталог DLP case management
+ ansible.builtin.file:
+ path: /opt/activitywatch/dlp-case-management
+ state: directory
+ owner: "{{ aw_server_user }}"
+ group: "{{ aw_server_group }}"
+ mode: "0755"
+ when: aw_dlp_case_management_enabled | default(true) | bool
+
+ - name: Скопировать файлы DLP case management
+ ansible.builtin.copy:
+ src: "{{ aw_repo_root }}/aw-server/dlp-case-management/{{ item }}"
+ dest: "/opt/activitywatch/dlp-case-management/{{ item }}"
+ owner: "{{ aw_server_user }}"
+ group: "{{ aw_server_group }}"
+ mode: "0644"
+ loop:
+ - case_service.py
+ - case_schema.py
+ - case_storage.py
+ - requirements.txt
+ when: aw_dlp_case_management_enabled | default(true) | bool
+
+ - name: Выставить executable для case_service
+ ansible.builtin.file:
+ path: /opt/activitywatch/dlp-case-management/case_service.py
+ owner: "{{ aw_server_user }}"
+ group: "{{ aw_server_group }}"
+ mode: "0755"
+ when: aw_dlp_case_management_enabled | default(true) | bool
+
+ - name: Создать virtualenv DLP case management
+ ansible.builtin.command:
+ cmd: python3 -m venv /opt/activitywatch/dlp-case-management/.venv
+ args:
+ creates: /opt/activitywatch/dlp-case-management/.venv/bin/python
+ when: aw_dlp_case_management_enabled | default(true) | bool
+
+ - name: Установить зависимости DLP case management
+ ansible.builtin.pip:
+ requirements: /opt/activitywatch/dlp-case-management/requirements.txt
+ virtualenv: /opt/activitywatch/dlp-case-management/.venv
+ when: aw_dlp_case_management_enabled | default(true) | bool
+
+ - name: Установить systemd unit DLP case management
+ ansible.builtin.copy:
+ src: "{{ aw_repo_root }}/aw-server/dlp-case-management/case-service.service"
+ dest: /etc/systemd/system/aw-dlp-case-management.service
+ owner: root
+ group: root
+ mode: "0644"
+ when: aw_dlp_case_management_enabled | default(true) | bool
+
- name: Установить скрипт AW worktime API
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.py"
@@ -575,6 +631,13 @@
state: restarted
when: aw_dlp_integrations_enabled | default(true) | bool
+ - name: Включить и перезапустить DLP case management
+ ansible.builtin.systemd:
+ name: aw-dlp-case-management.service
+ enabled: true
+ state: restarted
+ when: aw_dlp_case_management_enabled | default(true) | bool
+
- name: Включить и перезапустить AW worktime API
ansible.builtin.systemd:
name: aw-worktime-api.service
diff --git a/ansible/deploy_dlp_full_stack.yml b/ansible/deploy_dlp_full_stack.yml
index fe25228..5876679 100644
--- a/ansible/deploy_dlp_full_stack.yml
+++ b/ansible/deploy_dlp_full_stack.yml
@@ -10,3 +10,4 @@
- role: dlp-policy-engine
- role: dlp-content-analysis
- role: dlp-integrations
+ - role: dlp-case-management
diff --git a/ansible/group_vars/all.example.yml b/ansible/group_vars/all.example.yml
index d08d820..f6d14d0 100644
--- a/ansible/group_vars/all.example.yml
+++ b/ansible/group_vars/all.example.yml
@@ -42,4 +42,8 @@ aw_dlp_policy_engine_port: 5601
aw_dlp_policy_engine_db_path: "{{ aw_server_data_dir }}/dlp-policy-engine.sqlite"
aw_dlp_content_analysis_enabled: true
aw_dlp_integrations_enabled: true
+aw_dlp_case_management_enabled: true
+aw_dlp_case_bind_host: "0.0.0.0"
+aw_dlp_case_port: 5602
+aw_dlp_case_db_path: "/opt/activitywatch/dlp-case-management/cases.db"
aw_server_post_deploy_health_check_enabled: true
diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml
index 77de70a..6036c77 100644
--- a/ansible/group_vars/all.yml
+++ b/ansible/group_vars/all.yml
@@ -33,6 +33,10 @@ aw_dlp_policy_engine_port: 5601
aw_dlp_policy_engine_db_path: "{{ aw_server_data_dir }}/dlp-policy-engine.sqlite"
aw_dlp_content_analysis_enabled: true
aw_dlp_integrations_enabled: true
+aw_dlp_case_management_enabled: true
+aw_dlp_case_bind_host: "0.0.0.0"
+aw_dlp_case_port: 5602
+aw_dlp_case_db_path: "/opt/activitywatch/dlp-case-management/cases.db"
aw_server_post_deploy_health_check_enabled: true
aw_worktime_from: "08:00"
diff --git a/ansible/roles/dlp-case-management/tasks/main.yml b/ansible/roles/dlp-case-management/tasks/main.yml
new file mode 100644
index 0000000..ba06453
--- /dev/null
+++ b/ansible/roles/dlp-case-management/tasks/main.yml
@@ -0,0 +1,54 @@
+---
+- name: Ensure DLP case management directory
+ ansible.builtin.file:
+ path: /opt/activitywatch/dlp-case-management
+ state: directory
+ owner: "{{ aw_server_user | default('activitywatch') }}"
+ group: "{{ aw_server_group | default('activitywatch') }}"
+ mode: "0755"
+
+- name: Copy DLP case management files
+ ansible.builtin.copy:
+ src: "{{ playbook_dir }}/../aw-server/dlp-case-management/{{ item }}"
+ dest: "/opt/activitywatch/dlp-case-management/{{ item }}"
+ owner: "{{ aw_server_user | default('activitywatch') }}"
+ group: "{{ aw_server_group | default('activitywatch') }}"
+ mode: "0644"
+ loop:
+ - case_service.py
+ - case_schema.py
+ - case_storage.py
+ - requirements.txt
+
+- name: Make case service executable
+ ansible.builtin.file:
+ path: /opt/activitywatch/dlp-case-management/case_service.py
+ owner: "{{ aw_server_user | default('activitywatch') }}"
+ group: "{{ aw_server_group | default('activitywatch') }}"
+ mode: "0755"
+
+- name: Create venv for DLP case management
+ ansible.builtin.command:
+ cmd: python3 -m venv /opt/activitywatch/dlp-case-management/.venv
+ args:
+ creates: /opt/activitywatch/dlp-case-management/.venv/bin/python
+
+- name: Install Python dependencies for DLP case management
+ ansible.builtin.pip:
+ requirements: /opt/activitywatch/dlp-case-management/requirements.txt
+ virtualenv: /opt/activitywatch/dlp-case-management/.venv
+
+- name: Install systemd unit DLP case management
+ ansible.builtin.copy:
+ src: "{{ playbook_dir }}/../aw-server/dlp-case-management/case-service.service"
+ dest: /etc/systemd/system/aw-dlp-case-management.service
+ owner: root
+ group: root
+ mode: "0644"
+
+- name: Reload systemd and restart DLP case management
+ ansible.builtin.systemd:
+ name: aw-dlp-case-management.service
+ daemon_reload: true
+ enabled: true
+ state: restarted
diff --git a/aw-server/aw-ru-patch.js b/aw-server/aw-ru-patch.js
index 65f85c7..63f2954 100755
--- a/aw-server/aw-ru-patch.js
+++ b/aw-server/aw-ru-patch.js
@@ -958,6 +958,7 @@
'
' +
'' +
'' +
+ '' +
" | " +
""
);
@@ -1028,6 +1029,55 @@
}, 1);
}
+ function getCaseApiBase() {
+ if (window.__awCaseApiBase && typeof window.__awCaseApiBase === "string") {
+ return window.__awCaseApiBase.replace(/\/+$/, "");
+ }
+ try {
+ const origin = window.location.origin || "";
+ if (/:\d+$/.test(origin)) return origin.replace(/:\d+$/, ":5602");
+ return origin + ":5602";
+ } catch (error) {
+ return "http://127.0.0.1:5602";
+ }
+ }
+
+ async function caseApi(path, init) {
+ const response = await fetch(getCaseApiBase() + path, Object.assign({ credentials: "omit" }, init || {}));
+ if (!response.ok) throw new Error("Case API HTTP " + response.status);
+ if (response.status === 204) return null;
+ return response.json();
+ }
+
+ async function createCaseFromEvent(host, event, row) {
+ const data = event.data || {};
+ const verdict = row.querySelector("[data-aw-ru-dlp-verdict]").value;
+ const category = row.querySelector("[data-aw-ru-dlp-category]").value.trim();
+ const comment = row.querySelector("[data-aw-ru-dlp-comment]").value.trim();
+ const incidentId = buildDlpKey(event);
+ const title = "DLP " + (data.signalType || "incident") + " · " + (data.username || data.owner || host || "unknown");
+ return caseApi("/api/0/dlp/cases", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ incident_id: incidentId,
+ host: host,
+ title: title,
+ severity: verdict === "incident" ? "high" : "medium",
+ source_bucket: getDlpBucketIdFromHash(),
+ source_event_ts: event.timestamp,
+ evidence: {
+ signalType: data.signalType || "",
+ username: data.username || data.owner || "",
+ documentName: data.documentName || "",
+ printerName: data.printerName || "",
+ category: category,
+ comment: comment
+ }
+ })
+ });
+ }
+
async function saveDlpRule(host, event, row) {
const bucketId = "aw-dlp-rules_" + host;
await ensureAwBucket(bucketId, "aw-dlp-rules", "aw.dlp.rule", host);
@@ -1081,9 +1131,47 @@
message.textContent = "Ошибка сохранения правила: " + error.message;
}
});
+ row.querySelector("[data-aw-ru-create-case]").addEventListener("click", async function () {
+ const message = center.querySelector("[data-aw-ru-dlp-message]");
+ try {
+ const created = await createCaseFromEvent(host, event, row);
+ await renderCaseManager(center, host);
+ message.textContent = "Кейс создан: #" + (created && created.id ? created.id : "?");
+ } catch (error) {
+ message.textContent = "Ошибка создания кейса: " + error.message;
+ }
+ });
});
}
+ async function renderCaseManager(center, host) {
+ const tbody = center.querySelector("[data-aw-ru-dlp-cases]");
+ if (!tbody) return;
+ try {
+ const cases = await caseApi("/api/0/dlp/cases?host=" + encodeURIComponent(host) + "&limit=100", { method: "GET" });
+ const rows = (cases || []).map(function (c) {
+ return (
+ "" +
+ "| " + escapeHtml(String(c.id || "")) + " | " +
+ "" + escapeHtml(String(c.status || "")) + " | " +
+ "" + escapeHtml(String(c.severity || "")) + " | " +
+ "" + escapeHtml(String(c.title || "")) + " | " +
+ "" + escapeHtml(String(c.assignee || "")) + " | " +
+ "" + escapeHtml(String(c.incident_id || "")) + " | " +
+ "" + escapeHtml(String(c.updated_at || c.created_at || "")) + " | " +
+ "
"
+ );
+ });
+ tbody.innerHTML = rows.length ? rows.join("") : '| Кейсов нет. |
';
+ const status = center.querySelector("[data-aw-ru-dlp-cases-status]");
+ if (status) status.textContent = "Кейсов: " + (cases || []).length;
+ } catch (error) {
+ tbody.innerHTML = '| Ошибка загрузки кейсов: ' + escapeHtml(error.message) + ' |
';
+ const status = center.querySelector("[data-aw-ru-dlp-cases-status]");
+ if (status) status.textContent = "Кейсы недоступны";
+ }
+ }
+
async function setDlpRuleEnabled(host, ruleEvent, enabled) {
const bucketId = "aw-dlp-rules_" + host;
await ensureAwBucket(bucketId, "aw-dlp-rules", "aw.dlp.rule", host);
@@ -1238,6 +1326,7 @@
state.reviews = [];
}
renderDlpTableRows(center, host);
+ await renderCaseManager(center, host);
center.querySelector("[data-aw-ru-dlp-message]").textContent = "DLP review центр обновлен.";
} catch (error) {
center.querySelector("[data-aw-ru-dlp-message]").textContent = "Ошибка загрузки DLP-событий: " + error.message;
@@ -1293,6 +1382,16 @@
'| Загрузка... |
' +
'' +
'' +
+ '' +
+ '
' +
+ '
' +
+ '| ID | Статус | Severity | Заголовок | Исполнитель | Incident ID | Обновлено |
' +
+ '| Загрузка... |
' +
+ '
' +
+ '
' +
'';
heading.parentElement.insertBefore(center, heading.nextSibling);
center.querySelector("[data-aw-ru-refresh-dlp]").addEventListener("click", function () {
diff --git a/aw-server/dlp-case-management/case-service.service b/aw-server/dlp-case-management/case-service.service
new file mode 100644
index 0000000..054e7be
--- /dev/null
+++ b/aw-server/dlp-case-management/case-service.service
@@ -0,0 +1,16 @@
+[Unit]
+Description=AWatch DLP Case Management API
+After=network-online.target
+
+[Service]
+Type=simple
+EnvironmentFile=-/etc/activitywatch/aw-server.env
+WorkingDirectory=/opt/activitywatch/dlp-case-management
+ExecStart=/opt/activitywatch/dlp-case-management/.venv/bin/uvicorn case_service:APP --host ${AW_DLP_CASE_BIND_HOST} --port ${AW_DLP_CASE_PORT}
+Restart=on-failure
+RestartSec=3
+User=activitywatch
+Group=activitywatch
+
+[Install]
+WantedBy=multi-user.target
diff --git a/aw-server/dlp-case-management/case_schema.py b/aw-server/dlp-case-management/case_schema.py
new file mode 100644
index 0000000..0dc2eba
--- /dev/null
+++ b/aw-server/dlp-case-management/case_schema.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+CaseStatus = Literal["open", "investigating", "resolved", "closed"]
+
+
+class CaseCreate(BaseModel):
+ incident_id: str = Field(min_length=1, max_length=256)
+ host: str | None = Field(default=None, max_length=128)
+ title: str = Field(min_length=1, max_length=512)
+ severity: str = Field(default="medium", max_length=32)
+ assignee: str | None = Field(default=None, max_length=128)
+ source_bucket: str | None = Field(default=None, max_length=256)
+ source_event_ts: str | None = Field(default=None, max_length=64)
+ evidence: dict | None = None
+
+
+class CaseUpdate(BaseModel):
+ status: CaseStatus | None = None
+ assignee: str | None = Field(default=None, max_length=128)
+ title: str | None = Field(default=None, max_length=512)
+ severity: str | None = Field(default=None, max_length=32)
+
+
+class CaseCommentCreate(BaseModel):
+ comment: str = Field(min_length=1, max_length=2000)
+ author: str | None = Field(default=None, max_length=128)
+
+
+class CaseComment(BaseModel):
+ id: int
+ case_id: int
+ comment: str
+ author: str | None
+ created_at: datetime
+
+
+class CaseRecord(BaseModel):
+ id: int
+ incident_id: str
+ host: str | None
+ title: str
+ severity: str
+ assignee: str | None
+ status: CaseStatus
+ source_bucket: str | None
+ source_event_ts: str | None
+ evidence: dict | None
+ created_at: datetime
+ updated_at: datetime
+
diff --git a/aw-server/dlp-case-management/case_service.py b/aw-server/dlp-case-management/case_service.py
index 61a370f..f01927f 100644
--- a/aw-server/dlp-case-management/case_service.py
+++ b/aw-server/dlp-case-management/case_service.py
@@ -1,53 +1,78 @@
#!/usr/bin/env python3
from __future__ import annotations
-import sqlite3
+import os
from pathlib import Path
from typing import Any
-from fastapi import FastAPI
-from pydantic import BaseModel
+from fastapi import FastAPI, HTTPException, Query
+from fastapi.middleware.cors import CORSMiddleware
-DB = Path("/opt/activitywatch/dlp-case-management/cases.db")
+from case_schema import CaseCommentCreate, CaseCreate, CaseUpdate
+from case_storage import CaseStorage
+
+DB = Path(os.environ.get("AW_DLP_CASE_DB_PATH", "/opt/activitywatch/dlp-case-management/cases.db"))
APP = FastAPI(title="AWatch DLP Case Management")
+APP.add_middleware(
+ CORSMiddleware,
+ allow_origins=["http://127.0.0.1:5600", "http://localhost:5600", "http://10.10.10.13:5600", "*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+STORE = CaseStorage(DB)
-class CaseCreate(BaseModel):
- incident_id: str
- title: str
- severity: str = "medium"
- assignee: str | None = None
-
-
-def _conn() -> sqlite3.Connection:
- DB.parent.mkdir(parents=True, exist_ok=True)
- c = sqlite3.connect(DB)
- c.execute(
- "CREATE TABLE IF NOT EXISTS cases (id INTEGER PRIMARY KEY, incident_id TEXT, title TEXT, severity TEXT, assignee TEXT, status TEXT DEFAULT 'open')"
- )
- return c
+@APP.get("/health")
+def health() -> dict[str, Any]:
+ return {"ok": True, "db": str(DB)}
@APP.post("/api/0/dlp/cases")
def create_case(payload: CaseCreate) -> dict[str, Any]:
- c = _conn()
- cur = c.cursor()
- cur.execute(
- "INSERT INTO cases (incident_id,title,severity,assignee,status) VALUES (?,?,?,?,?)",
- (payload.incident_id, payload.title, payload.severity, payload.assignee, "open"),
- )
- c.commit()
- case_id = cur.lastrowid
- c.close()
- return {"id": case_id}
+ return STORE.create_case(payload.model_dump(exclude_none=True), actor="api")
@APP.get("/api/0/dlp/cases")
-def list_cases() -> list[dict[str, Any]]:
- c = _conn()
- rows = c.execute("SELECT id,incident_id,title,severity,assignee,status FROM cases ORDER BY id DESC").fetchall()
- c.close()
- return [
- {"id": r[0], "incident_id": r[1], "title": r[2], "severity": r[3], "assignee": r[4], "status": r[5]}
- for r in rows
- ]
+def list_cases(
+ status: str | None = Query(default=None),
+ host: str | None = Query(default=None),
+ limit: int = Query(default=200, ge=1, le=2000),
+) -> list[dict[str, Any]]:
+ return STORE.list_cases(status=status, host=host, limit=limit)
+
+
+@APP.get("/api/0/dlp/cases/{case_id}")
+def get_case(case_id: int) -> dict[str, Any]:
+ try:
+ case = STORE.get_case(case_id)
+ except KeyError:
+ raise HTTPException(status_code=404, detail="case not found")
+ case["comments"] = STORE.list_comments(case_id, limit=200)
+ case["audit"] = STORE.list_audit(case_id, limit=200)
+ return case
+
+
+@APP.patch("/api/0/dlp/cases/{case_id}")
+def update_case(case_id: int, payload: CaseUpdate) -> dict[str, Any]:
+ patch = payload.model_dump(exclude_none=True)
+ if not patch:
+ return STORE.get_case(case_id)
+ try:
+ return STORE.update_case(case_id, patch=patch, actor="api")
+ except KeyError:
+ raise HTTPException(status_code=404, detail="case not found")
+
+
+@APP.post("/api/0/dlp/cases/{case_id}/comments")
+def add_comment(case_id: int, payload: CaseCommentCreate) -> dict[str, Any]:
+ try:
+ STORE.get_case(case_id)
+ except KeyError:
+ raise HTTPException(status_code=404, detail="case not found")
+ return STORE.add_comment(case_id=case_id, comment=payload.comment, author=payload.author)
+
+
+@APP.get("/api/0/dlp/cases/{case_id}/comments")
+def list_comments(case_id: int, limit: int = Query(default=200, ge=1, le=2000)) -> list[dict[str, Any]]:
+ return STORE.list_comments(case_id=case_id, limit=limit)
diff --git a/aw-server/dlp-case-management/case_storage.py b/aw-server/dlp-case-management/case_storage.py
new file mode 100644
index 0000000..5fab597
--- /dev/null
+++ b/aw-server/dlp-case-management/case_storage.py
@@ -0,0 +1,255 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import json
+import sqlite3
+from contextlib import contextmanager
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Iterator
+
+
+class CaseStorage:
+ def __init__(self, db_path: Path) -> None:
+ self.db_path = db_path
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
+ self._init_db()
+
+ @contextmanager
+ def conn(self) -> Iterator[sqlite3.Connection]:
+ c = sqlite3.connect(self.db_path)
+ c.row_factory = sqlite3.Row
+ c.execute("PRAGMA journal_mode=WAL")
+ c.execute("PRAGMA foreign_keys=ON")
+ try:
+ yield c
+ finally:
+ c.close()
+
+ def _init_db(self) -> None:
+ with self.conn() as c:
+ c.executescript(
+ """
+ CREATE TABLE IF NOT EXISTS cases (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ incident_id TEXT NOT NULL,
+ host TEXT,
+ title TEXT NOT NULL,
+ severity TEXT NOT NULL DEFAULT 'medium',
+ assignee TEXT,
+ status TEXT NOT NULL DEFAULT 'open',
+ source_bucket TEXT,
+ source_event_ts TEXT,
+ evidence_json TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_cases_incident_id ON cases(incident_id);
+ CREATE INDEX IF NOT EXISTS idx_cases_status ON cases(status);
+
+ CREATE TABLE IF NOT EXISTS case_comments (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ case_id INTEGER NOT NULL,
+ comment TEXT NOT NULL,
+ author TEXT,
+ created_at TEXT NOT NULL,
+ FOREIGN KEY(case_id) REFERENCES cases(id) ON DELETE CASCADE
+ );
+
+ CREATE TABLE IF NOT EXISTS case_audit (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ case_id INTEGER NOT NULL,
+ action TEXT NOT NULL,
+ actor TEXT,
+ details_json TEXT,
+ created_at TEXT NOT NULL,
+ FOREIGN KEY(case_id) REFERENCES cases(id) ON DELETE CASCADE
+ );
+ """
+ )
+ c.commit()
+
+ @staticmethod
+ def _now() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+ @staticmethod
+ def _to_case_dict(row: sqlite3.Row) -> dict[str, Any]:
+ evidence = None
+ if row["evidence_json"]:
+ try:
+ evidence = json.loads(row["evidence_json"])
+ except Exception:
+ evidence = None
+ return {
+ "id": int(row["id"]),
+ "incident_id": row["incident_id"],
+ "host": row["host"],
+ "title": row["title"],
+ "severity": row["severity"],
+ "assignee": row["assignee"],
+ "status": row["status"],
+ "source_bucket": row["source_bucket"],
+ "source_event_ts": row["source_event_ts"],
+ "evidence": evidence,
+ "created_at": row["created_at"],
+ "updated_at": row["updated_at"],
+ }
+
+ def create_case(self, payload: dict[str, Any], actor: str | None = None) -> dict[str, Any]:
+ now = self._now()
+ with self.conn() as c:
+ cur = c.execute(
+ """
+ INSERT INTO cases (
+ incident_id, host, title, severity, assignee, status,
+ source_bucket, source_event_ts, evidence_json, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?)
+ """,
+ (
+ payload["incident_id"],
+ payload.get("host"),
+ payload["title"],
+ payload.get("severity", "medium"),
+ payload.get("assignee"),
+ payload.get("source_bucket"),
+ payload.get("source_event_ts"),
+ json.dumps(payload.get("evidence"), ensure_ascii=False) if payload.get("evidence") is not None else None,
+ now,
+ now,
+ ),
+ )
+ case_id = int(cur.lastrowid)
+ self._insert_audit(
+ c,
+ case_id=case_id,
+ action="create",
+ actor=actor,
+ details={"fields": {k: v for k, v in payload.items() if k != "evidence"}},
+ )
+ c.commit()
+ return self.get_case(case_id, c)
+
+ def list_cases(self, status: str | None = None, host: str | None = None, limit: int = 200) -> list[dict[str, Any]]:
+ q = "SELECT * FROM cases"
+ clauses = []
+ args: list[Any] = []
+ if status:
+ clauses.append("status = ?")
+ args.append(status)
+ if host:
+ clauses.append("host = ?")
+ args.append(host)
+ if clauses:
+ q += " WHERE " + " AND ".join(clauses)
+ q += " ORDER BY id DESC LIMIT ?"
+ args.append(int(limit))
+ with self.conn() as c:
+ rows = c.execute(q, args).fetchall()
+ return [self._to_case_dict(r) for r in rows]
+
+ def get_case(self, case_id: int, c: sqlite3.Connection | None = None) -> dict[str, Any]:
+ own = False
+ if c is None:
+ own = True
+ c = sqlite3.connect(self.db_path)
+ c.row_factory = sqlite3.Row
+ try:
+ row = c.execute("SELECT * FROM cases WHERE id = ?", (int(case_id),)).fetchone()
+ if not row:
+ raise KeyError(case_id)
+ return self._to_case_dict(row)
+ finally:
+ if own:
+ c.close()
+
+ def update_case(self, case_id: int, patch: dict[str, Any], actor: str | None = None) -> dict[str, Any]:
+ fields = []
+ args: list[Any] = []
+ for key in ("status", "assignee", "title", "severity"):
+ if key in patch and patch[key] is not None:
+ fields.append(f"{key} = ?")
+ args.append(patch[key])
+ if not fields:
+ return self.get_case(case_id)
+ fields.append("updated_at = ?")
+ args.append(self._now())
+ args.append(int(case_id))
+ with self.conn() as c:
+ c.execute(f"UPDATE cases SET {', '.join(fields)} WHERE id = ?", args)
+ self._insert_audit(c, case_id=case_id, action="update", actor=actor, details=patch)
+ c.commit()
+ return self.get_case(case_id, c)
+
+ def add_comment(self, case_id: int, comment: str, author: str | None = None) -> dict[str, Any]:
+ now = self._now()
+ with self.conn() as c:
+ cur = c.execute(
+ "INSERT INTO case_comments (case_id, comment, author, created_at) VALUES (?, ?, ?, ?)",
+ (int(case_id), comment, author, now),
+ )
+ cid = int(cur.lastrowid)
+ self._insert_audit(
+ c,
+ case_id=case_id,
+ action="comment",
+ actor=author,
+ details={"comment_id": cid},
+ )
+ c.commit()
+ row = c.execute("SELECT id, case_id, comment, author, created_at FROM case_comments WHERE id = ?", (cid,)).fetchone()
+ return dict(row)
+
+ def list_comments(self, case_id: int, limit: int = 200) -> list[dict[str, Any]]:
+ with self.conn() as c:
+ rows = c.execute(
+ "SELECT id, case_id, comment, author, created_at FROM case_comments WHERE case_id = ? ORDER BY id DESC LIMIT ?",
+ (int(case_id), int(limit)),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def list_audit(self, case_id: int, limit: int = 200) -> list[dict[str, Any]]:
+ with self.conn() as c:
+ rows = c.execute(
+ "SELECT id, case_id, action, actor, details_json, created_at FROM case_audit WHERE case_id = ? ORDER BY id DESC LIMIT ?",
+ (int(case_id), int(limit)),
+ ).fetchall()
+ out: list[dict[str, Any]] = []
+ for r in rows:
+ details = None
+ if r["details_json"]:
+ try:
+ details = json.loads(r["details_json"])
+ except Exception:
+ details = None
+ out.append(
+ {
+ "id": int(r["id"]),
+ "case_id": int(r["case_id"]),
+ "action": r["action"],
+ "actor": r["actor"],
+ "details": details,
+ "created_at": r["created_at"],
+ }
+ )
+ return out
+
+ def _insert_audit(
+ self,
+ c: sqlite3.Connection,
+ case_id: int,
+ action: str,
+ actor: str | None,
+ details: dict[str, Any] | None = None,
+ ) -> None:
+ c.execute(
+ "INSERT INTO case_audit (case_id, action, actor, details_json, created_at) VALUES (?, ?, ?, ?, ?)",
+ (
+ int(case_id),
+ action,
+ actor,
+ json.dumps(details, ensure_ascii=False) if details is not None else None,
+ self._now(),
+ ),
+ )
+
diff --git a/aw-server/dlp-case-management/requirements.txt b/aw-server/dlp-case-management/requirements.txt
new file mode 100644
index 0000000..e49493b
--- /dev/null
+++ b/aw-server/dlp-case-management/requirements.txt
@@ -0,0 +1,3 @@
+fastapi>=0.115.0
+uvicorn>=0.30.0
+pydantic>=2.7.0