//! Pages: the admin audit log (`/admin/audit`, instance admins only, 404 otherwise) and a user's //! own security log (`/settings/security/log`: entries the viewer acted in, or that targeted //! them). Both list newest first, 50 per page, continued with a cursor. use forge_core::tz::format_in_tz; use forge_core::{AuditAction, AuditCursor, AuditEntry, AuditFilter, Store}; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::{page, query_params}; use topcoat::view::{View, component, view}; use crate::auth::require_user; use crate::i18n::admin::{self as g, K}; use crate::i18n::{self, Lang}; use crate::pages::admin_users::admin_only; use crate::pages::issues_list::{list_href, stamp_unix}; use crate::state::AppState; use crate::views::shell; /// Entries per page. const PAGE_SIZE: usize = 50; #[query_params(error = bad_request)] pub(crate) struct AuditQuery { action: Option, actor: Option, before: Option, } /// One table row, already formatted for display. struct RowView { when: String, actor: String, action: String, target: String, ip: String, details: String, } fn row_view(entry: &AuditEntry, tz: &str, lang: Lang) -> RowView { let actor = match (entry.actor_name.as_str(), entry.actor_id.is_some()) { ("", _) => g::t(lang, K::Anonymous).to_owned(), (name, _) => name.to_owned(), }; let name = if entry.target_name.is_empty() { entry.target_id.as_str() } else { entry.target_name.as_str() }; let target = if entry.target_kind.is_empty() { name.to_owned() } else { format!("{}: {name}", entry.target_kind) }; RowView { when: format_in_tz(stamp_unix(&entry.at), tz), actor, action: entry.action.as_str().to_owned(), target, ip: entry.ip.clone(), details: if entry.data == "{}" { String::new() } else { entry.data.clone() }, } } /// One page of entries, and the cursor for the page after it when there may be one. async fn fetch_page( store: &Store, filter: &AuditFilter, before: Option<&str>, ) -> forge_core::Result<(Vec, Option)> { let cursor = before.and_then(AuditCursor::decode); let mut entries = store .list_audit(filter, cursor.as_ref(), (PAGE_SIZE + 1) as u32) .await?; let more = entries.len() > PAGE_SIZE; entries.truncate(PAGE_SIZE); let next = if more { entries .last() .map(|entry| AuditCursor::after(entry).encode()) } else { None }; Ok((entries, next)) } #[component] async fn audit_table(lang: Lang, rows: Vec, show_actor: bool) -> Result { Ok(view! { if rows.is_empty() {

(g::t(lang, K::NoEntries))

} else {
if show_actor { } for row in rows.into_iter() { if show_actor { } }
(g::t(lang, K::TableWhen))(g::t(lang, K::TableActor))(g::t(lang, K::ActionLabel)) (g::t(lang, K::TableTarget)) "IP" (g::t(lang, K::TableDetails))
(row.when.as_str())(row.actor.as_str())(row.action.as_str()) (row.target.as_str()) (row.ip.as_str()) (row.details.as_str())
} }) } #[component] async fn admin_audit_view( lang: Lang, action: String, actor: String, notice: Option, rows: Vec, older_href: Option, ) -> Result { Ok(view! {

(g::t(lang, K::AuditLog))

(g::t(lang, K::AuditIntro))

if let Some(message) = notice {

(message.as_str())

}
audit_table(lang: lang, rows: rows, show_actor: true)
}) } #[component] async fn own_log_view( lang: Lang, rows: Vec, older_href: Option, ) -> Result { Ok(view! {

(g::t(lang, K::SecurityLogTitle))

(g::t(lang, K::OwnLogIntro))

