forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
15.4 KiB
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
5use forge_core::tz::format_in_tz;
6use forge_core::{AuditAction, AuditCursor, AuditEntry, AuditFilter, Store};
7use topcoat::Result;
8use topcoat::context::{Cx, app_context};
9use topcoat::router::{page, query_params};
10use topcoat::view::{View, component, view};
11
12use crate::auth::require_user;
13use crate::i18n::admin::{self as g, K};
14use crate::i18n::{self, Lang};
15use crate::pages::admin_users::admin_only;
16use crate::pages::issues_list::{list_href, stamp_unix};
17use crate::state::AppState;
18use crate::views::shell;
19
20/// Entries per page.
21const PAGE_SIZE: usize = 50;
22
23#[query_params(error = bad_request)]
24pub(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.
31struct RowView {
32 when: String,
33 actor: String,
34 action: String,
35 target: String,
36 ip: String,
37 details: String,
38}
39
40fn 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.
70async 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]
92async 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]
132async 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]
180async 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")]
202async 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")]
253async 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)]
278mod 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}