Tahinli/forge
public0 watching
★ 0
| 1 | //! Pages: the admin audit log (`/admin/audit`, instance admins only, 404 otherwise) and a user's |
| 2 | //! own security log (`/settings/security/log`: entries the viewer acted in, or that targeted |
| 3 | //! them). Both list newest first, 50 per page, continued with a cursor. |
| 4 | |
| 5 | use forge_core::tz::format_in_tz; |
| 6 | use forge_core::{AuditAction, AuditCursor, AuditEntry, AuditFilter, Store}; |
| 7 | use topcoat::Result; |
| 8 | use topcoat::context::{Cx, app_context}; |
| 9 | use topcoat::router::{page, query_params}; |
| 10 | use topcoat::view::{View, component, view}; |
| 11 | |
| 12 | use crate::auth::require_user; |
| 13 | use crate::i18n::admin::{self as g, K}; |
| 14 | use crate::i18n::{self, Lang}; |
| 15 | use crate::pages::admin_users::admin_only; |
| 16 | use crate::pages::issues_list::{list_href, stamp_unix}; |
| 17 | use crate::state::AppState; |
| 18 | use crate::views::shell; |
| 19 | |
| 20 | /// Entries per page. |
| 21 | const PAGE_SIZE: usize = 50; |
| 22 | |
| 23 | #[query_params(error = bad_request)] |
| 24 | pub(crate) struct AuditQuery { |
| 25 | action: Option<String>, |
| 26 | actor: Option<String>, |
| 27 | before: Option<String>, |
| 28 | } |
| 29 | |
| 30 | /// One table row, already formatted for display. |
| 31 | struct RowView { |
| 32 | when: String, |
| 33 | actor: String, |
| 34 | action: String, |
| 35 | target: String, |
| 36 | ip: String, |
| 37 | details: String, |
| 38 | } |
| 39 | |
| 40 | fn row_view(entry: &AuditEntry, tz: &str, lang: Lang) -> RowView { |
| 41 | let actor = match (entry.actor_name.as_str(), entry.actor_id.is_some()) { |
| 42 | ("", _) => g::t(lang, K::Anonymous).to_owned(), |
| 43 | (name, _) => name.to_owned(), |
| 44 | }; |
| 45 | let name = if entry.target_name.is_empty() { |
| 46 | entry.target_id.as_str() |
| 47 | } else { |
| 48 | entry.target_name.as_str() |
| 49 | }; |
| 50 | let target = if entry.target_kind.is_empty() { |
| 51 | name.to_owned() |
| 52 | } else { |
| 53 | format!("{}: {name}", entry.target_kind) |
| 54 | }; |
| 55 | RowView { |
| 56 | when: format_in_tz(stamp_unix(&entry.at), tz), |
| 57 | actor, |
| 58 | action: entry.action.as_str().to_owned(), |
| 59 | target, |
| 60 | ip: entry.ip.clone(), |
| 61 | details: if entry.data == "{}" { |
| 62 | String::new() |
| 63 | } else { |
| 64 | entry.data.clone() |
| 65 | }, |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | /// One page of entries, and the cursor for the page after it when there may be one. |
| 70 | async fn fetch_page( |
| 71 | store: &Store, |
| 72 | filter: &AuditFilter, |
| 73 | before: Option<&str>, |
| 74 | ) -> forge_core::Result<(Vec<AuditEntry>, Option<String>)> { |
| 75 | let cursor = before.and_then(AuditCursor::decode); |
| 76 | let mut entries = store |
| 77 | .list_audit(filter, cursor.as_ref(), (PAGE_SIZE + 1) as u32) |
| 78 | .await?; |
| 79 | let more = entries.len() > PAGE_SIZE; |
| 80 | entries.truncate(PAGE_SIZE); |
| 81 | let next = if more { |
| 82 | entries |
| 83 | .last() |
| 84 | .map(|entry| AuditCursor::after(entry).encode()) |
| 85 | } else { |
| 86 | None |
| 87 | }; |
| 88 | Ok((entries, next)) |
| 89 | } |
| 90 | |
| 91 | #[component] |
| 92 | async fn audit_table(lang: Lang, rows: Vec<RowView>, show_actor: bool) -> Result<impl View> { |
| 93 | Ok(view! { |
| 94 | if rows.is_empty() { |
| 95 | <p class="muted">(g::t(lang, K::NoEntries))</p> |
| 96 | } else { |
| 97 | <div class="table-wrap"> |
| 98 | <table class="table"> |
| 99 | <thead> |
| 100 | <tr> |
| 101 | <th>(g::t(lang, K::TableWhen))</th> |
| 102 | if show_actor { |
| 103 | <th>(g::t(lang, K::TableActor))</th> |
| 104 | } |
| 105 | <th>(g::t(lang, K::ActionLabel))</th> |
| 106 | <th>(g::t(lang, K::TableTarget))</th> |
| 107 | <th>"IP"</th> |
| 108 | <th>(g::t(lang, K::TableDetails))</th> |
| 109 | </tr> |
| 110 | </thead> |
| 111 | <tbody> |
| 112 | for row in rows.into_iter() { |
| 113 | <tr> |
| 114 | <td class="small">(row.when.as_str())</td> |
| 115 | if show_actor { |
| 116 | <td>(row.actor.as_str())</td> |
| 117 | } |
| 118 | <td><code>(row.action.as_str())</code></td> |
| 119 | <td>(row.target.as_str())</td> |
| 120 | <td class="small">(row.ip.as_str())</td> |
| 121 | <td class="small">(row.details.as_str())</td> |
| 122 | </tr> |
| 123 | } |
| 124 | </tbody> |
| 125 | </table> |
| 126 | </div> |
| 127 | } |
| 128 | }) |
| 129 | } |
| 130 | |
| 131 | #[component] |
| 132 | async fn admin_audit_view( |
| 133 | lang: Lang, |
| 134 | action: String, |
| 135 | actor: String, |
| 136 | notice: Option<String>, |
| 137 | rows: Vec<RowView>, |
| 138 | older_href: Option<String>, |
| 139 | ) -> Result<impl View> { |
| 140 | Ok(view! { |
| 141 | <section class="page-head"> |
| 142 | <h1>(g::t(lang, K::AuditLog))</h1> |
| 143 | <p class="muted">(g::t(lang, K::AuditIntro))</p> |
| 144 | </section> |
| 145 | <section class="card"> |
| 146 | <form method="get" action="/admin/audit" class="stack"> |
| 147 | <label> |
| 148 | <span>(g::t(lang, K::ActionLabel))</span> |
| 149 | <select name="action"> |
| 150 | <option value="" selected=(action.is_empty())>(g::t(lang, K::AllActions))</option> |
| 151 | for known in AuditAction::ALL.iter().copied() { |
| 152 | <option value=(known.as_str()) selected=(known.as_str() == action.as_str())>(known.as_str())</option> |
| 153 | } |
| 154 | </select> |
| 155 | </label> |
| 156 | <label> |
| 157 | <span>(g::t(lang, K::ActorUsername))</span> |
| 158 | <input type="text" name="actor" value=(actor.as_str()) autocomplete="off"> |
| 159 | </label> |
| 160 | <button type="submit" class="primary">(g::t(lang, K::Filter))</button> |
| 161 | </form> |
| 162 | </section> |
| 163 | if let Some(message) = notice { |
| 164 | <section class="card notice" role="status"> |
| 165 | <p>(message.as_str())</p> |
| 166 | </section> |
| 167 | } |
| 168 | <section class="card"> |
| 169 | audit_table(lang: lang, rows: rows, show_actor: true) |
| 170 | <nav class="pager"> |
| 171 | if let Some(href) = older_href { |
| 172 | <a href=(href.as_str())>(g::t(lang, K::Older))</a> |
| 173 | } |
| 174 | </nav> |
| 175 | </section> |
| 176 | }) |
| 177 | } |
| 178 | |
| 179 | #[component] |
| 180 | async fn own_log_view( |
| 181 | lang: Lang, |
| 182 | rows: Vec<RowView>, |
| 183 | older_href: Option<String>, |
| 184 | ) -> Result<impl View> { |
| 185 | Ok(view! { |
| 186 | <section class="page-head"> |
| 187 | <h1>(g::t(lang, K::SecurityLogTitle))</h1> |
| 188 | <p class="muted">(g::t(lang, K::OwnLogIntro))</p> |
| 189 | </section> |
| 190 | <section class="card"> |
| 191 | audit_table(lang: lang, rows: rows, show_actor: false) |
| 192 | <nav class="pager"> |
| 193 | if let Some(href) = older_href { |
| 194 | <a href=(href.as_str())>(g::t(lang, K::Older))</a> |
| 195 | } |
| 196 | </nav> |
| 197 | </section> |
| 198 | }) |
| 199 | } |
| 200 | |
| 201 | #[page("/admin/audit")] |
| 202 | async fn admin_audit_page(cx: &Cx) -> Result<impl View> { |
| 203 | let viewer = admin_only(cx).await?; |
| 204 | let lang = i18n::lang(cx, Some(&viewer.0)); |
| 205 | let query = query_params::<AuditQuery>(cx)?; |
| 206 | let store = &app_context::<AppState>(cx).store; |
| 207 | let action = query.action.clone().unwrap_or_default().trim().to_owned(); |
| 208 | let actor = query.actor.clone().unwrap_or_default().trim().to_owned(); |
| 209 | |
| 210 | let mut filter = AuditFilter::default(); |
| 211 | let mut notice: Option<String> = None; |
| 212 | if !action.is_empty() { |
| 213 | match AuditAction::parse(&action) { |
| 214 | Some(known) => filter.action = Some(known), |
| 215 | None => notice = Some(g::no_action_named(lang, &action)), |
| 216 | } |
| 217 | } |
| 218 | if !actor.is_empty() { |
| 219 | match store.user_by_username(&actor).await? { |
| 220 | Some(user) => filter.actor_id = Some(user.id), |
| 221 | None => notice = Some(g::no_user_named(lang, &actor)), |
| 222 | } |
| 223 | } |
| 224 | // An unknown action or actor matches nothing, so the notice explains the empty table. |
| 225 | let (entries, next) = if notice.is_some() { |
| 226 | (Vec::new(), None) |
| 227 | } else { |
| 228 | fetch_page(store, &filter, query.before.as_deref()).await? |
| 229 | }; |
| 230 | |
| 231 | let tz = viewer.0.timezone.as_str(); |
| 232 | let rows: Vec<RowView> = entries |
| 233 | .iter() |
| 234 | .map(|entry| row_view(entry, tz, lang)) |
| 235 | .collect(); |
| 236 | let older_href = next.map(|cursor| { |
| 237 | list_href( |
| 238 | "/admin/audit", |
| 239 | &[ |
| 240 | ("action", action.as_str()), |
| 241 | ("actor", actor.as_str()), |
| 242 | ("before", cursor.as_str()), |
| 243 | ], |
| 244 | ) |
| 245 | }); |
| 246 | let title = g::t(lang, K::AuditLog); |
| 247 | Ok(view! { |
| 248 | shell(title: title, viewer: Some(&viewer), admin_audit_view(lang: lang, action: action, actor: actor, notice: notice, rows: rows, older_href: older_href)) |
| 249 | }) |
| 250 | } |
| 251 | |
| 252 | #[page("/settings/security/log")] |
| 253 | async fn own_log_page(cx: &Cx) -> Result<impl View> { |
| 254 | let viewer = require_user(cx).await?; |
| 255 | let lang = i18n::lang(cx, Some(&viewer.0)); |
| 256 | let query = query_params::<AuditQuery>(cx)?; |
| 257 | let store = &app_context::<AppState>(cx).store; |
| 258 | let filter = AuditFilter { |
| 259 | involving_user: Some(viewer.0.id.clone()), |
| 260 | ..Default::default() |
| 261 | }; |
| 262 | let (entries, next) = fetch_page(store, &filter, query.before.as_deref()).await?; |
| 263 | |
| 264 | let tz = viewer.0.timezone.as_str(); |
| 265 | let rows: Vec<RowView> = entries |
| 266 | .iter() |
| 267 | .map(|entry| row_view(entry, tz, lang)) |
| 268 | .collect(); |
| 269 | let older_href = |
| 270 | next.map(|cursor| list_href("/settings/security/log", &[("before", cursor.as_str())])); |
| 271 | let title = g::t(lang, K::SecurityLogTitle); |
| 272 | Ok(view! { |
| 273 | shell(title: title, viewer: Some(&viewer), own_log_view(lang: lang, rows: rows, older_href: older_href)) |
| 274 | }) |
| 275 | } |
| 276 | |
| 277 | #[cfg(test)] |
| 278 | mod tests { |
| 279 | use super::*; |
| 280 | |
| 281 | use topcoat::router::request::Request; |
| 282 | use topcoat::router::{Body, Router, to_bytes}; |
| 283 | |
| 284 | use crate::auth::SESSION_COOKIE; |
| 285 | use crate::pages::settings_profile::tests::{CSRF, TOKEN, fixture}; |
| 286 | |
| 287 | const ADMIN_TOKEN: &str = "audit-admin-session"; |
| 288 | const ADMIN_CSRF: &str = "audit-admin-csrf"; |
| 289 | /// A valid ed25519 public key for the key-add test. |
| 290 | const ED25519: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPTx1OLAmIEVXGSZou6JBQpDthDq02k21xOH+ZjDgGCT test@forge"; |
| 291 | |
| 292 | /// Sends one request, optionally with a session cookie, and returns the status and body. |
| 293 | async fn send( |
| 294 | router: &Router, |
| 295 | method: &str, |
| 296 | uri: &str, |
| 297 | token: Option<&str>, |
| 298 | form: Option<String>, |
| 299 | ) -> (u16, String) { |
| 300 | let mut builder = Request::builder().method(method).uri(uri); |
| 301 | if let Some(token) = token { |
| 302 | builder = builder.header("cookie", format!("__Host-{SESSION_COOKIE}={token}")); |
| 303 | } |
| 304 | let body = match form { |
| 305 | Some(form) => { |
| 306 | builder = builder.header("content-type", "application/x-www-form-urlencoded"); |
| 307 | Body::from(form) |
| 308 | } |
| 309 | None => Body::empty(), |
| 310 | }; |
| 311 | let res = router.handle(builder.body(body).unwrap()).await; |
| 312 | let status = res.status().as_u16(); |
| 313 | let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); |
| 314 | (status, String::from_utf8_lossy(&bytes).into_owned()) |
| 315 | } |
| 316 | |
| 317 | #[tokio::test] |
| 318 | async fn admin_page_is_404_for_anonymous_and_non_admins() { |
| 319 | let fx = fixture().await; |
| 320 | let (anon, _) = send(&fx.router, "GET", "/admin/audit", None, None).await; |
| 321 | assert_eq!(anon, 404); |
| 322 | let (user, _) = send(&fx.router, "GET", "/admin/audit", Some(TOKEN), None).await; |
| 323 | assert_eq!(user, 404); |
| 324 | } |
| 325 | |
| 326 | #[tokio::test] |
| 327 | async fn admin_page_lists_entries_and_filters_by_action() { |
| 328 | let fx = fixture().await; |
| 329 | let admin = fx.store.create_user("root", None, "h", true).await.unwrap(); |
| 330 | crate::test_support::create_session(&fx.store, &admin.id, ADMIN_TOKEN, ADMIN_CSRF).await; |
| 331 | let mut entry = AuditEntry::new(AuditAction::LoginFailed); |
| 332 | entry.target_name = "ghost-user".into(); |
| 333 | fx.store.record_audit(&entry).await.unwrap(); |
| 334 | |
| 335 | let (status, body) = send(&fx.router, "GET", "/admin/audit", Some(ADMIN_TOKEN), None).await; |
| 336 | assert_eq!(status, 200); |
| 337 | assert!(body.contains("ghost-user")); |
| 338 | |
| 339 | let (status, body) = send( |
| 340 | &fx.router, |
| 341 | "GET", |
| 342 | "/admin/audit?action=login_failed", |
| 343 | Some(ADMIN_TOKEN), |
| 344 | None, |
| 345 | ) |
| 346 | .await; |
| 347 | assert_eq!(status, 200); |
| 348 | assert!(body.contains("ghost-user")); |
| 349 | |
| 350 | let (_, body) = send( |
| 351 | &fx.router, |
| 352 | "GET", |
| 353 | "/admin/audit?action=logout", |
| 354 | Some(ADMIN_TOKEN), |
| 355 | None, |
| 356 | ) |
| 357 | .await; |
| 358 | assert!(!body.contains("ghost-user")); |
| 359 | |
| 360 | let (_, body) = send( |
| 361 | &fx.router, |
| 362 | "GET", |
| 363 | "/admin/audit?action=nonsense", |
| 364 | Some(ADMIN_TOKEN), |
| 365 | None, |
| 366 | ) |
| 367 | .await; |
| 368 | assert!(body.contains("There is no action named nonsense.")); |
| 369 | } |
| 370 | |
| 371 | #[tokio::test] |
| 372 | async fn adding_an_ssh_key_is_recorded() { |
| 373 | let fx = fixture().await; |
| 374 | let form = form_urlencoded::Serializer::new(String::new()) |
| 375 | .append_pair("csrf", CSRF) |
| 376 | .append_pair("title", "laptop") |
| 377 | .append_pair("public_key", ED25519) |
| 378 | .finish(); |
| 379 | let (status, _) = send( |
| 380 | &fx.router, |
| 381 | "POST", |
| 382 | "/settings/keys", |
| 383 | Some(TOKEN), |
| 384 | Some(form), |
| 385 | ) |
| 386 | .await; |
| 387 | assert_eq!(status, 303); |
| 388 | |
| 389 | let filter = AuditFilter { |
| 390 | action: Some(AuditAction::SshKeyAdded), |
| 391 | ..Default::default() |
| 392 | }; |
| 393 | let rows = fx.store.list_audit(&filter, None, 10).await.unwrap(); |
| 394 | assert_eq!(rows.len(), 1); |
| 395 | assert_eq!(rows[0].actor_id.as_deref(), Some(fx.user.id.as_str())); |
| 396 | assert_eq!(rows[0].target_name, "laptop"); |
| 397 | assert_eq!(rows[0].target_kind, "ssh_key"); |
| 398 | } |
| 399 | |
| 400 | #[tokio::test] |
| 401 | async fn own_log_shows_only_entries_about_the_viewer() { |
| 402 | let fx = fixture().await; |
| 403 | let bob = fx.store.create_user("bob", None, "h", false).await.unwrap(); |
| 404 | let mut mine = AuditEntry::new(AuditAction::LoginSuccess); |
| 405 | mine.actor_id = Some(fx.user.id.clone()); |
| 406 | mine.actor_name = fx.user.username.clone(); |
| 407 | fx.store.record_audit(&mine).await.unwrap(); |
| 408 | let mut theirs = AuditEntry::new(AuditAction::LoginSuccess); |
| 409 | theirs.actor_id = Some(bob.id.clone()); |
| 410 | theirs.actor_name = "bob".into(); |
| 411 | fx.store.record_audit(&theirs).await.unwrap(); |
| 412 | // An admin action on the viewer is about the viewer, so it shows up. |
| 413 | let mut about_me = AuditEntry::new(AuditAction::PasswordResetByAdmin); |
| 414 | about_me.actor_id = Some(bob.id.clone()); |
| 415 | about_me.target_kind = "user".into(); |
| 416 | about_me.target_id = fx.user.id.clone(); |
| 417 | fx.store.record_audit(&about_me).await.unwrap(); |
| 418 | |
| 419 | let (status, body) = send( |
| 420 | &fx.router, |
| 421 | "GET", |
| 422 | "/settings/security/log", |
| 423 | Some(TOKEN), |
| 424 | None, |
| 425 | ) |
| 426 | .await; |
| 427 | assert_eq!(status, 200); |
| 428 | assert_eq!(body.matches("login_success").count(), 1); |
| 429 | assert!(body.contains("password_reset_by_admin")); |
| 430 | } |
| 431 | |
| 432 | #[tokio::test] |
| 433 | async fn own_log_requires_a_session() { |
| 434 | let fx = fixture().await; |
| 435 | let (status, _) = send(&fx.router, "GET", "/settings/security/log", None, None).await; |
| 436 | assert_eq!(status, 303); |
| 437 | } |
| 438 | |
| 439 | #[test] |
| 440 | fn anonymous_actor_and_target_fallbacks() { |
| 441 | let mut entry = AuditEntry::new(AuditAction::LoginFailed); |
| 442 | entry.target_name = "mallory".into(); |
| 443 | let row = row_view(&entry, "UTC", Lang::En); |
| 444 | assert_eq!(row.actor, "anonymous"); |
| 445 | assert_eq!(row.target, "mallory"); |
| 446 | assert_eq!(row.details, ""); |
| 447 | } |
| 448 | } |