From 8de5d14cfb3ab1422707e8a7d0db466abeb5e526 Mon Sep 17 00:00:00 2001 From: PTah Date: Mon, 1 Jun 2026 09:55:12 +1000 Subject: [PATCH] feat: SAC 0.6.0 user edit UI, login to dashboard, daily report timer sync Co-authored-by: Cursor --- backend/app/api/v1/users.py | 15 ++ backend/app/version.py | 2 +- backend/tests/test_health.py | 4 +- backend/tests/test_users_auth.py | 72 ++++++ deploy/README.md | 4 + deploy/sac-deploy.sh | 18 ++ .../systemd/render-sac-daily-report-timer.sh | 44 ++++ deploy/systemd/sac-daily-report.timer | 6 +- docs/pilot-2.2-heartbeat-reports.md | 6 +- docs/runbook-ops.md | 4 +- docs/testing-e2e-checklist.md | 2 +- frontend/package.json | 2 +- frontend/src/api.ts | 57 ++++- frontend/src/version.ts | 2 +- frontend/src/views/HostsView.vue | 50 +++- frontend/src/views/LoginView.vue | 2 +- frontend/src/views/UsersView.vue | 215 ++++++++++++++---- 17 files changed, 449 insertions(+), 56 deletions(-) create mode 100644 deploy/systemd/render-sac-daily-report-timer.sh diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index beabf1a..215f763 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -32,6 +32,7 @@ class CreateUserRequest(BaseModel): class UpdateUserRequest(BaseModel): + username: str | None = Field(default=None, min_length=2, max_length=64) role: str | None = None password: str | None = Field(default=None, min_length=8, max_length=128) is_active: bool | None = None @@ -91,6 +92,20 @@ def update_user( if user is None: raise HTTPException(status_code=404, detail="User not found") + if body.username is not None: + normalized = body.username.strip() + if not normalized: + raise HTTPException(status_code=422, detail="username is required") + existing = db.scalar( + select(User).where( + func.lower(User.username) == normalized.lower(), + User.id != user.id, + ) + ) + if existing is not None: + raise HTTPException(status_code=400, detail="username already exists") + user.username = normalized + if body.role is not None: if body.role not in USER_ROLES: raise HTTPException(status_code=422, detail=f"role must be one of: {', '.join(sorted(USER_ROLES))}") diff --git a/backend/app/version.py b/backend/app/version.py index cca63d8..54c9262 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,5 +1,5 @@ """Единый источник версии SAC (API, health, логи, OpenAPI).""" APP_NAME = "Security Alert Center" -APP_VERSION = "0.5.0" +APP_VERSION = "0.6.0" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 2b33936..b4ec9f4 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL def test_version_constants(): - assert APP_VERSION == "0.5.0" + assert APP_VERSION == "0.6.0" assert APP_NAME == "Security Alert Center" - assert APP_VERSION_LABEL == "Security Alert Center v.0.5.0" + assert APP_VERSION_LABEL == "Security Alert Center v.0.6.0" diff --git a/backend/tests/test_users_auth.py b/backend/tests/test_users_auth.py index ff31f7d..fe11da9 100644 --- a/backend/tests/test_users_auth.py +++ b/backend/tests/test_users_auth.py @@ -62,3 +62,75 @@ def test_admin_create_monitor_user(client, jwt_headers): def test_monitor_cannot_list_users(client, jwt_monitor_headers): response = client.get("/api/v1/users", headers=jwt_monitor_headers) assert response.status_code == 403 + + +def test_admin_update_user_role_and_password(client, jwt_headers): + create = client.post( + "/api/v1/users", + headers=jwt_headers, + json={"username": "patch-me", "password": "longpassword1", "role": "monitor"}, + ) + assert create.status_code == 201 + user_id = create.json()["id"] + + response = client.patch( + f"/api/v1/users/{user_id}", + headers=jwt_headers, + json={"role": "admin", "password": "newpassword9"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["role"] == "admin" + + login = client.post( + "/api/v1/auth/login", + json={"username": "patch-me", "password": "newpassword9"}, + ) + assert login.status_code == 200 + assert login.json()["role"] == "admin" + + +def test_admin_update_username(client, jwt_headers): + create = client.post( + "/api/v1/users", + headers=jwt_headers, + json={"username": "old-name", "password": "longpassword1", "role": "monitor"}, + ) + user_id = create.json()["id"] + + response = client.patch( + f"/api/v1/users/{user_id}", + headers=jwt_headers, + json={"username": "new-name"}, + ) + assert response.status_code == 200 + assert response.json()["username"] == "new-name" + + login = client.post( + "/api/v1/auth/login", + json={"username": "new-name", "password": "longpassword1"}, + ) + assert login.status_code == 200 + + +def test_admin_deactivate_user(client, jwt_headers): + create = client.post( + "/api/v1/users", + headers=jwt_headers, + json={"username": "to-disable", "password": "longpassword1", "role": "monitor"}, + ) + user_id = create.json()["id"] + + response = client.patch( + f"/api/v1/users/{user_id}", + headers=jwt_headers, + json={"is_active": False}, + ) + assert response.status_code == 200 + assert response.json()["is_active"] is False + + login = client.post( + "/api/v1/auth/login", + json={"username": "to-disable", "password": "longpassword1"}, + ) + assert login.status_code == 401 diff --git a/deploy/README.md b/deploy/README.md index 8e1b93b..204fdc6 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -15,6 +15,10 @@ sudo chmod 755 /opt/sac-deploy.sh sudo /opt/sac-deploy.sh ``` +Суточные отчёты: `sac-daily-report.service` + timer. Файл `sac-daily-report.timer` в git — шаблон; +на сервере timer **генерируется** из `SAC_DAILY_REPORT_HOUR` / `SAC_DAILY_REPORT_TIMEZONE` +(`deploy/systemd/render-sac-daily-report-timer.sh`, вызывается из `sac-deploy.sh`). + ## Альтернатива: Docker Только для стенда. См. [docs/install-ubuntu-24.04-docker.md](../docs/install-ubuntu-24.04-docker.md). diff --git a/deploy/sac-deploy.sh b/deploy/sac-deploy.sh index 8f46a56..9a59b46 100644 --- a/deploy/sac-deploy.sh +++ b/deploy/sac-deploy.sh @@ -122,10 +122,28 @@ else die "${SERVICE_NAME} запущен, но /health не ответил — journalctl -u ${SERVICE_NAME} -n 80 --no-pager" fi +RENDER_DAILY_TIMER="${APP_ROOT}/deploy/systemd/render-sac-daily-report-timer.sh" +if [ -f "${RENDER_DAILY_TIMER}" ]; then + chmod +x "${RENDER_DAILY_TIMER}" 2>/dev/null || true +fi + for aux in sac-retention sac-daily-report; do for suffix in service timer; do src="${APP_ROOT}/deploy/systemd/${aux}.${suffix}" dst="/etc/systemd/system/${aux}.${suffix}" + if [ "${aux}" = "sac-daily-report" ] && [ "${suffix}" = "timer" ]; then + if [ -x "${RENDER_DAILY_TIMER}" ]; then + log "Генерация ${dst} из ${CONFIG_FILE} (SAC_DAILY_REPORT_HOUR/TIMEZONE)" + bash "${RENDER_DAILY_TIMER}" "${CONFIG_FILE}" "${dst}" + systemctl daemon-reload + systemctl restart "${aux}.timer" 2>/dev/null || systemctl start "${aux}.timer" 2>/dev/null || true + elif [ -f "${src}" ]; then + log "WARN: ${RENDER_DAILY_TIMER} не найден — копируем статический timer" + cp "${src}" "${dst}" + systemctl daemon-reload + fi + continue + fi if [ -f "${src}" ]; then if [ ! -f "${dst}" ] || ! cmp -s "${src}" "${dst}"; then log "Обновление ${dst}" diff --git a/deploy/systemd/render-sac-daily-report-timer.sh b/deploy/systemd/render-sac-daily-report-timer.sh new file mode 100644 index 0000000..cdd06db --- /dev/null +++ b/deploy/systemd/render-sac-daily-report-timer.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Генерирует sac-daily-report.timer из SAC_DAILY_REPORT_HOUR и SAC_DAILY_REPORT_TIMEZONE. +# Usage: sudo deploy/systemd/render-sac-daily-report-timer.sh [config.env] [output.timer] + +set -euo pipefail + +CONFIG_FILE="${1:-/opt/security-alert-center/config/sac-api.env}" +OUTPUT="${2:-/etc/systemd/system/sac-daily-report.timer}" + +HOUR=9 +TZ_NAME="Europe/Moscow" + +if [ -f "${CONFIG_FILE}" ]; then + set -a + # shellcheck source=/dev/null + source "${CONFIG_FILE}" + set +a +fi + +HOUR="${SAC_DAILY_REPORT_HOUR:-9}" +TZ_NAME="${SAC_DAILY_REPORT_TIMEZONE:-Europe/Moscow}" + +if ! [[ "${HOUR}" =~ ^[0-9]+$ ]] || [ "${HOUR}" -lt 0 ] || [ "${HOUR}" -gt 23 ]; then + printf 'render-sac-daily-report-timer: invalid SAC_DAILY_REPORT_HOUR=%s\n' "${HOUR}" >&2 + exit 1 +fi + +HOUR_PADDED="$(printf '%02d' "${HOUR}")" + +cat >"${OUTPUT}" <(path: string, init: RequestInit = {}): Promise } if (!res.ok) { const text = await res.text(); - throw new Error(text || res.statusText); + throw new Error(parseApiError(text) || res.statusText); } return res.json() as Promise; } @@ -72,6 +72,61 @@ export function fetchMe(): Promise { return apiFetch("/api/v1/auth/me"); } +export function parseApiError(text: string): string { + if (!text) return ""; + try { + const data = JSON.parse(text) as { detail?: string | Array<{ msg?: string }> }; + if (typeof data.detail === "string") return data.detail; + if (Array.isArray(data.detail)) { + return data.detail.map((item) => item.msg ?? String(item)).join("; "); + } + } catch { + /* plain text */ + } + return text; +} + +export interface UserSummary { + id: number; + username: string; + role: string; + is_active: boolean; + created_at: string; +} + +export interface UserListResponse { + items: UserSummary[]; +} + +export interface UpdateUserPayload { + username?: string; + role?: string; + password?: string; + is_active?: boolean; +} + +export function fetchUsers(): Promise { + return apiFetch("/api/v1/users"); +} + +export function createUser(payload: { + username: string; + password: string; + role: string; +}): Promise { + return apiFetch("/api/v1/users", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function updateUser(userId: number, payload: UpdateUserPayload): Promise { + return apiFetch(`/api/v1/users/${userId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + export interface EventSummary { id: number; event_id: string; diff --git a/frontend/src/version.ts b/frontend/src/version.ts index bab8e63..4960971 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,3 +1,3 @@ export const APP_NAME = "Security Alert Center"; -export const APP_VERSION = "0.5.0"; +export const APP_VERSION = "0.6.0"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; diff --git a/frontend/src/views/HostsView.vue b/frontend/src/views/HostsView.vue index 3a34b8c..cd23968 100644 --- a/frontend/src/views/HostsView.vue +++ b/frontend/src/views/HostsView.vue @@ -84,8 +84,47 @@ type SortKey = | "last_seen_at" | "event_count" | "last_daily_report_at"; -const sortBy = ref("last_seen_at"); -const sortDir = ref<"asc" | "desc">("desc"); + +const HOSTS_SORT_KEYS: SortKey[] = [ + "id", + "display_name", + "hostname", + "product", + "os_family", + "ipv4", + "last_seen_at", + "event_count", + "last_daily_report_at", +]; + +const HOSTS_SORT_STORAGE_KEY = "sac_hosts_sort"; + +function loadHostsSortPrefs(): { sortBy: SortKey; sortDir: "asc" | "desc" } { + const fallback = { sortBy: "last_seen_at" as SortKey, sortDir: "desc" as const }; + try { + const raw = localStorage.getItem(HOSTS_SORT_STORAGE_KEY); + if (!raw) return fallback; + const parsed = JSON.parse(raw) as { sortBy?: string; sortDir?: string }; + if ( + parsed.sortBy && + HOSTS_SORT_KEYS.includes(parsed.sortBy as SortKey) && + (parsed.sortDir === "asc" || parsed.sortDir === "desc") + ) { + return { sortBy: parsed.sortBy as SortKey, sortDir: parsed.sortDir }; + } + } catch { + /* ignore corrupt storage */ + } + return fallback; +} + +function saveHostsSortPrefs(sortBy: SortKey, sortDir: "asc" | "desc") { + localStorage.setItem(HOSTS_SORT_STORAGE_KEY, JSON.stringify({ sortBy, sortDir })); +} + +const initialSort = loadHostsSortPrefs(); +const sortBy = ref(initialSort.sortBy); +const sortDir = ref<"asc" | "desc">(initialSort.sortDir); const deletingId = ref(null); function formatDt(iso: string) { @@ -101,10 +140,11 @@ function agentLabel(status: string) { function setSort(key: SortKey) { if (sortBy.value === key) { sortDir.value = sortDir.value === "asc" ? "desc" : "asc"; - return; + } else { + sortBy.value = key; + sortDir.value = key === "id" || key === "last_seen_at" || key === "event_count" ? "desc" : "asc"; } - sortBy.value = key; - sortDir.value = key === "id" || key === "last_seen_at" || key === "event_count" ? "desc" : "asc"; + saveHostsSortPrefs(sortBy.value, sortDir.value); } function sortMark(key: SortKey) { diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 07bc2ea..1579e58 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -42,7 +42,7 @@ async function submit() { body: JSON.stringify({ username: username.value, password: password.value }), }); setAuth(data.access_token, data.role); - await router.push("/events"); + await router.push("/dashboard"); } catch (e) { error.value = e instanceof Error ? e.message : "Ошибка входа"; } finally { diff --git a/frontend/src/views/UsersView.vue b/frontend/src/views/UsersView.vue index 66a4e2b..2b59429 100644 --- a/frontend/src/views/UsersView.vue +++ b/frontend/src/views/UsersView.vue @@ -8,16 +8,23 @@

{{ error }}

{{ success }}

-
-

Новый пользователь

-
+
+

{{ editingUser ? `Редактирование: ${editingUser.username}` : "Новый пользователь" }}

+ - + +
+ + +
@@ -40,14 +64,18 @@ Роль Статус Создан + Действия - + {{ u.username }} {{ u.role }} {{ u.is_active ? "активен" : "отключён" }} {{ formatDate(u.created_at) }} + + + @@ -56,31 +84,26 @@