From 80d82d36dd721d2208dd2590d8dc6bb817f83bef Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Thu, 28 May 2026 11:49:59 +0200 Subject: [PATCH] audit logging --- app/db/audit.py | 77 +++++++++++++++++++++++++++++++++++++++++ app/main.py | 3 +- app/routes/apps.py | 41 ++++++++++++++++++++++ app/routes/audit.py | 61 ++++++++++++++++++++++++++++++++ app/routes/auth.py | 16 +++++++++ app/routes/backups.py | 34 ++++++++++++++++++ app/templates/layout.py | 1 + 7 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 app/db/audit.py create mode 100644 app/routes/audit.py diff --git a/app/db/audit.py b/app/db/audit.py new file mode 100644 index 0000000..4b7a40d --- /dev/null +++ b/app/db/audit.py @@ -0,0 +1,77 @@ +import json +from typing import Any + +from app.db.database import get_connection + + +def log_audit_event( + user, + action: str, + target_type: str, + target_id=None, + source: str = "portal", + metadata: dict[str, Any] | None = None, +): + if user: + user_id = user.get("id") + username = user.get("username") or "system" + else: + user_id = None + username = "system" + source = "system" + + metadata_json = json.dumps(metadata or {}, ensure_ascii=False, sort_keys=True) + + con = get_connection() + con.execute( + """ + INSERT INTO audit_events ( + user_id, + username, + action, + target_type, + target_id, + source, + metadata, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, + ( + user_id, + username, + action, + target_type, + target_id, + source, + metadata_json, + ), + ) + con.commit() + con.close() + + +def get_audit_events(limit: int = 200): + con = get_connection() + + rows = con.execute( + """ + SELECT + id, + user_id, + username, + action, + target_type, + target_id, + source, + metadata, + created_at + FROM audit_events + ORDER BY id DESC + LIMIT ? + """, + (limit,), + ).fetchall() + + con.close() + return [dict(row) for row in rows] diff --git a/app/main.py b/app/main.py index a36118d..4ac1d1f 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from .config import read_env_value -from .routes import apps, auth, backups, deployments, health +from .routes import apps, audit, auth, backups, deployments, health def create_app() -> FastAPI: @@ -27,6 +27,7 @@ def create_app() -> FastAPI: app.include_router(apps.router) app.include_router(backups.router) app.include_router(deployments.router) + app.include_router(audit.router) return app diff --git a/app/routes/apps.py b/app/routes/apps.py index e74eadd..519e275 100644 --- a/app/routes/apps.py +++ b/app/routes/apps.py @@ -14,6 +14,7 @@ from ..config import ( read_env_value, ) from ..db.apps import get_apps, update_app_resources +from ..db.audit import log_audit_event from ..shell import run_command from ..templates.layout import page, render_result @@ -203,6 +204,20 @@ def create_app( deploy_result = run_command([DEPLOY_SCRIPT, app_id]) status = "OK" if create_result.returncode == 0 and deploy_result.returncode == 0 else "FAILED" + log_audit_event( + user, + action="create_app", + target_type="app", + target_id=app_id, + metadata={ + "app_id": app_id, + "app_name": app_name, + "template": template, + "status": status, + "create_returncode": create_result.returncode, + "deploy_returncode": deploy_result.returncode, + }, + ) return render_result( title=f"Vytvoření aplikace: {status}", @@ -223,6 +238,17 @@ def create_app( def delete_app(app_id: str = Form(...), user=Depends(require_user)): result = run_command([DELETE_APP_SCRIPT, app_id]) status = "OK" if result.returncode == 0 else "FAILED" + log_audit_event( + user, + action="delete_app", + target_type="app", + target_id=app_id, + metadata={ + "app_id": app_id, + "status": status, + "returncode": result.returncode, + }, + ) return render_result( title=f"Smazání aplikace: {status}", @@ -253,6 +279,21 @@ def update_resources( if catalog_result.returncode == 0 and compose_result.returncode == 0 and deploy_result.returncode == 0 else "FAILED" ) + log_audit_event( + user, + action="update_resources", + target_type="app", + target_id=app_id, + metadata={ + "app_id": app_id, + "memory": memory, + "cpus": cpus, + "status": status, + "catalog_returncode": catalog_result.returncode, + "compose_returncode": compose_result.returncode, + "deploy_returncode": deploy_result.returncode, + }, + ) return render_result( title=f"Úprava prostředků: {status}", diff --git a/app/routes/audit.py b/app/routes/audit.py new file mode 100644 index 0000000..ceb638b --- /dev/null +++ b/app/routes/audit.py @@ -0,0 +1,61 @@ +import html + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse + +from app.auth import require_user +from app.db.audit import get_audit_events +from app.templates.layout import page + +router = APIRouter() + + +@router.get("/audit", response_class=HTMLResponse) +def audit_page(request: Request, user=Depends(require_user)): + rows = "" + + for event in get_audit_events(): + metadata = event.get("metadata") or "{}" + metadata_preview = metadata if len(metadata) <= 180 else f"{metadata[:177]}..." + + rows += f""" + + {html.escape(event.get("created_at", "") or "")} + {html.escape(event.get("username", "") or "")} + {html.escape(event.get("action", "") or "")} + {html.escape(event.get("target_type", "") or "")} + {html.escape(str(event.get("target_id") or ""))} + {html.escape(event.get("source", "") or "")} + {html.escape(metadata_preview)} + + """ + + if not rows: + rows = 'Zatím nejsou evidované žádné auditní události.' + + return page( + "Audit", + f""" +
+

Audit

+

Přehled interních akcí provedených v portálu.

+
+ +
+

Události

+ + + + + + + + + + + {rows} +
ČasUživatelAkceTyp cíleCílZdrojMetadata
+
+ """, + user=user, + ) diff --git a/app/routes/auth.py b/app/routes/auth.py index e695e60..d8c59a1 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse from app.auth import authenticate_user, current_user +from app.db.audit import log_audit_event from app.templates.layout import page router = APIRouter() @@ -26,12 +27,27 @@ def login(request: Request, username: str = Form(...), password: str = Form(...) request.session.clear() request.session["user_id"] = user["id"] + log_audit_event( + user, + action="login_success", + target_type="user", + target_id=user.get("id"), + metadata={"username": user.get("username")}, + ) return RedirectResponse(url="/portal", status_code=303) @router.post("/logout") def logout(request: Request): + user = current_user(request) + log_audit_event( + user, + action="logout", + target_type="user", + target_id=user.get("id") if user else None, + metadata={"username": user.get("username")} if user else {}, + ) request.session.clear() return RedirectResponse(url="/portal/login", status_code=303) diff --git a/app/routes/backups.py b/app/routes/backups.py index 89b2f81..fe701b0 100644 --- a/app/routes/backups.py +++ b/app/routes/backups.py @@ -7,6 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse from ..auth import require_user from ..backups import is_backup_path, list_backups from ..config import BACKUP_SCRIPT +from ..db.audit import log_audit_event from ..shell import run_command from ..templates.layout import page, render_result @@ -44,6 +45,10 @@ def backups_page(request: Request, user=Depends(require_user)): +
+ + +
Obnova je nebezpečná a musí se spustit ručně přes SSH.
@@ -95,6 +100,15 @@ def backups_page(request: Request, user=Depends(require_user)): def create_backup(user=Depends(require_user)): result = run_command([BACKUP_SCRIPT]) status = "OK" if result.returncode == 0 else "FAILED" + log_audit_event( + user, + action="backup_create", + target_type="backup", + metadata={ + "status": status, + "returncode": result.returncode, + }, + ) return render_result( title=f"Vytvoření zálohy: {status}", @@ -116,3 +130,23 @@ def delete_backup(backup_path: str = Form(...), user=Depends(require_user)): target.unlink() return RedirectResponse(url="/portal/backups", status_code=303) + + +@router.post("/backups/restore-trigger") +def restore_trigger(backup_path: str = Form(...), user=Depends(require_user)): + target = Path(backup_path) + + if not is_backup_path(target): + return HTMLResponse("Neplatná cesta k záloze", status_code=400) + + log_audit_event( + user, + action="restore_trigger", + target_type="backup", + target_id=target.name, + metadata={ + "backup_path": str(target), + }, + ) + + return RedirectResponse(url="/portal/backups", status_code=303) diff --git a/app/templates/layout.py b/app/templates/layout.py index 169f76d..36ec8a2 100644 --- a/app/templates/layout.py +++ b/app/templates/layout.py @@ -16,6 +16,7 @@ def page(title: str, body: str, user=None) -> str: Nová aplikace Nasazení Zálohy + Audit """ user_panel = f"""