manta_server/server/audit/
middleware.rs1use std::{
4 collections::BTreeMap,
5 convert::Infallible,
6 net::SocketAddr,
7 sync::{Arc, Mutex},
8 time::Instant,
9};
10
11use axum::{
12 extract::{
13 ConnectInfo, FromRequestParts, MatchedPath, OriginalUri, Request, State,
14 },
15 http::{HeaderMap, Method, StatusCode, header, request::Parts},
16 middleware::Next,
17 response::Response,
18};
19use manta_shared::common::jwt_ops;
20use serde::Serialize;
21
22use super::event::{self, Actor, Client, EventKind, Verdict};
23use crate::server::ServerState;
24
25#[derive(Default)]
27struct Note {
28 action: Option<&'static str>,
29 dry_run: Option<bool>,
30 targets: BTreeMap<String, Vec<String>>,
31 nodes: Vec<String>,
32 produced: BTreeMap<String, String>,
33 details: serde_json::Map<String, serde_json::Value>,
34}
35
36#[derive(Clone, Default)]
49pub struct AuditNote(Option<Arc<Mutex<Note>>>);
50
51impl AuditNote {
52 fn with(&self, f: impl FnOnce(&mut Note)) -> &Self {
53 if let Some(note) = &self.0 {
54 f(&mut note.lock().unwrap_or_else(|p| p.into_inner()));
57 }
58 self
59 }
60
61 pub fn action(&self, action: &'static str) -> &Self {
63 self.with(|n| n.action = Some(action))
64 }
65
66 pub fn dry_run(&self, dry_run: bool) -> &Self {
68 self.with(|n| n.dry_run = Some(dry_run))
69 }
70
71 pub fn target(&self, kind: &str, value: impl Into<String>) -> &Self {
74 self.with(|n| {
75 n.targets
76 .entry(kind.to_string())
77 .or_default()
78 .push(value.into());
79 })
80 }
81
82 pub fn targets<I, S>(&self, kind: &str, values: I) -> &Self
84 where
85 I: IntoIterator<Item = S>,
86 S: Into<String>,
87 {
88 self.with(|n| {
89 let mut values = values.into_iter().map(Into::into).peekable();
90 if values.peek().is_some() {
91 n.targets
92 .entry(kind.to_string())
93 .or_default()
94 .extend(values);
95 }
96 })
97 }
98
99 pub fn nodes<I, S>(&self, values: I) -> &Self
101 where
102 I: IntoIterator<Item = S>,
103 S: Into<String>,
104 {
105 self.with(|n| n.nodes.extend(values.into_iter().map(Into::into)))
106 }
107
108 pub fn produced(&self, kind: &str, id: impl Into<String>) -> &Self {
111 self.with(|n| {
112 n.produced.insert(kind.to_string(), id.into());
113 })
114 }
115
116 pub fn detail(&self, key: &str, value: impl Serialize) -> &Self {
119 self.with(|n| {
120 if let Ok(v) = serde_json::to_value(value) {
121 n.details.insert(key.to_string(), v);
122 }
123 })
124 }
125}
126
127impl<S: Send + Sync> FromRequestParts<S> for AuditNote {
128 type Rejection = Infallible;
129
130 async fn from_request_parts(
131 parts: &mut Parts,
132 _state: &S,
133 ) -> Result<Self, Self::Rejection> {
134 Ok(
135 parts
136 .extensions
137 .get::<AuditNote>()
138 .cloned()
139 .unwrap_or_default(),
140 )
141 }
142}
143
144fn audited_kind(method: &Method, route: &str) -> Option<EventKind> {
147 if route.ends_with("/console") {
148 Some(EventKind::ConsoleSession)
149 } else if matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) {
150 None
151 } else {
152 Some(EventKind::ApiRequest)
153 }
154}
155
156fn actor(headers: &HeaderMap) -> Actor {
160 let Some(token) = headers
161 .get(header::AUTHORIZATION)
162 .and_then(|v| v.to_str().ok())
163 else {
164 return Actor::default();
165 };
166 let claim = |name| jwt_ops::get_string_claim(token, name).ok().flatten();
167 Actor {
168 username: claim("preferred_username"),
169 name: claim("name"),
170 subject: claim("sub"),
171 }
172}
173
174fn header_str(headers: &HeaderMap, name: &str) -> Option<String> {
175 headers
176 .get(name)
177 .and_then(|v| v.to_str().ok())
178 .map(str::to_string)
179}
180
181pub async fn audit_requests(
187 State(state): State<Arc<ServerState>>,
188 mut request: Request,
189 next: Next,
190) -> Response {
191 let Some(route) = request
192 .extensions()
193 .get::<MatchedPath>()
194 .map(|p| p.as_str().to_string())
195 else {
196 return next.run(request).await;
197 };
198 let Some(kind) = audited_kind(request.method(), &route) else {
199 return next.run(request).await;
200 };
201
202 let note = AuditNote(Some(Arc::default()));
203 request.extensions_mut().insert(note.clone());
204
205 let headers = request.headers();
206 let actor = actor(headers);
207 let site = header_str(headers, "X-Manta-Site");
208 let client = Client {
209 ip: request
210 .extensions()
211 .get::<ConnectInfo<SocketAddr>>()
212 .map(|ConnectInfo(peer)| peer.ip().to_string()),
213 forwarded_for: header_str(headers, "X-Forwarded-For"),
214 };
215 let uri = request
218 .extensions()
219 .get::<OriginalUri>()
220 .map_or_else(|| request.uri().clone(), |OriginalUri(u)| u.clone());
221 let req = event::Request {
222 method: request.method().to_string(),
223 route,
224 path: uri.path().to_string(),
225 query: uri.query().map(str::to_string),
226 };
227
228 let started = Instant::now();
229 let response = next.run(request).await;
230 let status = response.status();
231 if status == StatusCode::METHOD_NOT_ALLOWED {
232 return response;
233 }
234
235 let note = note
238 .0
239 .map(|n| std::mem::take(&mut *n.lock().unwrap_or_else(|p| p.into_inner())))
240 .unwrap_or_default();
241 let action = note
242 .action
243 .map_or_else(|| format!("{} {}", req.method, req.route), str::to_string);
244 let mut event = state.audit.event(kind, action);
245 event.actor = actor;
246 event.client = client;
247 event.site = site;
248 event.request = req;
249 event.outcome = event::Outcome {
250 result: Verdict::from_status(status),
251 status: status.as_u16(),
252 duration_ms: u64::try_from(started.elapsed().as_millis())
253 .unwrap_or(u64::MAX),
254 };
255 event.dry_run = note.dry_run;
256 event.targets = note.targets;
257 event.nodes = note.nodes;
258 event.produced = note.produced;
259 event.details = note.details;
260 state.audit.emit(event);
261
262 response
263}
264
265#[cfg(test)]
266mod tests {
267 use axum::http::HeaderValue;
268 use base64::prelude::*;
269
270 use super::*;
271
272 #[test]
273 fn audits_mutations_and_consoles_only() {
274 let r = "/v2/groups/{label}";
275 assert_eq!(audited_kind(&Method::GET, r), None);
276 assert_eq!(audited_kind(&Method::HEAD, r), None);
277 assert_eq!(audited_kind(&Method::OPTIONS, r), None);
278 for m in [Method::POST, Method::PUT, Method::PATCH, Method::DELETE] {
279 assert_eq!(audited_kind(&m, r), Some(EventKind::ApiRequest), "{m}");
280 }
281 assert_eq!(
282 audited_kind(&Method::GET, "/v2/nodes/{xname}/console"),
283 Some(EventKind::ConsoleSession)
284 );
285 }
286
287 #[test]
288 fn actor_reads_claims_from_bearer_header() {
289 let claims = serde_json::json!({
290 "preferred_username": "alice", "name": "Alice A", "sub": "u-1"
291 });
292 let token =
293 format!("h.{}.s", BASE64_URL_SAFE_NO_PAD.encode(claims.to_string()));
294 let mut headers = HeaderMap::new();
295 headers.insert(
296 header::AUTHORIZATION,
297 HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
298 );
299 assert_eq!(
300 actor(&headers),
301 Actor {
302 username: Some("alice".into()),
303 name: Some("Alice A".into()),
304 subject: Some("u-1".into()),
305 }
306 );
307 }
308
309 #[test]
310 fn actor_is_empty_without_or_with_garbage_token() {
311 assert_eq!(actor(&HeaderMap::new()), Actor::default());
312 let mut headers = HeaderMap::new();
313 headers.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer x"));
314 assert_eq!(actor(&headers), Actor::default());
315 }
316
317 #[test]
318 fn inert_note_ignores_calls() {
319 let note = AuditNote::default();
320 note.action("x.y").target("group", "g").dry_run(true);
321 assert!(note.0.is_none());
322 }
323
324 #[test]
325 fn note_accumulates_targets_and_details() {
326 let note = AuditNote(Some(Arc::default()));
327 note
328 .action("group.delete")
329 .target("group", "g1")
330 .targets("node", ["x1", "x2"])
331 .targets("image", Vec::<String>::new())
332 .dry_run(false)
333 .detail("force", true)
334 .nodes(["x1000c0s0b0n0", "x1000c0s0b0n1"])
335 .produced("transition", "t-1");
336 let n = note.0.unwrap();
337 let n = n.lock().unwrap();
338 assert_eq!(n.action, Some("group.delete"));
339 assert_eq!(n.targets["group"], vec!["g1"]);
340 assert_eq!(n.targets["node"], vec!["x1", "x2"]);
341 assert!(!n.targets.contains_key("image"), "empty list left an entry");
342 assert_eq!(n.dry_run, Some(false));
343 assert_eq!(n.details["force"], serde_json::json!(true));
344 assert_eq!(n.nodes, vec!["x1000c0s0b0n0", "x1000c0s0b0n1"]);
345 assert_eq!(n.produced["transition"], "t-1");
346 }
347}