feat(dlp): implement case management service and DLP Review integration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user