1use std::collections::VecDeque;
23use std::io::Write;
24use std::sync::{Mutex, OnceLock};
25
26use crate::core::json::escape as json_escape;
27
28pub const AUDIT_CAPACITY: usize = 1000;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
33pub enum Role {
34 Viewer,
35 Operator,
36 Admin,
37}
38
39impl Role {
40 pub fn parse(text: &str) -> Option<Role> {
41 match text.trim().to_ascii_lowercase().as_str() {
42 "viewer" => Some(Role::Viewer),
43 "operator" => Some(Role::Operator),
44 "admin" => Some(Role::Admin),
45 _ => None,
46 }
47 }
48
49 pub fn as_str(self) -> &'static str {
50 match self {
51 Role::Viewer => "viewer",
52 Role::Operator => "operator",
53 Role::Admin => "admin",
54 }
55 }
56}
57
58#[derive(Debug, Clone, PartialEq)]
60pub struct TokenUser {
61 pub name: String,
62 pub token: String,
63 pub role: Role,
64}
65
66pub fn parse_users(list: &str, env: &dyn Fn(&str) -> Option<String>) -> Result<Vec<TokenUser>, String> {
69 let mut users = Vec::new();
70 for entry in list.split(',').map(str::trim).filter(|e| !e.is_empty()) {
71 let parts: Vec<&str> = entry.split(':').map(str::trim).collect();
72 let [name, var, role] = parts.as_slice() else {
73 return Err(format!("[admin] users: {entry:?} is not name:ENV_VAR:role"));
74 };
75 let role = Role::parse(role).ok_or_else(|| format!("[admin] users: {entry:?}: role must be viewer, operator or admin"))?;
76 if name.is_empty() || var.is_empty() {
77 return Err(format!("[admin] users: {entry:?} is not name:ENV_VAR:role"));
78 }
79 let token = env(var).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).ok_or_else(|| format!("[admin] users: {name}'s token variable {var} is unset or empty"))?;
80 if token.len() < 16 {
81 return Err(format!("[admin] users: {name}'s token (in {var}) is shorter than 16 characters"));
82 }
83 users.push(TokenUser { name: name.to_string(), token, role });
84 }
85 Ok(users)
86}
87
88pub fn parse_role_map(list: &str) -> Result<Vec<(String, Role)>, String> {
91 list.split(',')
92 .map(str::trim)
93 .filter(|e| !e.is_empty())
94 .map(|entry| {
95 let (who, role) = entry.rsplit_once('=').ok_or_else(|| format!("[admin] oidc_roles: {entry:?} is not group=role"))?;
96 let role = Role::parse(role).ok_or_else(|| format!("[admin] oidc_roles: {entry:?}: role must be viewer, operator or admin"))?;
97 Ok((who.trim().to_string(), role))
98 })
99 .collect()
100}
101
102pub fn role_for(map: &[(String, Role)], email: Option<&str>, groups: &[String]) -> Option<Role> {
105 map.iter()
106 .filter(|(who, _)| who == "*" || groups.iter().any(|g| g == who) || email.is_some_and(|e| who.contains('@') && who.eq_ignore_ascii_case(e)))
107 .map(|(_, role)| *role)
108 .max()
109}
110
111pub fn required_role(method: &str, route: &str) -> Role {
114 match method {
115 "GET" | "HEAD" | "OPTIONS" => Role::Viewer,
116 _ if route == "/authz/tuples" || route.starts_with("/authz/tuples/") => Role::Admin,
117 _ if route == "/authz/check" => Role::Viewer,
119 _ => Role::Operator,
120 }
121}
122
123#[derive(Debug, Clone, PartialEq)]
125pub struct Principal {
126 pub user: String,
127 pub role: Role,
128 pub via: &'static str,
130}
131
132#[derive(Debug, Clone, PartialEq)]
136pub struct AuditEntry {
137 pub at: u64,
139 pub user: String,
140 pub role: String,
141 pub via: String,
142 pub address: String,
143 pub method: String,
144 pub path: String,
145 pub detail: String,
147 pub status: u16,
148 pub node: String,
150}
151
152impl AuditEntry {
153 pub fn to_json(&self) -> String {
154 format!(
155 r#"{{"at":{},"time":"{}","user":"{}","role":"{}","via":"{}","address":"{}","method":"{}","path":"{}","detail":"{}","status":{},"node":"{}"}}"#,
156 self.at,
157 crate::http::date::format(self.at),
158 json_escape(&self.user),
159 json_escape(&self.role),
160 json_escape(&self.via),
161 json_escape(&self.address),
162 json_escape(&self.method),
163 json_escape(&self.path),
164 json_escape(&self.detail),
165 self.status,
166 json_escape(&self.node),
167 )
168 }
169}
170
171fn secret_key(key: &str) -> bool {
173 let key = key.to_ascii_lowercase();
174 ["token", "secret", "password", "passwd", "key", "credential", "authorization", "cookie"].iter().any(|s| key.contains(s))
175}
176
177pub fn redact(body: &[u8]) -> String {
180 let text = String::from_utf8_lossy(body);
181 let text = text.trim();
182 let redacted = match crate::mcp::json_rpc::object_members(text) {
183 Some(members) if !members.is_empty() => {
184 let parts: Vec<String> = members
185 .iter()
186 .map(|(k, v)| if secret_key(k) { format!("\"{}\":\"(redacted)\"", json_escape(k)) } else { format!("\"{}\":{}", json_escape(k), v.trim()) })
187 .collect();
188 format!("{{{}}}", parts.join(","))
189 }
190 _ => text.to_string(),
191 };
192 if redacted.len() > 2048 {
193 let mut end = 2048;
194 while !redacted.is_char_boundary(end) {
195 end -= 1;
196 }
197 format!("{}…", &redacted[..end])
198 } else {
199 redacted
200 }
201}
202
203struct Audit {
204 recent: VecDeque<AuditEntry>,
205 file: Option<String>,
206 chain_prev: String,
210}
211
212fn audit() -> &'static Mutex<Audit> {
213 static AUDIT: OnceLock<Mutex<Audit>> = OnceLock::new();
214 AUDIT.get_or_init(|| Mutex::new(Audit { recent: VecDeque::new(), file: None, chain_prev: CHAIN_START.to_string() }))
215}
216
217pub fn set_audit_file(path: &str) {
220 let file = Some(path.trim().to_string()).filter(|p| !p.is_empty());
221 let chain_prev = file.as_deref().and_then(last_line).map(|line| line_hash(&line)).unwrap_or_else(|| CHAIN_START.to_string());
222 let mut audit = audit().lock().unwrap_or_else(|e| e.into_inner());
223 audit.file = file;
224 audit.chain_prev = chain_prev;
225}
226
227pub const CHAIN_START: &str = "0000000000000000000000000000000000000000000000000000000000000000";
239
240fn hex(bytes: &[u8]) -> String {
241 bytes.iter().map(|b| format!("{b:02x}")).collect()
242}
243
244pub fn line_hash(line: &str) -> String {
246 hex(&crate::core::sha256::sha256(line.as_bytes()))
247}
248
249pub fn chain_line(json: &str, prev: &str) -> String {
252 let open = json.strip_suffix('}').unwrap_or(json);
253 let with_prev = format!("{open},\"prev\":\"{prev}\"}}");
254 let hash = line_hash(&with_prev);
255 format!("{open},\"prev\":\"{prev}\",\"hash\":\"{hash}\"}}")
256}
257
258fn split_chained(line: &str) -> Option<(String, &str, &str)> {
261 let is_hash = |s: &str| s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
262 let body = line.strip_suffix("\"}")?;
263 let (rest, hash) = body.rsplit_once(",\"hash\":\"")?;
264 let (_, prev) = rest.rsplit_once(",\"prev\":\"")?;
265 let prev = prev.strip_suffix('"')?;
266 (is_hash(hash) && is_hash(prev)).then(|| (format!("{rest}}}"), prev, hash))
267}
268
269fn last_line(path: &str) -> Option<String> {
271 use std::io::{Read, Seek, SeekFrom};
272 let mut file = std::fs::File::open(path).ok()?;
273 let len = file.metadata().ok()?.len();
274 let start = len.saturating_sub(64 * 1024);
276 file.seek(SeekFrom::Start(start)).ok()?;
277 let mut tail = Vec::new();
278 file.read_to_end(&mut tail).ok()?;
279 let text = String::from_utf8_lossy(&tail);
280 text.lines().rev().find(|l| !l.trim().is_empty()).map(str::to_string)
281}
282
283#[derive(Debug, Clone, PartialEq, Default)]
285pub struct ChainReport {
286 pub entries: usize,
288 pub chained: usize,
290 pub unchained: usize,
292 pub continues_from: Option<String>,
295 pub broken: Option<(usize, String)>,
297}
298
299impl ChainReport {
300 pub fn ok(&self) -> bool {
302 self.broken.is_none()
303 }
304
305 pub fn to_json(&self) -> String {
306 let continues = self.continues_from.as_deref().map_or("null".to_string(), |p| format!("\"{p}\""));
307 let broken = self.broken.as_ref().map_or("null".to_string(), |(line, why)| format!(r#"{{"line":{line},"reason":"{}"}}"#, json_escape(why)));
308 format!(
309 r#"{{"ok":{},"entries":{},"chained":{},"unchained":{},"continues_from":{continues},"broken":{broken}}}"#,
310 self.ok(),
311 self.entries,
312 self.chained,
313 self.unchained
314 )
315 }
316}
317
318pub fn verify_chain(text: &str) -> ChainReport {
320 let mut report = ChainReport::default();
321 let mut previous: Option<&str> = None;
322 for (number, line) in text.lines().enumerate().map(|(i, l)| (i + 1, l.trim_end_matches('\r'))) {
323 if line.trim().is_empty() {
324 continue;
325 }
326 report.entries += 1;
327 match split_chained(line) {
328 None if report.chained > 0 => {
329 report.broken = Some((number, "no prev/hash after the chain started: the entry was changed, or written by something else".to_string()));
330 return report;
331 }
332 None => report.unchained += 1,
333 Some((without_hash, prev, hash)) => {
334 if line_hash(&without_hash) != hash {
335 report.broken = Some((number, "the entry doesn't match its hash: it was changed".to_string()));
336 return report;
337 }
338 match previous {
339 Some(before) if line_hash(before) != prev => {
340 report.broken = Some((number, "prev isn't the hash of the line before: an entry was removed, inserted or changed".to_string()));
341 return report;
342 }
343 Some(_) => {}
344 None if prev != CHAIN_START => report.continues_from = Some(prev.to_string()),
345 None => {}
346 }
347 report.chained += 1;
348 }
349 }
350 previous = Some(line);
351 }
352 report
353}
354
355pub fn verify_audit_file() -> Result<(String, ChainReport), String> {
358 let path = audit().lock().unwrap_or_else(|e| e.into_inner()).file.clone().ok_or("no [admin] audit_log file is configured")?;
359 let text = std::fs::read(&path).map_err(|e| format!("can't read {path}: {e}"))?;
360 Ok((path, verify_chain(&String::from_utf8_lossy(&text))))
361}
362
363pub fn node_name() -> String {
365 std::env::var("RWS_CONFIG_CLUSTER_NODE")
366 .ok()
367 .map(|n| n.trim().to_string())
368 .filter(|n| !n.is_empty())
369 .or_else(|| std::env::var("HOSTNAME").ok().filter(|h| !h.is_empty()))
370 .or_else(|| std::fs::read_to_string("/etc/hostname").ok().map(|h| h.trim().to_string()).filter(|h| !h.is_empty()))
371 .unwrap_or_else(|| "rws".to_string())
372}
373
374pub fn record(entry: AuditEntry) {
376 let line = entry.to_json();
377 let mut audit = audit().lock().unwrap_or_else(|e| e.into_inner());
378 if audit.recent.len() >= AUDIT_CAPACITY {
379 audit.recent.pop_front();
380 }
381 audit.recent.push_back(entry);
382 if let Some(path) = audit.file.clone() {
383 let chained = chain_line(&line, &audit.chain_prev);
384 let written = std::fs::OpenOptions::new().create(true).append(true).open(&path).and_then(|mut f| writeln!(f, "{chained}"));
385 match written {
386 Ok(()) => audit.chain_prev = line_hash(&chained),
387 Err(e) => eprintln!("[admin] can't append to the audit log {path}: {e}"),
388 }
389 }
390}
391
392pub fn record_change(principal: &Principal, address: &str, method: &str, path: &str, body: &[u8], status: u16) {
394 record(AuditEntry {
395 at: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0),
396 user: principal.user.clone(),
397 role: principal.role.as_str().to_string(),
398 via: principal.via.to_string(),
399 address: address.to_string(),
400 method: method.to_string(),
401 path: path.to_string(),
402 detail: redact(body),
403 status,
404 node: node_name(),
405 });
406}
407
408pub fn recent_json(n: usize) -> String {
410 let audit = audit().lock().unwrap_or_else(|e| e.into_inner());
411 let entries: Vec<String> = audit.recent.iter().rev().take(n).map(AuditEntry::to_json).collect();
412 format!("[{}]", entries.join(","))
413}
414
415pub struct McpAuditLayer {
419 tools: Vec<String>,
420}
421
422impl McpAuditLayer {
423 pub fn new(tools: impl IntoIterator<Item = impl Into<String>>) -> McpAuditLayer {
424 McpAuditLayer { tools: tools.into_iter().map(Into::into).collect() }
425 }
426
427 fn call<'a>(&self, body: &'a [u8]) -> Option<(String, String)> {
429 let text = std::str::from_utf8(body).ok()?;
430 let members = crate::mcp::json_rpc::object_members(text)?;
431 let field = |k: &str| members.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone());
432 if field("method")?.trim() != "\"tools/call\"" {
433 return None;
434 }
435 let params = crate::mcp::json_rpc::object_members(&field("params")?)?;
436 let name = params.iter().find(|(k, _)| k == "name").map(|(_, v)| v.trim().trim_matches('"').to_string())?;
437 let arguments = params.iter().find(|(k, _)| k == "arguments").map(|(_, v)| v.clone()).unwrap_or_default();
438 self.tools.contains(&name).then_some((name, arguments))
439 }
440}
441
442impl crate::middleware::Middleware for McpAuditLayer {
443 fn handle(&self, request: &crate::request::Request, connection: &crate::server::ConnectionInfo, next: &dyn crate::application::Application) -> Result<crate::response::Response, String> {
444 let call = if request.method == "POST" { self.call(&request.body) } else { None };
445 let response = next.execute(request, connection)?;
446 if let Some((tool, arguments)) = call {
447 let principal = Principal { user: "mcp".to_string(), role: Role::Admin, via: "mcp" };
448 record_change(&principal, &crate::ip_filter::client_ip(request, &connection.client.ip), "TOOL", &tool, arguments.as_bytes(), response.status_code as u16);
449 }
450 Ok(response)
451 }
452
453 fn accepts_streamed_body(&self, request: &crate::request::Request, _connection: &crate::server::ConnectionInfo) -> bool {
456 !is_mcp_post(request)
457 }
458
459 fn is_passthrough(&self, request: &crate::request::Request, _connection: &crate::server::ConnectionInfo) -> bool {
460 !is_mcp_post(request)
461 }
462
463 fn runs_inline(&self, _request: &crate::request::Request, _connection: &crate::server::ConnectionInfo) -> bool {
464 true
465 }
466}
467
468fn is_mcp_post(request: &crate::request::Request) -> bool {
469 request.method == "POST" && crate::url::URL::request_target_path(&request.request_uri).is_some_and(|p| p == "/mcp" || p.starts_with("/mcp/"))
470}