audit_table(lang: lang, rows: rows, show_actor: false)
}) } #[page("/admin/audit")] async fn admin_audit_page(cx: &Cx) -> Result { let viewer = admin_only(cx).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let query = query_params::(cx)?; let store = &app_context::(cx).store; let action = query.action.clone().unwrap_or_default().trim().to_owned(); let actor = query.actor.clone().unwrap_or_default().trim().to_owned(); let mut filter = AuditFilter::default(); let mut notice: Option = None; if !action.is_empty() { match AuditAction::parse(&action) { Some(known) => filter.action = Some(known), None => notice = Some(g::no_action_named(lang, &action)), } } if !actor.is_empty() { match store.user_by_username(&actor).await? { Some(user) => filter.actor_id = Some(user.id), None => notice = Some(g::no_user_named(lang, &actor)), } } // An unknown action or actor matches nothing, so the notice explains the empty table. let (entries, next) = if notice.is_some() { (Vec::new(), None) } else { fetch_page(store, &filter, query.before.as_deref()).await? }; let tz = viewer.0.timezone.as_str(); let rows: Vec = entries .iter() .map(|entry| row_view(entry, tz, lang)) .collect(); let older_href = next.map(|cursor| { list_href( "/admin/audit", &[ ("action", action.as_str()), ("actor", actor.as_str()), ("before", cursor.as_str()), ], ) }); let title = g::t(lang, K::AuditLog); Ok(view! { shell(title: title, viewer: Some(&viewer), admin_audit_view(lang: lang, action: action, actor: actor, notice: notice, rows: rows, older_href: older_href)) }) } #[page("/settings/security/log")] async fn own_log_page(cx: &Cx) -> Result { let viewer = require_user(cx).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let query = query_params::(cx)?; let store = &app_context::(cx).store; let filter = AuditFilter { involving_user: Some(viewer.0.id.clone()), ..Default::default() }; let (entries, next) = fetch_page(store, &filter, query.before.as_deref()).await?; let tz = viewer.0.timezone.as_str(); let rows: Vec = entries .iter() .map(|entry| row_view(entry, tz, lang)) .collect(); let older_href = next.map(|cursor| list_href("/settings/security/log", &[("before", cursor.as_str())])); let title = g::t(lang, K::SecurityLogTitle); Ok(view! { shell(title: title, viewer: Some(&viewer), own_log_view(lang: lang, rows: rows, older_href: older_href)) }) } #[cfg(test)] mod tests { use super::*; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::auth::SESSION_COOKIE; use crate::pages::settings_profile::tests::{CSRF, TOKEN, fixture}; const ADMIN_TOKEN: &str = "audit-admin-session"; const ADMIN_CSRF: &str = "audit-admin-csrf"; /// A valid ed25519 public key for the key-add test. const ED25519: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPTx1OLAmIEVXGSZou6JBQpDthDq02k21xOH+ZjDgGCT test@forge"; /// Sends one request, optionally with a session cookie, and returns the status and body. async fn send( router: &Router, method: &str, uri: &str, token: Option<&str>, form: Option, ) -> (u16, String) { let mut builder = Request::builder().method(method).uri(uri); if let Some(token) = token { builder = builder.header("cookie", format!("__Host-{SESSION_COOKIE}={token}")); } let body = match form { Some(form) => { builder = builder.header("content-type", "application/x-www-form-urlencoded"); Body::from(form) } None => Body::empty(), }; let res = router.handle(builder.body(body).unwrap()).await; let status = res.status().as_u16(); let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); (status, String::from_utf8_lossy(&bytes).into_owned()) } #[tokio::test] async fn admin_page_is_404_for_anonymous_and_non_admins() { let fx = fixture().await; let (anon, _) = send(&fx.router, "GET", "/admin/audit", None, None).await; assert_eq!(anon, 404); let (user, _) = send(&fx.router, "GET", "/admin/audit", Some(TOKEN), None).await; assert_eq!(user, 404); } #[tokio::test] async fn admin_page_lists_entries_and_filters_by_action() { let fx = fixture().await; let admin = fx.store.create_user("root", None, "h", true).await.unwrap(); crate::test_support::create_session(&fx.store, &admin.id, ADMIN_TOKEN, ADMIN_CSRF).await; let mut entry = AuditEntry::new(AuditAction::LoginFailed); entry.target_name = "ghost-user".into(); fx.store.record_audit(&entry).await.unwrap(); let (status, body) = send(&fx.router, "GET", "/admin/audit", Some(ADMIN_TOKEN), None).await; assert_eq!(status, 200); assert!(body.contains("ghost-user")); let (status, body) = send( &fx.router, "GET", "/admin/audit?action=login_failed", Some(ADMIN_TOKEN), None, ) .await; assert_eq!(status, 200); assert!(body.contains("ghost-user")); let (_, body) = send( &fx.router, "GET", "/admin/audit?action=logout", Some(ADMIN_TOKEN), None, ) .await; assert!(!body.contains("ghost-user")); let (_, body) = send( &fx.router, "GET", "/admin/audit?action=nonsense", Some(ADMIN_TOKEN), None, ) .await; assert!(body.contains("There is no action named nonsense.")); } #[tokio::test] async fn adding_an_ssh_key_is_recorded() { let fx = fixture().await; let form = form_urlencoded::Serializer::new(String::new()) .append_pair("csrf", CSRF) .append_pair("title", "laptop") .append_pair("public_key", ED25519) .finish(); let (status, _) = send( &fx.router, "POST", "/settings/keys", Some(TOKEN), Some(form), ) .await; assert_eq!(status, 303); let filter = AuditFilter { action: Some(AuditAction::SshKeyAdded), ..Default::default() }; let rows = fx.store.list_audit(&filter, None, 10).await.unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].actor_id.as_deref(), Some(fx.user.id.as_str())); assert_eq!(rows[0].target_name, "laptop"); assert_eq!(rows[0].target_kind, "ssh_key"); } #[tokio::test] async fn own_log_shows_only_entries_about_the_viewer() { let fx = fixture().await; let bob = fx.store.create_user("bob", None, "h", false).await.unwrap(); let mut mine = AuditEntry::new(AuditAction::LoginSuccess); mine.actor_id = Some(fx.user.id.clone()); mine.actor_name = fx.user.username.clone(); fx.store.record_audit(&mine).await.unwrap(); let mut theirs = AuditEntry::new(AuditAction::LoginSuccess); theirs.actor_id = Some(bob.id.clone()); theirs.actor_name = "bob".into(); fx.store.record_audit(&theirs).await.unwrap(); // An admin action on the viewer is about the viewer, so it shows up. let mut about_me = AuditEntry::new(AuditAction::PasswordResetByAdmin); about_me.actor_id = Some(bob.id.clone()); about_me.target_kind = "user".into(); about_me.target_id = fx.user.id.clone(); fx.store.record_audit(&about_me).await.unwrap(); let (status, body) = send( &fx.router, "GET", "/settings/security/log", Some(TOKEN), None, ) .await; assert_eq!(status, 200); assert_eq!(body.matches("login_success").count(), 1); assert!(body.contains("password_reset_by_admin")); } #[tokio::test] async fn own_log_requires_a_session() { let fx = fixture().await; let (status, _) = send(&fx.router, "GET", "/settings/security/log", None, None).await; assert_eq!(status, 303); } #[test] fn anonymous_actor_and_target_fallbacks() { let mut entry = AuditEntry::new(AuditAction::LoginFailed); entry.target_name = "mallory".into(); let row = row_view(&entry, "UTC", Lang::En); assert_eq!(row.actor, "anonymous"); assert_eq!(row.target, "mallory"); assert_eq!(row.details, ""); } }