audit logging
This commit is contained in:
@@ -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
@@ -5,7 +5,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from .config import read_env_value
|
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:
|
def create_app() -> FastAPI:
|
||||||
@@ -27,6 +27,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(apps.router)
|
app.include_router(apps.router)
|
||||||
app.include_router(backups.router)
|
app.include_router(backups.router)
|
||||||
app.include_router(deployments.router)
|
app.include_router(deployments.router)
|
||||||
|
app.include_router(audit.router)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from ..config import (
|
|||||||
read_env_value,
|
read_env_value,
|
||||||
)
|
)
|
||||||
from ..db.apps import get_apps, update_app_resources
|
from ..db.apps import get_apps, update_app_resources
|
||||||
|
from ..db.audit import log_audit_event
|
||||||
from ..shell import run_command
|
from ..shell import run_command
|
||||||
from ..templates.layout import page, render_result
|
from ..templates.layout import page, render_result
|
||||||
|
|
||||||
@@ -203,6 +204,20 @@ def create_app(
|
|||||||
deploy_result = run_command([DEPLOY_SCRIPT, app_id])
|
deploy_result = run_command([DEPLOY_SCRIPT, app_id])
|
||||||
|
|
||||||
status = "OK" if create_result.returncode == 0 and deploy_result.returncode == 0 else "FAILED"
|
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(
|
return render_result(
|
||||||
title=f"Vytvoření aplikace: {status}",
|
title=f"Vytvoření aplikace: {status}",
|
||||||
@@ -223,6 +238,17 @@ def create_app(
|
|||||||
def delete_app(app_id: str = Form(...), user=Depends(require_user)):
|
def delete_app(app_id: str = Form(...), user=Depends(require_user)):
|
||||||
result = run_command([DELETE_APP_SCRIPT, app_id])
|
result = run_command([DELETE_APP_SCRIPT, app_id])
|
||||||
status = "OK" if result.returncode == 0 else "FAILED"
|
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(
|
return render_result(
|
||||||
title=f"Smazání aplikace: {status}",
|
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
|
if catalog_result.returncode == 0 and compose_result.returncode == 0 and deploy_result.returncode == 0
|
||||||
else "FAILED"
|
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(
|
return render_result(
|
||||||
title=f"Úprava prostředků: {status}",
|
title=f"Úprava prostředků: {status}",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Form, Request
|
|||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
from app.auth import authenticate_user, current_user
|
from app.auth import authenticate_user, current_user
|
||||||
|
from app.db.audit import log_audit_event
|
||||||
from app.templates.layout import page
|
from app.templates.layout import page
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -26,12 +27,27 @@ def login(request: Request, username: str = Form(...), password: str = Form(...)
|
|||||||
|
|
||||||
request.session.clear()
|
request.session.clear()
|
||||||
request.session["user_id"] = user["id"]
|
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)
|
return RedirectResponse(url="/portal", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
def logout(request: Request):
|
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()
|
request.session.clear()
|
||||||
return RedirectResponse(url="/portal/login", status_code=303)
|
return RedirectResponse(url="/portal/login", status_code=303)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
from ..auth import require_user
|
from ..auth import require_user
|
||||||
from ..backups import is_backup_path, list_backups
|
from ..backups import is_backup_path, list_backups
|
||||||
from ..config import BACKUP_SCRIPT
|
from ..config import BACKUP_SCRIPT
|
||||||
|
from ..db.audit import log_audit_event
|
||||||
from ..shell import run_command
|
from ..shell import run_command
|
||||||
from ..templates.layout import page, render_result
|
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}">
|
<input readonly value="{restore_cmd}">
|
||||||
<button type="button" class="copy-button" data-copy-value="{restore_cmd}" onclick="copyText(this)">Kopírovat</button>
|
<button type="button" class="copy-button" data-copy-value="{restore_cmd}" onclick="copyText(this)">Kopírovat</button>
|
||||||
</div>
|
</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>
|
<div class="muted">Obnova je nebezpečná a musí se spustit ručně přes SSH.</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="actions-cell">
|
<td class="actions-cell">
|
||||||
@@ -95,6 +100,15 @@ def backups_page(request: Request, user=Depends(require_user)):
|
|||||||
def create_backup(user=Depends(require_user)):
|
def create_backup(user=Depends(require_user)):
|
||||||
result = run_command([BACKUP_SCRIPT])
|
result = run_command([BACKUP_SCRIPT])
|
||||||
status = "OK" if result.returncode == 0 else "FAILED"
|
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(
|
return render_result(
|
||||||
title=f"Vytvoření zálohy: {status}",
|
title=f"Vytvoření zálohy: {status}",
|
||||||
@@ -116,3 +130,23 @@ def delete_backup(backup_path: str = Form(...), user=Depends(require_user)):
|
|||||||
target.unlink()
|
target.unlink()
|
||||||
|
|
||||||
return RedirectResponse(url="/portal/backups", status_code=303)
|
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)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ def page(title: str, body: str, user=None) -> str:
|
|||||||
<a href="/portal/new-app">Nová aplikace</a>
|
<a href="/portal/new-app">Nová aplikace</a>
|
||||||
<a href="/portal/deployments">Nasazení</a>
|
<a href="/portal/deployments">Nasazení</a>
|
||||||
<a href="/portal/backups">Zálohy</a>
|
<a href="/portal/backups">Zálohy</a>
|
||||||
|
<a href="/portal/audit">Audit</a>
|
||||||
</nav>
|
</nav>
|
||||||
"""
|
"""
|
||||||
user_panel = f"""
|
user_panel = f"""
|
||||||
|
|||||||
Reference in New Issue
Block a user