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 @@ 'Загрузка...' + '' + '' + + '
' + + '
' + + '
Case Management
' + + '
Кейсов: 0
' + + '
' + + '' + + '' + + '' + + '
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