kitgit

tirbofish/kitgit · diff

dcc7669 · Thribhu K

feat: add per-user security audit log

Verified

diff --git a/migrations/011_user_audit_log.sql b/migrations/011_user_audit_log.sql
new file mode 100644
index 0000000..13a5f1b
--- /dev/null
+++ b/migrations/011_user_audit_log.sql
@@ -0,0 +1,18 @@
+-- Per-user security / account audit trail (separate from activity_events social feed).
+CREATE TABLE IF NOT EXISTS audit_log (
+    id BIGSERIAL PRIMARY KEY,
+    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
+    action TEXT NOT NULL,
+    ip TEXT,
+    user_agent TEXT,
+    metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS audit_log_user_id_created_at_idx
+    ON audit_log(user_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS audit_log_created_at_idx
+    ON audit_log(created_at DESC);
+CREATE INDEX IF NOT EXISTS audit_log_action_idx
+    ON audit_log(action);
diff --git a/src/db/models.rs b/src/db/models.rs
index 874e00c..3635aa6 100644
--- a/src/db/models.rs
+++ b/src/db/models.rs
@@ -158,6 +158,19 @@ pub struct ActivityEvent {
     pub created_at: DateTime<Utc>,
 }
 
+/// Per-user security / account audit entry (not the social activity feed).
+#[derive(Debug, Clone, FromRow)]
+pub struct AuditLog {
+    pub id: i64,
+    pub user_id: Uuid,
+    pub actor_id: Option<Uuid>,
+    pub action: String,
+    pub ip: Option<String>,
+    pub user_agent: Option<String>,
+    pub metadata: serde_json::Value,
+    pub created_at: DateTime<Utc>,
+}
+
 #[derive(Debug, Clone, FromRow)]
 pub struct Issue {
     pub id: Uuid,
diff --git a/src/db/queries.rs b/src/db/queries.rs
index a236a11..e2fb5e1 100644
--- a/src/db/queries.rs
+++ b/src/db/queries.rs
@@ -2349,3 +2349,45 @@ pub async fn lfs_object_size(pool: &PgPool, oid: &str) -> Result<Option<i64>> {
         .await?;
     Ok(row.map(|r| r.0))
 }
+
+// ── per-user audit log ───────────────────────────────────────────────────────
+
+pub async fn record_audit_log(
+    pool: &PgPool,
+    user_id: Uuid,
+    actor_id: Option<Uuid>,
+    action: &str,
+    ip: Option<&str>,
+    user_agent: Option<&str>,
+    metadata: serde_json::Value,
+) -> Result<()> {
+    sqlx::query(
+        r#"
+        INSERT INTO audit_log (user_id, actor_id, action, ip, user_agent, metadata)
+        VALUES ($1, $2, $3, $4, $5, $6)
+        "#,
+    )
+    .bind(user_id)
+    .bind(actor_id)
+    .bind(action)
+    .bind(ip)
+    .bind(user_agent)
+    .bind(metadata)
+    .execute(pool)
+    .await?;
+    Ok(())
+}
+
+pub async fn list_audit_log_for_user(
+    pool: &PgPool,
+    user_id: Uuid,
+    limit: i64,
+) -> Result<Vec<AuditLog>> {
+    Ok(sqlx::query_as::<_, AuditLog>(
+        "SELECT * FROM audit_log WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2",
+    )
+    .bind(user_id)
+    .bind(limit)
+    .fetch_all(pool)
+    .await?)
+}
diff --git a/src/web/mod.rs b/src/web/mod.rs
index c4b4cbb..f382238 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -72,6 +72,7 @@ pub fn app_router(state: AppState) -> Router {
         .route("/auth/callback", get(routes::auth_callback))
         .route("/auth/logout", get(routes::auth_logout))
         .route("/admin", get(routes::admin_panel))
+        .route("/admin/users/{username}/audit", get(routes::admin_user_audit))
         .route("/admin/users", post(routes::admin_set_user))
         .route("/admin/users/suspend", post(routes::admin_set_suspended))
         .route("/admin/motd", post(routes::admin_save_motd))
diff --git a/src/web/routes.rs b/src/web/routes.rs
index c080f6f..aa1cb58 100644
--- a/src/web/routes.rs
+++ b/src/web/routes.rs
@@ -113,6 +113,95 @@ fn redirect_with_cookies(to: &str, cookies: Vec<HeaderValue>) -> Response {
     res
 }
 
+/// Best-effort client IP / User-Agent from reverse-proxy headers.
+pub fn request_client_meta(headers: &HeaderMap) -> (Option<String>, Option<String>) {
+    let ua = headers
+        .get(axum::http::header::USER_AGENT)
+        .and_then(|v| v.to_str().ok())
+        .map(|s| s.chars().take(512).collect::<String>());
+    let ip = headers
+        .get("x-forwarded-for")
+        .and_then(|v| v.to_str().ok())
+        .and_then(|s| s.split(',').next())
+        .map(|s| s.trim().to_string())
+        .filter(|s| !s.is_empty())
+        .or_else(|| {
+            headers
+                .get("x-real-ip")
+                .and_then(|v| v.to_str().ok())
+                .map(|s| s.trim().to_string())
+                .filter(|s| !s.is_empty())
+        });
+    (ip, ua)
+}
+
+/// Write an audit_log row; failures are logged and never fail the request.
+pub async fn record_audit(
+    state: &AppState,
+    headers: &HeaderMap,
+    user_id: Uuid,
+    actor_id: Option<Uuid>,
+    action: &str,
+    metadata: serde_json::Value,
+) {
+    let (ip, ua) = request_client_meta(headers);
+    if let Err(e) = queries::record_audit_log(
+        &state.pool,
+        user_id,
+        actor_id,
+        action,
+        ip.as_deref(),
+        ua.as_deref(),
+        metadata,
+    )
+    .await
+    {
+        tracing::warn!("audit_log write failed ({action}): {e:#}");
+    }
+}
+
+fn audit_action_label(action: &str) -> &'static str {
+    match action {
+        "login.success" => "Signed in",
+        "login.failure" => "Failed sign-in",
+        "logout" => "Signed out",
+        "ssh_key.add" => "Added SSH key",
+        "ssh_key.delete" => "Removed SSH key",
+        "gpg_key.add" => "Added GPG key",
+        "gpg_key.delete" => "Removed GPG key",
+        "session.revoke" => "Revoked session",
+        "session.revoke_others" => "Revoked other sessions",
+        "mfa.enable" => "Enabled MFA",
+        "mfa.disable" => "Disabled MFA",
+        "username.change" => "Changed username",
+        "password.change" => "Changed password",
+        "email.add" => "Added email",
+        "email.delete" => "Removed email",
+        "privacy.update" => "Updated privacy settings",
+        _ => "Account activity",
+    }
+}
+
+pub fn audit_entries_view(rows: Vec<crate::db::models::AuditLog>) -> Vec<AuditEntryView> {
+    rows.into_iter()
+        .map(|e| {
+            let meta = if e.metadata.is_null() || e.metadata == serde_json::json!({}) {
+                String::new()
+            } else {
+                e.metadata.to_string()
+            };
+            AuditEntryView {
+                action: e.action.clone(),
+                action_label: audit_action_label(&e.action).to_string(),
+                ip: e.ip.unwrap_or_default(),
+                user_agent: e.user_agent.unwrap_or_default(),
+                metadata: meta,
+                created_at: e.created_at,
+            }
+        })
+        .collect()
+}
+
 pub fn avatar_url_for(user: &User) -> String {
     let bust = user.updated_at.timestamp();
     // Always go through our avatar endpoint when a local file exists.
@@ -939,22 +1028,46 @@ pub struct LoginForm {
 
 pub async fn auth_login_submit(
     State(state): State<AppState>,
+    headers: HeaderMap,
     Form(form): Form<LoginForm>,
 ) -> AppResult<Response> {
     match auth::login_with_password(&state.auth, &form.username, &form.password).await {
-        Ok(LoginOutcome::Complete { token, .. }) => Ok(redirect_with_cookies(
-            "/",
-            vec![
-                session_cookie_header(&token, 14 * 24 * 3600),
-                clear_mfa_pending_cookie(),
-            ],
-        )),
+        Ok(LoginOutcome::Complete { user, token }) => {
+            record_audit(
+                &state,
+                &headers,
+                user.id,
+                Some(user.id),
+                "login.success",
+                serde_json::json!({ "method": "password" }),
+            )
+            .await;
+            Ok(redirect_with_cookies(
+                "/",
+                vec![
+                    session_cookie_header(&token, 14 * 24 * 3600),
+                    clear_mfa_pending_cookie(),
+                ],
+            ))
+        }
         Ok(LoginOutcome::MfaRequired { pending_token }) => Ok(redirect_with_cookie(
             "/auth/mfa",
             mfa_pending_cookie_header(&pending_token, 10 * 60),
         )),
         Err(e) => {
             tracing::warn!("login failed: {e:#}");
+            let uname = form.username.trim().to_ascii_lowercase();
+            if let Ok(Some(u)) = queries::get_user_by_username(&state.pool, &uname).await {
+                record_audit(
+                    &state,
+                    &headers,
+                    u.id,
+                    None,
+                    "login.failure",
+                    serde_json::json!({ "method": "password", "username": uname }),
+                )
+                .await;
+            }
             Ok(LoginTemplate {
                 viewer: None,
                 error: Some(crate::mfa::sanitize_user_error(&e.to_string())),
@@ -995,13 +1108,24 @@ pub async fn auth_mfa_submit(
         return Ok(redirect("/auth/login"));
     };
     match auth::complete_mfa_login(&state.auth, &pending, &form.code).await {
-        Ok((_user, token)) => Ok(redirect_with_cookies(
-            "/",
-            vec![
-                session_cookie_header(&token, 14 * 24 * 3600),
-                clear_mfa_pending_cookie(),
-            ],
-        )),
+        Ok((user, token)) => {
+            record_audit(
+                &state,
+                &headers,
+                user.id,
+                Some(user.id),
+                "login.success",
+                serde_json::json!({ "method": "password+mfa" }),
+            )
+            .await;
+            Ok(redirect_with_cookies(
+                "/",
+                vec![
+                    session_cookie_header(&token, 14 * 24 * 3600),
+                    clear_mfa_pending_cookie(),
+                ],
+            ))
+        }
         Err(e) => {
             tracing::warn!("mfa challenge failed: {e:#}");
             let msg = crate::mfa::sanitize_user_error(&e.to_string());
@@ -1151,6 +1275,7 @@ pub struct CallbackQuery {
 
 pub async fn auth_callback(
     State(state): State<AppState>,
+    headers: HeaderMap,
     Query(q): Query<CallbackQuery>,
 ) -> AppResult<Response> {
     if let Some(err) = q.error {
@@ -1158,7 +1283,16 @@ pub async fn auth_callback(
     }
     let code = q.code.ok_or_else(|| AppError::bad("missing code"))?;
     let st = q.state.ok_or_else(|| AppError::bad("missing state"))?;
-    let (_user, token) = auth::finish_login(&state.auth, &code, &st).await?;
+    let (user, token) = auth::finish_login(&state.auth, &code, &st).await?;
+    record_audit(
+        &state,
+        &headers,
+        user.id,
+        Some(user.id),
+        "login.success",
+        serde_json::json!({ "method": "oidc" }),
+    )
+    .await;
     let cookie = session_cookie_header(&token, 14 * 24 * 3600);
     Ok(redirect_with_cookie("/", cookie))
 }
@@ -1167,6 +1301,17 @@ pub async fn auth_logout(
     State(state): State<AppState>,
     headers: HeaderMap,
 ) -> AppResult<Response> {
+    if let Ok(Some(user)) = current_user(&state.auth, &headers).await {
+        record_audit(
+            &state,
+            &headers,
+            user.id,
+            Some(user.id),
+            "logout",
+            serde_json::json!({}),
+        )
+        .await;
+    }
     auth::logout(&state.auth, &headers).await?;
     Ok(redirect_with_cookie("/", clear_session_cookie()))
 }
@@ -1325,6 +1470,24 @@ pub async fn admin_panel(
     })
 }
 
+pub async fn admin_user_audit(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path(username): Path<String>,
+) -> AppResult<impl IntoResponse> {
+    let viewer = require_site_admin(&state.auth, &headers).await?;
+    let user = queries::get_user_by_username(&state.pool, &username)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    let audit_rows = queries::list_audit_log_for_user(&state.pool, user.id, 100).await?;
+    let audit_entries = audit_entries_view(audit_rows);
+    Ok(AdminUserAuditTemplate {
+        viewer: Some(viewer),
+        user,
+        audit_entries,
+    })
+}
+
 #[derive(Deserialize)]
 pub struct AdminToggleForm {
     pub user_id: Uuid,
@@ -1779,6 +1942,15 @@ pub async fn keys_add(
     queries::add_ssh_key(&state.pool, user.id, name, public_key, &fp, key_usage)
         .await
         .map_err(|e| AppError::bad(format!("could not add key: {e}")))?;
+    record_audit(
+        &state,
+        &headers,
+        user.id,
+        Some(user.id),
+        "ssh_key.add",
+        serde_json::json!({ "name": name, "fingerprint": fp, "key_usage": key_usage }),
+    )
+    .await;
     Ok(redirect_see_other("/settings/keys"))
 }
 
@@ -1805,6 +1977,15 @@ pub async fn keys_delete(
 ) -> AppResult<Response> {
     let user = require_login(&state.auth, &headers).await?;
     queries::delete_ssh_key(&state.pool, user.id, id).await?;
+    record_audit(
+        &state,
+        &headers,
+        user.id,
+        Some(user.id),
+        "ssh_key.delete",
+        serde_json::json!({ "key_id": id }),
+    )
+    .await;
     Ok(redirect_see_other("/settings/keys"))
 }
 
diff --git a/src/web/routes_extra.rs b/src/web/routes_extra.rs
index 712ab5f..ffda9ae 100644
--- a/src/web/routes_extra.rs
+++ b/src/web/routes_extra.rs
@@ -1,7 +1,8 @@
 //! Social, account, branch, raw, and admin helpers.
 
 use super::routes::{
-    avatar_url_for, load_repo_context, redirect_see_other, require_login, AppError, AppResult,
+    audit_entries_view, avatar_url_for, load_repo_context, record_audit, redirect_see_other,
+    require_login, AppError, AppResult,
 };
 use crate::auth::{self, clear_session_cookie, hash_token, token_from_headers};
 use crate::db::queries;
@@ -414,12 +415,15 @@ pub async fn account_settings(
             }
         })
         .collect();
+    let audit_rows = queries::list_audit_log_for_user(&state.pool, user.id, 50).await?;
+    let audit_entries = audit_entries_view(audit_rows);
     Ok(AccountSettingsTemplate {
         viewer: Some(user.clone()),
         user,
         emails,
         sessions,
         current_session_id,
+        audit_entries,
         error: None,
         message: None,
     })
@@ -517,6 +521,15 @@ pub async fn mfa_confirm(
     let hashes: Vec<String> = codes.iter().map(|c| crate::mfa::hash_recovery_code(c)).collect();
     let secret = pending.to_string();
     queries::mfa_confirm_enroll(&state.pool, user.id, &secret, &hashes).await?;
+    record_audit(
+        &state,
+        &headers,
+        user.id,
+        Some(user.id),
+        "mfa.enable",
+        serde_json::json!({}),
+    )
+    .await;
     let mfa = queries::get_user_mfa(&state.pool, user.id).await?;
     Ok(mfa_page(
         user,
@@ -566,6 +579,15 @@ pub async fn mfa_disable(
         .into_response());
     }
     queries::mfa_disable(&state.pool, user.id).await?;
+    record_audit(
+        &state,
+        &headers,
+        user.id,
+        Some(user.id),
+        "mfa.disable",
+        serde_json::json!({}),
+    )
+    .await;
     Ok(redirect_see_other("/settings/mfa").into_response())
 }
 
@@ -610,6 +632,15 @@ pub async fn account_change_username(
             std::fs::rename(&old_root, &new_root)?;
         }
         queries::update_username(&state.pool, user.id, &username).await?;
+        record_audit(
+            &state,
+            &headers,
+            user.id,
+            Some(user.id),
+            "username.change",
+            serde_json::json!({ "from": user.username, "to": username }),
+        )
+        .await;
     }
     Ok(redirect_see_other("/settings/account"))
 }
@@ -639,6 +670,15 @@ pub async fn account_change_password(
     auth::authentik_set_password(&state.auth, pk, &form.new_password)
         .await
         .map_err(|e| AppError::bad(crate::mfa::sanitize_user_error(&e.to_string())))?;
+    record_audit(
+        &state,
+        &headers,
+        user.id,
+        Some(user.id),
+        "password.change",
+        serde_json::json!({}),
+    )
+    .await;
     Ok(redirect_see_other("/settings/account"))
 }
 
@@ -661,6 +701,18 @@ pub async fn account_privacy(
         checkbox(&form.vigilant_mode),
     )
     .await?;
+    record_audit(
+        &state,
+        &headers,
+        user.id,
+        Some(user.id),
+        "privacy.update",
+        serde_json::json!({
+            "show_email": checkbox(&form.show_email),
+            "vigilant_mode": checkbox(&form.vigilant_mode),
+        }),
+    )
+    .await;
     Ok(redirect_see_other("/settings/account"))
 }
 
@@ -680,6 +732,15 @@ pub async fn account_add_email(
         return Err(AppError::bad("invalid email"));
     }
     queries::add_user_email(&state.pool, user.id, email).await?;
+    record_audit(
+        &state,
+        &headers,
+        user.id,
+        Some(user.id),
+        "email.add",
+        serde_json::json!({ "email": email }),
+    )
+    .await;
     Ok(redirect_see_other("/settings/account"))
 }
 
@@ -690,6 +751,15 @@ pub async fn account_delete_email(
 ) -> AppResult<Response> {
     let user = require_login(&state.auth, &headers).await?;
     queries::delete_user_email(&state.pool, id, user.id).await?;
+    record_audit(
+        &state,
+        &headers,
+        user.id,
+        Some(user.id),
+        "email.delete",
+        serde_json::json!({ "email_id": id }),
+    )
+    .await;
     Ok(redirect_see_other("/settings/account"))
 }
 
@@ -700,6 +770,15 @@ pub async fn account_revoke_session(
 ) -> AppResult<Response> {
     let user = require_login(&state.auth, &headers).await?;
     queries::delete_session_by_id(&state.pool, id, user.id).await?;
+    record_audit(
+        &state,
+        &headers,
+        user.id,
+        Some(user.id),
+        "session.revoke",
+        serde_json::json!({ "session_id": id }),
+    )
+    .await;
     Ok(redirect_see_other("/settings/account"))
 }
 
@@ -712,6 +791,15 @@ pub async fn account_revoke_others(
         return Err(AppError::unauthorized());
     };
     queries::delete_other_sessions(&state.pool, user.id, &hash_token(&token)).await?;
+    record_audit(
+        &state,
+        &headers,
+        user.id,
+        Some(user.id),
+        "session.revoke_others",
+        serde_json::json!({}),
+    )
+    .await;
     Ok(redirect_see_other("/settings/account"))
 }
 
@@ -787,6 +875,15 @@ pub async fn gpg_add(
     let fp = crate::git::verify::gpg_fingerprint_from_armor(key)
         .unwrap_or_else(|_| crate::git::verify::gpg_fingerprint_fallback(key));
     queries::add_gpg_key(&state.pool, user.id, form.name.trim(), key, &fp).await?;
+    record_audit(
+        &state,
+        &headers,
+        user.id,
+        Some(user.id),
+        "gpg_key.add",
+        serde_json::json!({ "name": form.name.trim(), "fingerprint": fp }),
+    )
+    .await;
     Ok(redirect_see_other("/settings/keys"))
 }
 
@@ -797,6 +894,15 @@ pub async fn gpg_delete(
 ) -> AppResult<Response> {
     let user = require_login(&state.auth, &headers).await?;
     queries::delete_gpg_key(&state.pool, id, user.id).await?;
+    record_audit(
+        &state,
+        &headers,
+        user.id,
+        Some(user.id),
+        "gpg_key.delete",
+        serde_json::json!({ "key_id": id }),
+    )
+    .await;
     Ok(redirect_see_other("/settings/keys"))
 }
 
diff --git a/src/web/templates.rs b/src/web/templates.rs
index a2932d4..bb5d803 100644
--- a/src/web/templates.rs
+++ b/src/web/templates.rs
@@ -184,10 +184,20 @@ pub struct AccountSettingsTemplate {
     pub emails: Vec<UserEmail>,
     pub sessions: Vec<SessionView>,
     pub current_session_id: Option<Uuid>,
+    pub audit_entries: Vec<AuditEntryView>,
     pub error: Option<String>,
     pub message: Option<String>,
 }
 
+pub struct AuditEntryView {
+    pub action: String,
+    pub action_label: String,
+    pub ip: String,
+    pub user_agent: String,
+    pub metadata: String,
+    pub created_at: DateTime<Utc>,
+}
+
 #[derive(Template, WebTemplate)]
 #[template(path = "mfa_challenge.html")]
 pub struct MfaChallengeTemplate {
@@ -550,6 +560,14 @@ pub struct AdminTemplate {
     pub flash: Option<String>,
 }
 
+#[derive(Template, WebTemplate)]
+#[template(path = "admin_user_audit.html")]
+pub struct AdminUserAuditTemplate {
+    pub viewer: Option<User>,
+    pub user: User,
+    pub audit_entries: Vec<AuditEntryView>,
+}
+
 pub struct AdminInviteView {
     pub id: Uuid,
     pub code: String,
diff --git a/templates/account_settings.html b/templates/account_settings.html
index 248b87f..de6841d 100644
--- a/templates/account_settings.html
+++ b/templates/account_settings.html
@@ -131,6 +131,34 @@
   </div>
 
   <div class="kg-settings-section">
+    <span class="kg-kicker">security audit log</span>
+    <p class="kg-muted">Recent sign-ins and account changes for this account.</p>
+    {% if audit_entries.is_empty() %}
+      <p class="kg-muted">No audit events yet.</p>
+    {% else %}
+      <ul class="kg-list" style="margin-top:0.75rem">
+        {% for e in audit_entries %}
+        <li>
+          <span>
+            <strong>{{ e.action_label }}</strong>
+            <span class="kg-meta"> · {{ e.created_at }}</span>
+            {% if !e.ip.is_empty() %}
+              <span class="kg-meta"> · {{ e.ip }}</span>
+            {% endif %}
+            {% if !e.user_agent.is_empty() %}
+              <span class="kg-meta"> · {{ e.user_agent }}</span>
+            {% endif %}
+            {% if !e.metadata.is_empty() %}
+              <span class="kg-meta"> · <code>{{ e.metadata }}</code></span>
+            {% endif %}
+          </span>
+        </li>
+        {% endfor %}
+      </ul>
+    {% endif %}
+  </div>
+
+  <div class="kg-settings-section">
     <span class="kg-kicker">GDPR</span>
     <div class="kg-actions">
       <a class="kg-btn kg-btn--ghost" href="/settings/account/export">export my data</a>
diff --git a/templates/admin.html b/templates/admin.html
index f16f5c8..2723110 100644
--- a/templates/admin.html
+++ b/templates/admin.html
@@ -104,6 +104,7 @@
         </span>
       </div>
       <div class="kg-admin-actions">
+        <a class="kg-btn kg-btn--ghost" href="/admin/users/{{ u.username }}/audit">audit</a>
         <form method="post" action="/admin/users/suspend" style="display:inline">
           <input type="hidden" name="user_id" value="{{ u.id }}" />
           {% if u.is_suspended %}
diff --git a/templates/admin_user_audit.html b/templates/admin_user_audit.html
new file mode 100644
index 0000000..5dc5a6b
--- /dev/null
+++ b/templates/admin_user_audit.html
@@ -0,0 +1,37 @@
+{% extends "layout.html" %}
+{% block title %}audit — {{ user.username }} — kitgit{% endblock %}
+{% block content %}
+<section class="kg-section">
+  <span class="kg-kicker">admin</span>
+  <h1 class="kg-title">Audit log · {{ user.username }}</h1>
+  <p class="kg-muted">
+    <a href="/admin">← back to admin</a>
+    · <a href="/{{ user.username }}">profile</a>
+  </p>
+
+  {% if audit_entries.is_empty() %}
+    <p class="kg-muted" style="margin-top:1.5rem">No audit events for this user.</p>
+  {% else %}
+    <ul class="kg-list" style="margin-top:1.5rem">
+      {% for e in audit_entries %}
+      <li>
+        <span>
+          <strong>{{ e.action_label }}</strong>
+          <code class="kg-meta">{{ e.action }}</code>
+          <span class="kg-meta"> · {{ e.created_at }}</span>
+          {% if !e.ip.is_empty() %}
+            <span class="kg-meta"> · {{ e.ip }}</span>
+          {% endif %}
+          {% if !e.user_agent.is_empty() %}
+            <span class="kg-meta"> · {{ e.user_agent }}</span>
+          {% endif %}
+          {% if !e.metadata.is_empty() %}
+            <span class="kg-meta"> · <code>{{ e.metadata }}</code></span>
+          {% endif %}
+        </span>
+      </li>
+      {% endfor %}
+    </ul>
+  {% endif %}
+</section>
+{% endblock %}