modified: web/app.py

modified:   web/blueprints/auth.py
	modified:   web/blueprints/group_admin.py
	modified:   web/blueprints/site_admin.py
	new file:   web/limiter.py
	modified:   web/panel_db.py
	modified:   web/requirements.txt
	new file:   web/templates/429.html
	new file:   web/templates/admin/audit_log.html
	modified:   web/templates/admin/base.html
This commit is contained in:
simon
2026-04-14 13:02:41 +02:00
parent 452d50e5b5
commit 3b78f5dfb1
10 changed files with 564 additions and 35 deletions

View File

@@ -119,6 +119,56 @@ PANEL_SCHEMA = [
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"""CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
actor_user_id INT NULL,
actor_username VARCHAR(50) NULL,
action VARCHAR(100) NOT NULL,
entity_type VARCHAR(50) NULL,
entity_id VARCHAR(100) NULL,
details JSON NULL,
group_id INT NULL,
ip_address VARCHAR(45) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_audit_actor (actor_user_id),
INDEX idx_audit_group (group_id),
INDEX idx_audit_action (action),
INDEX idx_audit_ts (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"""CREATE TABLE IF NOT EXISTS schema_migrations (
version INT PRIMARY KEY,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
note VARCHAR(255) NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
]
# ─────────────────────────────────────────────────────────────
# Versioned migrations (applied once, tracked in schema_migrations)
# Each entry: (version_int, sql_statement, human_readable_note)
# ─────────────────────────────────────────────────────────────
PANEL_MIGRATIONS = [
(1,
"ALTER TABLE group_members MODIFY COLUMN role "
"ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer'",
"Extend group_members.role ENUM"),
(2,
"ALTER TABLE group_invites MODIFY COLUMN role "
"ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer'",
"Extend group_invites.role ENUM"),
(3,
"ALTER TABLE group_invites ADD COLUMN IF NOT EXISTS last_sent_at DATETIME NULL",
"Add group_invites.last_sent_at"),
(4,
"ALTER TABLE group_invites ADD COLUMN IF NOT EXISTS send_count INT NOT NULL DEFAULT 0",
"Add group_invites.send_count"),
(5,
"ALTER TABLE group_invites MODIFY COLUMN group_id INT NULL",
"Allow group_invites.group_id to be NULL"),
(6,
"ALTER TABLE group_invites ADD COLUMN IF NOT EXISTS is_site_admin TINYINT(1) NOT NULL DEFAULT 0",
"Add group_invites.is_site_admin"),
]
CREDS_SCHEMA = [
@@ -149,43 +199,33 @@ CREDS_SCHEMA = [
def init_databases():
"""Erstellt alle benötigten Tabellen falls nicht vorhanden."""
"""Creates all required tables and applies pending schema migrations."""
import logging
_log = logging.getLogger(__name__)
panel = get_panel_db()
try:
with panel.cursor() as cur:
# Create tables (idempotent)
for stmt in PANEL_SCHEMA:
cur.execute(stmt)
# Best-effort migrations for existing installs.
try:
cur.execute(
"ALTER TABLE group_members MODIFY role ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer'"
)
except Exception:
pass
try:
cur.execute(
"ALTER TABLE group_invites MODIFY role ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer'"
)
except Exception:
pass
try:
cur.execute("ALTER TABLE group_invites ADD COLUMN last_sent_at DATETIME NULL")
except Exception:
pass
try:
cur.execute("ALTER TABLE group_invites ADD COLUMN send_count INT NOT NULL DEFAULT 0")
except Exception:
pass
# Determine already-applied migration versions
cur.execute("SELECT version FROM schema_migrations")
applied = {row["version"] for row in cur.fetchall()}
for version, sql, note in PANEL_MIGRATIONS:
if version in applied:
continue
try:
cur.execute("SET foreign_key_checks=0")
cur.execute("ALTER TABLE group_invites MODIFY group_id INT NULL")
cur.execute("SET foreign_key_checks=1")
except Exception:
pass
try:
cur.execute("ALTER TABLE group_invites ADD COLUMN is_site_admin TINYINT(1) NOT NULL DEFAULT 0")
except Exception:
pass
cur.execute(sql)
cur.execute(
"INSERT IGNORE INTO schema_migrations (version, note) VALUES (%s, %s)",
(version, note),
)
_log.info("Migration %d applied: %s", version, note)
except Exception as exc:
_log.warning("Migration %d skipped (%s): %s", version, note, exc)
finally:
panel.close()
@@ -683,3 +723,92 @@ def delete_site_mail_settings():
def has_site_mail_settings() -> bool:
row = _creds_query("SELECT id FROM site_mail_settings WHERE config_key=%s", ("primary",), fetchone=True)
return row is not None
# ─────────────────────────────────────────────────────────────
# Audit-Log
# ─────────────────────────────────────────────────────────────
def log_audit_event(
actor_user_id,
actor_username: str | None,
action: str,
entity_type: str | None = None,
entity_id: str | None = None,
details: dict | None = None,
group_id: int | None = None,
ip_address: str | None = None,
):
"""Records an audit event. Never raises — audit log must not break the main flow."""
try:
_panel_query(
"INSERT INTO audit_log "
"(actor_user_id, actor_username, action, entity_type, entity_id, details, group_id, ip_address) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s)",
(
actor_user_id,
actor_username,
action,
entity_type,
str(entity_id) if entity_id is not None else None,
json.dumps(details) if details else None,
group_id,
ip_address,
),
write=True,
)
except Exception:
import logging
logging.getLogger(__name__).warning("Failed to write audit event: %s", action)
def get_audit_log(
page: int = 1,
per_page: int = 50,
action_filter: str | None = None,
group_id_filter: int | None = None,
actor_filter: str | None = None,
):
offset = (page - 1) * per_page
conditions: list[str] = []
args: list = []
if action_filter:
conditions.append("al.action LIKE %s")
args.append(f"%{action_filter}%")
if group_id_filter:
conditions.append("al.group_id = %s")
args.append(group_id_filter)
if actor_filter:
conditions.append("al.actor_username LIKE %s")
args.append(f"%{actor_filter}%")
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
count_row = _panel_query(
f"SELECT COUNT(*) AS c FROM audit_log al {where}", args, fetchone=True
)
total = int(count_row["c"]) if count_row else 0
rows = _panel_query(
f"SELECT al.*, g.name AS group_name "
f"FROM audit_log al "
f"LEFT JOIN user_groups g ON g.id = al.group_id "
f"{where} ORDER BY al.created_at DESC LIMIT %s OFFSET %s",
args + [per_page, offset],
)
# Ensure details is always a dict (pymysql may return JSON as string)
for row in (rows or []):
d = row.get("details")
if isinstance(d, str):
try:
row["details"] = json.loads(d)
except Exception:
row["details"] = {}
return rows, total
def get_audit_log_distinct_actions() -> list[str]:
rows = _panel_query("SELECT DISTINCT action FROM audit_log ORDER BY action")
return [r["action"] for r in rows] if rows else []