audit logging

This commit is contained in:
JiriUhlir
2026-05-28 11:49:59 +02:00
parent f18aabb5ba
commit 80d82d36dd
7 changed files with 232 additions and 1 deletions
+77
View File
@@ -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]
+2 -1
View File
@@ -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
+41
View File
@@ -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}",
+61
View File
@@ -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"""
<tr>
<td>{html.escape(event.get("created_at", "") or "")}</td>
<td>{html.escape(event.get("username", "") or "")}</td>
<td><span class="pill pill-muted">{html.escape(event.get("action", "") or "")}</span></td>
<td>{html.escape(event.get("target_type", "") or "")}</td>
<td>{html.escape(str(event.get("target_id") or ""))}</td>
<td>{html.escape(event.get("source", "") or "")}</td>
<td><code>{html.escape(metadata_preview)}</code></td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="7">Zatím nejsou evidované žádné auditní události.</td></tr>'
return page(
"Audit",
f"""
<div class="card">
<h2>Audit</h2>
<p class="muted">Přehled interních akcí provedených v portálu.</p>
</div>
<div class="card">
<h2>Události</h2>
<table>
<tr>
<th>Čas</th>
<th>Uživatel</th>
<th>Akce</th>
<th>Typ cíle</th>
<th>Cíl</th>
<th>Zdroj</th>
<th>Metadata</th>
</tr>
{rows}
</table>
</div>
""",
user=user,
)
+16
View File
@@ -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)
+34
View File
@@ -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)):
<input readonly value="{restore_cmd}">
<button type="button" class="copy-button" data-copy-value="{restore_cmd}" onclick="copyText(this)">Kopírovat</button>
</div>
<form method="post" action="/portal/backups/restore-trigger">
<input type="hidden" name="backup_path" value="{backup_path}">
<button type="submit" class="btn-secondary">Evidovat obnovu</button>
</form>
<div class="muted">Obnova je nebezpečná a musí se spustit ručně přes SSH.</div>
</td>
<td class="actions-cell">
@@ -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)
+1
View File
@@ -16,6 +16,7 @@ def page(title: str, body: str, user=None) -> str:
<a href="/portal/new-app">Nová aplikace</a>
<a href="/portal/deployments">Nasazení</a>
<a href="/portal/backups">Zálohy</a>
<a href="/portal/audit">Audit</a>
</nav>
"""
user_panel = f"""