tirbofish/kitgit · commit
dcc7669c97978c6de7f8b8c02107b1bddc76281c
feat: add per-user security audit log
Type: SSH
SSH Key Fingerprint:
Verified
SgQHY4vUORbJC3ZtdixCl62ek/4QGh/iG2FRSyjfGPc
@@ -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); @@ -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, @@ -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?) +} @@ -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)) @@ -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")) } @@ -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")) } @@ -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, @@ -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> @@ -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 %} @@ -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 %}