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
@@ -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
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)
@@ -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