feat(dlp): implement case management service and DLP Review integration

This commit is contained in:
igor04091968
2026-05-13 03:08:53 +03:00
parent bc44052391
commit 28a4dae67f
11 changed files with 616 additions and 36 deletions
+63
View File
@@ -305,6 +305,9 @@
AW_DLP_POLICY_ENGINE_BIND_HOST={{ aw_dlp_policy_engine_bind_host }} 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_PORT={{ aw_dlp_policy_engine_port }}
AW_DLP_POLICY_ENGINE_DB_PATH={{ aw_dlp_policy_engine_db_path }} 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_DATA_HOME={{ aw_server_data_dir }}/.local/share
XDG_CONFIG_HOME={{ aw_server_data_dir }}/.config XDG_CONFIG_HOME={{ aw_server_data_dir }}/.config
@@ -478,6 +481,59 @@
mode: "0644" mode: "0644"
when: aw_dlp_integrations_enabled | default(true) | bool 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 - name: Установить скрипт AW worktime API
ansible.builtin.copy: ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.py" src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.py"
@@ -575,6 +631,13 @@
state: restarted state: restarted
when: aw_dlp_integrations_enabled | default(true) | bool 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 - name: Включить и перезапустить AW worktime API
ansible.builtin.systemd: ansible.builtin.systemd:
name: aw-worktime-api.service name: aw-worktime-api.service
+1
View File
@@ -10,3 +10,4 @@
- role: dlp-policy-engine - role: dlp-policy-engine
- role: dlp-content-analysis - role: dlp-content-analysis
- role: dlp-integrations - role: dlp-integrations
- role: dlp-case-management
+4
View File
@@ -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_policy_engine_db_path: "{{ aw_server_data_dir }}/dlp-policy-engine.sqlite"
aw_dlp_content_analysis_enabled: true aw_dlp_content_analysis_enabled: true
aw_dlp_integrations_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_server_post_deploy_health_check_enabled: true
+4
View File
@@ -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_policy_engine_db_path: "{{ aw_server_data_dir }}/dlp-policy-engine.sqlite"
aw_dlp_content_analysis_enabled: true aw_dlp_content_analysis_enabled: true
aw_dlp_integrations_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_server_post_deploy_health_check_enabled: true
aw_worktime_from: "08:00" aw_worktime_from: "08:00"
@@ -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
+99
View File
@@ -958,6 +958,7 @@
'<td class="aw-ru-dlp-actions">' + '<td class="aw-ru-dlp-actions">' +
'<button type="button" data-aw-ru-save-review>Сохранить</button>' + '<button type="button" data-aw-ru-save-review>Сохранить</button>' +
'<button type="button" data-aw-ru-save-rule>Правило</button>' + '<button type="button" data-aw-ru-save-rule>Правило</button>' +
'<button type="button" data-aw-ru-create-case>Кейс</button>' +
"</td>" + "</td>" +
"</tr>" "</tr>"
); );
@@ -1028,6 +1029,55 @@
}, 1); }, 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) { async function saveDlpRule(host, event, row) {
const bucketId = "aw-dlp-rules_" + host; const bucketId = "aw-dlp-rules_" + host;
await ensureAwBucket(bucketId, "aw-dlp-rules", "aw.dlp.rule", host); await ensureAwBucket(bucketId, "aw-dlp-rules", "aw.dlp.rule", host);
@@ -1081,9 +1131,47 @@
message.textContent = "Ошибка сохранения правила: " + error.message; 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 (
"<tr>" +
"<td>" + escapeHtml(String(c.id || "")) + "</td>" +
"<td>" + escapeHtml(String(c.status || "")) + "</td>" +
"<td>" + escapeHtml(String(c.severity || "")) + "</td>" +
"<td>" + escapeHtml(String(c.title || "")) + "</td>" +
"<td>" + escapeHtml(String(c.assignee || "")) + "</td>" +
"<td>" + escapeHtml(String(c.incident_id || "")) + "</td>" +
"<td>" + escapeHtml(String(c.updated_at || c.created_at || "")) + "</td>" +
"</tr>"
);
});
tbody.innerHTML = rows.length ? rows.join("") : '<tr><td colspan="7">Кейсов нет.</td></tr>';
const status = center.querySelector("[data-aw-ru-dlp-cases-status]");
if (status) status.textContent = "Кейсов: " + (cases || []).length;
} catch (error) {
tbody.innerHTML = '<tr><td colspan="7">Ошибка загрузки кейсов: ' + escapeHtml(error.message) + '</td></tr>';
const status = center.querySelector("[data-aw-ru-dlp-cases-status]");
if (status) status.textContent = "Кейсы недоступны";
}
}
async function setDlpRuleEnabled(host, ruleEvent, enabled) { async function setDlpRuleEnabled(host, ruleEvent, enabled) {
const bucketId = "aw-dlp-rules_" + host; const bucketId = "aw-dlp-rules_" + host;
await ensureAwBucket(bucketId, "aw-dlp-rules", "aw.dlp.rule", host); await ensureAwBucket(bucketId, "aw-dlp-rules", "aw.dlp.rule", host);
@@ -1238,6 +1326,7 @@
state.reviews = []; state.reviews = [];
} }
renderDlpTableRows(center, host); renderDlpTableRows(center, host);
await renderCaseManager(center, host);
center.querySelector("[data-aw-ru-dlp-message]").textContent = "DLP review центр обновлен."; center.querySelector("[data-aw-ru-dlp-message]").textContent = "DLP review центр обновлен.";
} catch (error) { } catch (error) {
center.querySelector("[data-aw-ru-dlp-message]").textContent = "Ошибка загрузки DLP-событий: " + error.message; center.querySelector("[data-aw-ru-dlp-message]").textContent = "Ошибка загрузки DLP-событий: " + error.message;
@@ -1293,6 +1382,16 @@
'<tbody data-aw-ru-dlp-reviews><tr><td colspan="7">Загрузка...</td></tr></tbody>' + '<tbody data-aw-ru-dlp-reviews><tr><td colspan="7">Загрузка...</td></tr></tbody>' +
'</table>' + '</table>' +
'</div>' + '</div>' +
'<div class="aw-ru-dlp-section">' +
'<div class="aw-ru-dlp-toolbar">' +
'<h5>Case Management</h5>' +
'<div class="aw-ru-dlp-status" data-aw-ru-dlp-cases-status>Кейсов: 0</div>' +
'</div>' +
'<table class="aw-ru-dlp-table">' +
'<thead><tr><th>ID</th><th>Статус</th><th>Severity</th><th>Заголовок</th><th>Исполнитель</th><th>Incident ID</th><th>Обновлено</th></tr></thead>' +
'<tbody data-aw-ru-dlp-cases><tr><td colspan="7">Загрузка...</td></tr></tbody>' +
'</table>' +
'</div>' +
'<div class="aw-ru-dlp-message" data-aw-ru-dlp-message></div>'; '<div class="aw-ru-dlp-message" data-aw-ru-dlp-message></div>';
heading.parentElement.insertBefore(center, heading.nextSibling); heading.parentElement.insertBefore(center, heading.nextSibling);
center.querySelector("[data-aw-ru-refresh-dlp]").addEventListener("click", function () { center.querySelector("[data-aw-ru-refresh-dlp]").addEventListener("click", function () {
@@ -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
@@ -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
+61 -36
View File
@@ -1,53 +1,78 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations from __future__ import annotations
import sqlite3 import os
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from fastapi import FastAPI from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel 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 = 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): @APP.get("/health")
incident_id: str def health() -> dict[str, Any]:
title: str return {"ok": True, "db": str(DB)}
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.post("/api/0/dlp/cases") @APP.post("/api/0/dlp/cases")
def create_case(payload: CaseCreate) -> dict[str, Any]: def create_case(payload: CaseCreate) -> dict[str, Any]:
c = _conn() return STORE.create_case(payload.model_dump(exclude_none=True), actor="api")
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}
@APP.get("/api/0/dlp/cases") @APP.get("/api/0/dlp/cases")
def list_cases() -> list[dict[str, Any]]: def list_cases(
c = _conn() status: str | None = Query(default=None),
rows = c.execute("SELECT id,incident_id,title,severity,assignee,status FROM cases ORDER BY id DESC").fetchall() host: str | None = Query(default=None),
c.close() limit: int = Query(default=200, ge=1, le=2000),
return [ ) -> list[dict[str, Any]]:
{"id": r[0], "incident_id": r[1], "title": r[2], "severity": r[3], "assignee": r[4], "status": r[5]} return STORE.list_cases(status=status, host=host, limit=limit)
for r in rows
]
@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)
@@ -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(),
),
)
@@ -0,0 +1,3 @@
fastapi>=0.115.0
uvicorn>=0.30.0
pydantic>=2.7.0