diff --git a/fabledscryer/audit/__init__.py b/fabledscryer/audit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fabledscryer/audit/routes.py b/fabledscryer/audit/routes.py new file mode 100644 index 0000000..a10eab4 --- /dev/null +++ b/fabledscryer/audit/routes.py @@ -0,0 +1,42 @@ +from __future__ import annotations +import json +from quart import Blueprint, render_template, request, current_app +from sqlalchemy import select +from fabledscryer.auth.middleware import require_role +from fabledscryer.models.audit import AuditEvent +from fabledscryer.models.users import UserRole + +audit_bp = Blueprint("audit", __name__, url_prefix="/audit") + + +@audit_bp.get("/") +@require_role(UserRole.admin) +async def list_events(): + limit = min(int(request.args.get("limit", 200)), 500) + action_filter = request.args.get("action", "").strip() + async with current_app.db_sessionmaker() as db: + stmt = select(AuditEvent).order_by(AuditEvent.timestamp.desc()).limit(limit) + if action_filter: + stmt = select(AuditEvent).where( + AuditEvent.action.like(f"{action_filter}%") + ).order_by(AuditEvent.timestamp.desc()).limit(limit) + result = await db.execute(stmt) + events = result.scalars().all() + + # Parse detail_json for display + parsed = [] + for e in events: + detail = {} + if e.detail_json: + try: + detail = json.loads(e.detail_json) + except (ValueError, TypeError): + detail = {"raw": e.detail_json} + parsed.append({"event": e, "detail": detail}) + + return await render_template( + "audit/list.html", + events=parsed, + limit=limit, + action_filter=action_filter, + ) diff --git a/fabledscryer/auth/ldap_auth.py b/fabledscryer/auth/ldap_auth.py new file mode 100644 index 0000000..098229f --- /dev/null +++ b/fabledscryer/auth/ldap_auth.py @@ -0,0 +1,98 @@ +"""fabledscryer/auth/ldap_auth.py + +LDAP authentication helper. Requires the optional `ldap3` package. +Falls back gracefully if ldap3 is not installed. +""" +from __future__ import annotations +import asyncio +import logging + +logger = logging.getLogger(__name__) + + +def _ldap_available() -> bool: + try: + import ldap3 # noqa: F401 + return True + except ImportError: + return False + + +def _ldap_authenticate_sync(cfg: dict, username: str, password: str) -> dict | None: + """Synchronous LDAP auth. Returns user info dict or None on failure. + + Called via run_in_executor so it doesn't block the event loop. + """ + import ldap3 + + host = cfg.get("host", "") + port = int(cfg.get("port", 389)) + use_tls = cfg.get("tls", False) + bind_dn = cfg.get("bind_dn", "") + bind_password = cfg.get("bind_password", "") + base_dn = cfg.get("base_dn", "") + user_filter = cfg.get("user_filter", "(uid={username})").format(username=username) + admin_group_dn = cfg.get("admin_group_dn", "") + operator_group_dn = cfg.get("operator_group_dn", "") + attr_username = cfg.get("attr_username", "uid") + attr_email = cfg.get("attr_email", "mail") + + tls = ldap3.Tls() if use_tls else None + server = ldap3.Server(host, port=port, tls=tls, get_info=ldap3.ALL) + + # Service account bind to search for user DN + try: + svc_conn = ldap3.Connection(server, user=bind_dn, password=bind_password, auto_bind=True) + except Exception as exc: + logger.error("LDAP service bind failed: %s", exc) + return None + + try: + svc_conn.search( + search_base=base_dn, + search_filter=user_filter, + attributes=[attr_username, attr_email, "memberOf", "dn"], + ) + if not svc_conn.entries: + logger.debug("LDAP user not found: %s", username) + return None + entry = svc_conn.entries[0] + user_dn = str(entry.entry_dn) + ldap_username = str(getattr(entry, attr_username, [username])[0]) if hasattr(entry, attr_username) else username + ldap_email = str(getattr(entry, attr_email, [""])[0]) if hasattr(entry, attr_email) else "" + member_of: list[str] = [str(g) for g in getattr(entry, "memberOf", [])] + except Exception as exc: + logger.error("LDAP search failed: %s", exc) + return None + finally: + svc_conn.unbind() + + # Bind as the user to verify password + try: + user_conn = ldap3.Connection(server, user=user_dn, password=password, auto_bind=True) + user_conn.unbind() + except Exception: + logger.debug("LDAP password verify failed for %s", username) + return None + + # Map role from group membership + role = "viewer" + if admin_group_dn and admin_group_dn in member_of: + role = "admin" + elif operator_group_dn and operator_group_dn in member_of: + role = "operator" + + return { + "username": ldap_username, + "email": ldap_email, + "role": role, + } + + +async def ldap_authenticate(cfg: dict, username: str, password: str) -> dict | None: + """Async wrapper around synchronous LDAP auth. Returns user info or None.""" + if not _ldap_available(): + logger.warning("ldap3 not installed — LDAP auth unavailable") + return None + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, _ldap_authenticate_sync, cfg, username, password) diff --git a/fabledscryer/auth/oidc.py b/fabledscryer/auth/oidc.py new file mode 100644 index 0000000..3cacf4f --- /dev/null +++ b/fabledscryer/auth/oidc.py @@ -0,0 +1,109 @@ +"""fabledscryer/auth/oidc.py + +OIDC Authorization Code flow helpers using httpx. +No extra dependencies — works with any OIDC provider (Authentik, Keycloak, etc.). + +JWT signature verification is intentionally skipped for homelab use; +we trust the HTTPS connection to the discovery-documented token/userinfo endpoints. +""" +from __future__ import annotations +import base64 +import hashlib +import json +import logging +import os +import time + +import httpx + +logger = logging.getLogger(__name__) + +# Module-level cache: {discovery_url: (fetched_at, doc)} +_discovery_cache: dict[str, tuple[float, dict]] = {} +_DISCOVERY_TTL = 300 # seconds + + +async def get_discovery(discovery_url: str) -> dict: + """Fetch and cache the OIDC provider discovery document.""" + now = time.monotonic() + if discovery_url in _discovery_cache: + fetched_at, doc = _discovery_cache[discovery_url] + if now - fetched_at < _DISCOVERY_TTL: + return doc + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(discovery_url) + resp.raise_for_status() + doc = resp.json() + _discovery_cache[discovery_url] = (now, doc) + return doc + + +def build_authorize_url(doc: dict, client_id: str, redirect_uri: str, + scopes: str, state: str, nonce: str) -> str: + """Build the IdP authorization redirect URL.""" + import urllib.parse + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": scopes, + "state": state, + "nonce": nonce, + } + return doc["authorization_endpoint"] + "?" + urllib.parse.urlencode(params) + + +async def exchange_code(doc: dict, client_id: str, client_secret: str, + code: str, redirect_uri: str) -> dict: + """Exchange authorization code for tokens. Returns token response dict.""" + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.post( + doc["token_endpoint"], + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": client_id, + "client_secret": client_secret, + }, + ) + resp.raise_for_status() + return resp.json() + + +async def get_userinfo(doc: dict, access_token: str) -> dict: + """Fetch user info from the IdP's userinfo endpoint.""" + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get( + doc["userinfo_endpoint"], + headers={"Authorization": f"Bearer {access_token}"}, + ) + resp.raise_for_status() + return resp.json() + + +def decode_id_token_payload(id_token: str) -> dict: + """Decode ID token payload without verifying signature (homelab only).""" + try: + parts = id_token.split(".") + if len(parts) < 2: + return {} + payload_b64 = parts[1] + # Add padding + payload_b64 += "=" * (4 - len(payload_b64) % 4) + return json.loads(base64.urlsafe_b64decode(payload_b64)) + except Exception: + return {} + + +def map_role(groups: list[str], admin_group: str, operator_group: str) -> str: + """Map IdP groups to a local role string.""" + if admin_group and admin_group in groups: + return "admin" + if operator_group and operator_group in groups: + return "operator" + return "viewer" + + +def generate_state() -> str: + return base64.urlsafe_b64encode(os.urandom(24)).decode().rstrip("=") diff --git a/fabledscryer/auth/routes.py b/fabledscryer/auth/routes.py index c9eb4c7..ba7d299 100644 --- a/fabledscryer/auth/routes.py +++ b/fabledscryer/auth/routes.py @@ -8,6 +8,33 @@ from fabledscryer.models.users import User, UserRole auth_bp = Blueprint("auth", __name__) +async def _provision_external_user(app, username: str, email: str, role_str: str): + """Create or update a user from an external auth provider (OIDC/LDAP). + + External users get a randomised unusable password so local login is blocked. + Their role is updated on every login to reflect current IdP group membership. + """ + import secrets + from fabledscryer.models.users import UserRole + async with app.db_sessionmaker() as db: + result = await db.execute(select(User).where(User.username == username)) + user = result.scalar_one_or_none() + new_role = UserRole(role_str) + async with db.begin(): + if user is None: + unusable_hash = bcrypt.hashpw(secrets.token_bytes(32), bcrypt.gensalt()).decode() + user = User(username=username, email=email or f"{username}@external", + password_hash=unusable_hash, role=new_role) + db.add(user) + else: + if user.role != new_role: + user.role = new_role + # Re-fetch committed user + async with app.db_sessionmaker() as db: + result = await db.execute(select(User).where(User.username == username)) + return result.scalar_one() + + async def get_user_count(app) -> int: async with app.db_sessionmaker() as db: result = await db.execute(select(func.count()).select_from(User)) @@ -25,21 +52,124 @@ async def login(): from quart import current_app if await get_user_count(current_app) == 0: return redirect(url_for("auth.setup")) - return await render_template("auth/login.html") + oidc_cfg = current_app.config.get("OIDC", {}) + return await render_template("auth/login.html", + oidc_enabled=oidc_cfg.get("enabled", False)) @auth_bp.post("/login") async def login_post(): from quart import current_app + from fabledscryer.core.audit import log_audit form = await request.form username = form.get("username", "").strip() - password = form.get("password", "").encode() + password_str = form.get("password", "") + password = password_str.encode() + + # Try local auth first user = await get_user_by_username(current_app, username) - if not user or not user.is_active: - return await render_template("auth/login.html", error="Invalid credentials"), 400 - if not bcrypt.checkpw(password, user.password_hash.encode()): - return await render_template("auth/login.html", error="Invalid credentials"), 400 + if user and user.is_active and bcrypt.checkpw(password, user.password_hash.encode()): + login_user(user) + await log_audit(current_app, user.id, user.username, "auth.login", + detail={"method": "local", "role": user.role.value}) + return redirect(url_for("dashboard.index")) + + # Try LDAP fallback if enabled + ldap_cfg = current_app.config.get("LDAP", {}) + if ldap_cfg.get("enabled"): + from fabledscryer.auth.ldap_auth import ldap_authenticate + ldap_info = await ldap_authenticate(ldap_cfg, username, password_str) + if ldap_info: + user = await _provision_external_user( + current_app, ldap_info["username"], ldap_info["email"], ldap_info["role"] + ) + login_user(user) + await log_audit(current_app, user.id, user.username, "auth.login", + detail={"method": "ldap", "role": user.role.value}) + return redirect(url_for("dashboard.index")) + + oidc_cfg = current_app.config.get("OIDC", {}) + return await render_template( + "auth/login.html", + error="Invalid credentials", + oidc_enabled=oidc_cfg.get("enabled", False), + ), 400 + + +@auth_bp.get("/login/oidc") +async def login_oidc(): + from quart import current_app + from fabledscryer.auth.oidc import get_discovery, build_authorize_url, generate_state + oidc_cfg = current_app.config.get("OIDC", {}) + if not oidc_cfg.get("enabled") or not oidc_cfg.get("discovery_url"): + return redirect(url_for("auth.login")) + try: + doc = await get_discovery(oidc_cfg["discovery_url"]) + except Exception as exc: + return await render_template("auth/login.html", error=f"OIDC discovery failed: {exc}", + oidc_enabled=True), 502 + state = generate_state() + nonce = generate_state() + session["oidc_state"] = state + session["oidc_nonce"] = nonce + redirect_uri = url_for("auth.login_oidc_callback", _external=True) + url = build_authorize_url( + doc, oidc_cfg["client_id"], redirect_uri, + oidc_cfg.get("scopes", "openid profile email"), state, nonce, + ) + return redirect(url) + + +@auth_bp.get("/login/oidc/callback") +async def login_oidc_callback(): + from quart import current_app + from fabledscryer.auth.oidc import get_discovery, exchange_code, get_userinfo, map_role + from fabledscryer.core.audit import log_audit + + oidc_cfg = current_app.config.get("OIDC", {}) + error = request.args.get("error") + if error: + desc = request.args.get("error_description", error) + return await render_template("auth/login.html", error=f"SSO error: {desc}", + oidc_enabled=True), 400 + + code = request.args.get("code", "") + state = request.args.get("state", "") + if not code or state != session.pop("oidc_state", None): + return await render_template("auth/login.html", error="Invalid SSO state — try again.", + oidc_enabled=True), 400 + + try: + doc = await get_discovery(oidc_cfg["discovery_url"]) + redirect_uri = url_for("auth.login_oidc_callback", _external=True) + tokens = await exchange_code( + doc, oidc_cfg["client_id"], oidc_cfg["client_secret"], code, redirect_uri + ) + userinfo = await get_userinfo(doc, tokens["access_token"]) + except Exception as exc: + return await render_template("auth/login.html", error=f"SSO login failed: {exc}", + oidc_enabled=True), 502 + + username_claim = oidc_cfg.get("username_claim", "preferred_username") + email_claim = oidc_cfg.get("email_claim", "email") + groups_claim = oidc_cfg.get("groups_claim", "groups") + + username = userinfo.get(username_claim) or userinfo.get("sub", "") + email = userinfo.get(email_claim, "") or f"{username}@oidc" + groups = userinfo.get(groups_claim, []) + if not isinstance(groups, list): + groups = [] + role = map_role(groups, oidc_cfg.get("admin_group", ""), oidc_cfg.get("operator_group", "")) + + if not username: + return await render_template("auth/login.html", + error="SSO returned no username.", + oidc_enabled=True), 400 + + user = await _provision_external_user(current_app, username, email, role) login_user(user) + await log_audit(current_app, user.id, user.username, "auth.login", + detail={"method": "oidc", "role": role}) return redirect(url_for("dashboard.index")) @@ -60,6 +190,7 @@ async def setup(): @auth_bp.post("/setup") async def setup_post(): from quart import current_app + from fabledscryer.core.audit import log_audit if await get_user_count(current_app) > 0: return redirect(url_for("auth.login")) form = await request.form @@ -74,4 +205,6 @@ async def setup_post(): async with db.begin(): db.add(user) login_user(user) + await log_audit(current_app, user.id, user.username, "auth.setup", + detail={"username": username}) return redirect(url_for("dashboard.index")) diff --git a/fabledscryer/core/audit.py b/fabledscryer/core/audit.py new file mode 100644 index 0000000..63ad5e2 --- /dev/null +++ b/fabledscryer/core/audit.py @@ -0,0 +1,40 @@ +"""fabledscryer/core/audit.py + +Helpers for writing audit log entries. Each call opens its own DB +session so audit events are committed independently of the calling +route's transaction (audit is only written after the main action +succeeds, by calling this after the main `async with db.begin()` block). +""" +from __future__ import annotations +import json +import logging +from datetime import datetime, timezone + +logger = logging.getLogger(__name__) + + +async def log_audit( + app, + user_id: str | None, + username: str, + action: str, + entity_type: str | None = None, + entity_id: str | None = None, + detail: dict | None = None, +) -> None: + """Write one audit event. Never raises — failures are logged and swallowed.""" + from fabledscryer.models.audit import AuditEvent + try: + async with app.db_sessionmaker() as db: + async with db.begin(): + db.add(AuditEvent( + user_id=user_id, + username=username or "unknown", + action=action, + entity_type=entity_type, + entity_id=entity_id, + detail_json=json.dumps(detail) if detail else None, + timestamp=datetime.now(timezone.utc), + )) + except Exception: + logger.exception("Failed to write audit event action=%r", action) diff --git a/fabledscryer/core/settings.py b/fabledscryer/core/settings.py index 920cbbe..25a954c 100644 --- a/fabledscryer/core/settings.py +++ b/fabledscryer/core/settings.py @@ -51,6 +51,35 @@ DEFAULTS: dict[str, Any] = { "ping.threshold.good_ms": 50, "ping.threshold.warn_ms": 200, "plugins.index_url": "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/raw/branch/main/index.yaml", + # OIDC single-sign-on + "oidc.enabled": False, + "oidc.discovery_url": "", + "oidc.client_id": "", + "oidc.client_secret": "", + "oidc.scopes": "openid profile email", + "oidc.username_claim": "preferred_username", + "oidc.email_claim": "email", + "oidc.groups_claim": "groups", + "oidc.admin_group": "", + "oidc.operator_group": "", + # LDAP authentication + "ldap.enabled": False, + "ldap.host": "", + "ldap.port": 389, + "ldap.tls": False, + "ldap.bind_dn": "", + "ldap.bind_password": "", + "ldap.base_dn": "", + "ldap.user_filter": "(uid={username})", + "ldap.admin_group_dn": "", + "ldap.operator_group_dn": "", + "ldap.attr_username": "uid", + "ldap.attr_email": "mail", + # Scheduled reports + "reports.enabled": False, + "reports.schedule_day": 6, # 0=Monday … 6=Sunday + "reports.schedule_hour": 8, # UTC hour + "reports.last_sent_at": "", } @@ -123,6 +152,14 @@ def to_ansible_cfg(settings: dict[str, Any]) -> dict: return {"sources": settings.get("ansible.sources", [])} +def to_oidc_cfg(settings: dict[str, Any]) -> dict: + return {k[len("oidc."):]: settings.get(k, DEFAULTS[k]) for k in DEFAULTS if k.startswith("oidc.")} + + +def to_ldap_cfg(settings: dict[str, Any]) -> dict: + return {k[len("ldap."):]: settings.get(k, DEFAULTS[k]) for k in DEFAULTS if k.startswith("ldap.")} + + def to_plugins_cfg(settings: dict[str, Any]) -> dict: """Assemble {plugin_name: {...config}} from all plugin.* keys.""" result = {} diff --git a/fabledscryer/migrations/versions/0009_widget_variants.py b/fabledscryer/migrations/versions/0009_widget_variants.py new file mode 100644 index 0000000..286a3e5 --- /dev/null +++ b/fabledscryer/migrations/versions/0009_widget_variants.py @@ -0,0 +1,26 @@ +"""Widget variants — add config_json and title to dashboard_widgets + +Revision ID: 0009_widget_variants +Revises: 0008_share_tokens +Create Date: 2026-03-22 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "0009_widget_variants" +down_revision: Union[str, None] = "0008_share_tokens" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("dashboard_widgets", + sa.Column("title", sa.String(128), nullable=True)) + op.add_column("dashboard_widgets", + sa.Column("config_json", sa.Text, nullable=True)) + + +def downgrade() -> None: + op.drop_column("dashboard_widgets", "config_json") + op.drop_column("dashboard_widgets", "title") diff --git a/fabledscryer/migrations/versions/0010_maintenance_windows.py b/fabledscryer/migrations/versions/0010_maintenance_windows.py new file mode 100644 index 0000000..309cd15 --- /dev/null +++ b/fabledscryer/migrations/versions/0010_maintenance_windows.py @@ -0,0 +1,32 @@ +"""Maintenance windows for alert suppression + +Revision ID: 0010_maintenance_windows +Revises: 0009_widget_variants +Create Date: 2026-03-22 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "0010_maintenance_windows" +down_revision: Union[str, None] = "0009_widget_variants" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "maintenance_windows", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("name", sa.String(128), nullable=False), + sa.Column("start_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("end_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("scope_source", sa.String(64), nullable=True), + sa.Column("scope_resource", sa.String(255), nullable=True), + sa.Column("created_by", sa.String(36), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("maintenance_windows") diff --git a/fabledscryer/migrations/versions/0011_audit_log.py b/fabledscryer/migrations/versions/0011_audit_log.py new file mode 100644 index 0000000..c3e3fdb --- /dev/null +++ b/fabledscryer/migrations/versions/0011_audit_log.py @@ -0,0 +1,35 @@ +"""Audit log table + +Revision ID: 0011_audit_log +Revises: 0010_maintenance_windows +Create Date: 2026-03-22 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "0011_audit_log" +down_revision: Union[str, None] = "0010_maintenance_windows" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "audit_events", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False), + sa.Column("user_id", sa.String(36), + sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("username", sa.String(64), nullable=False, server_default="system"), + sa.Column("action", sa.String(64), nullable=False), + sa.Column("entity_type", sa.String(64), nullable=True), + sa.Column("entity_id", sa.String(255), nullable=True), + sa.Column("detail_json", sa.Text, nullable=True), + ) + op.create_index("ix_audit_events_timestamp", "audit_events", ["timestamp"]) + + +def downgrade() -> None: + op.drop_index("ix_audit_events_timestamp", "audit_events") + op.drop_table("audit_events") diff --git a/fabledscryer/models/audit.py b/fabledscryer/models/audit.py new file mode 100644 index 0000000..2d53d50 --- /dev/null +++ b/fabledscryer/models/audit.py @@ -0,0 +1,24 @@ +from __future__ import annotations +import uuid +from datetime import datetime, timezone +from sqlalchemy import DateTime, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column +from .base import Base + + +class AuditEvent(Base): + __tablename__ = "audit_events" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + timestamp: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) + ) + user_id: Mapped[str | None] = mapped_column( + String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + # Denormalized so display survives user deletion + username: Mapped[str] = mapped_column(String(64), nullable=False, default="system") + action: Mapped[str] = mapped_column(String(64), nullable=False) + entity_type: Mapped[str | None] = mapped_column(String(64), nullable=True) + entity_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + detail_json: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/fabledscryer/templates/audit/list.html b/fabledscryer/templates/audit/list.html new file mode 100644 index 0000000..693b467 --- /dev/null +++ b/fabledscryer/templates/audit/list.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% block title %}Audit Log — Fabled Scryer{% endblock %} +{% block content %} +
| Time (UTC) | +User | +Action | +Entity | +Detail | +
|---|---|---|---|---|
| + {{ e.timestamp.strftime("%Y-%m-%d %H:%M:%S") }} + | +{{ e.username }} | ++ {% set cat = e.action.split('.')[0] %} + + {{ e.action }} + + | ++ {% if e.entity_type %} + {{ e.entity_type }}/{{ e.entity_id or "" }} + {% endif %} + | ++ {% if d %} + {% for k, v in d.items() %} + {{ k }}: + {{ v }} + {% if not loop.last %} · {% endif %} + {% endfor %} + {% endif %} + | +
+ Showing last {{ limit }} events. + {% if events | length == limit %} + Load more + {% endif %} +
+{% else %} +No audit events recorded yet.
+