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