1use std::collections::VecDeque;
19use std::io::Write;
20use std::sync::{Mutex, OnceLock};
21
22use crate::core::json::escape as json_escape;
23
24pub const AUDIT_CAPACITY: usize = 1000;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
29pub enum Role {
30 Viewer,
31 Operator,
32 Admin,
33}
34
35impl Role {
36 pub fn parse(text: &str) -> Option<Role> {
37 match text.trim().to_ascii_lowercase().as_str() {
38 "viewer" => Some(Role::Viewer),
39 "operator" => Some(Role::Operator),
40 "admin" => Some(Role::Admin),
41 _ => None,
42 }
43 }
44
45 pub fn as_str(self) -> &'static str {
46 match self {
47 Role::Viewer => "viewer",
48 Role::Operator => "operator",
49 Role::Admin => "admin",
50 }
51 }
52}
53
54#[derive(Debug, Clone, PartialEq)]
56pub struct TokenUser {
57 pub name: String,
58 pub token: String,
59 pub role: Role,
60}
61
62pub fn parse_users(list: &str, env: &dyn Fn(&str) -> Option<String>) -> Result<Vec<TokenUser>, String> {
65 let mut users = Vec::new();
66 for entry in list.split(',').map(str::trim).filter(|e| !e.is_empty()) {
67 let parts: Vec<&str> = entry.split(':').map(str::trim).collect();
68 let [name, var, role] = parts.as_slice() else {
69 return Err(format!("[admin] users: {entry:?} is not name:ENV_VAR:role"));
70 };
71 let role = Role::parse(role).ok_or_else(|| format!("[admin] users: {entry:?}: role must be viewer, operator or admin"))?;
72 if name.is_empty() || var.is_empty() {
73 return Err(format!("[admin] users: {entry:?} is not name:ENV_VAR:role"));
74 }
75 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"))?;
76 if token.len() < 16 {
77 return Err(format!("[admin] users: {name}'s token (in {var}) is shorter than 16 characters"));
78 }
79 users.push(TokenUser { name: name.to_string(), token, role });
80 }
81 Ok(users)
82}
83
84pub fn parse_role_map(list: &str) -> Result<Vec<(String, Role)>, String> {
87 list.split(',')
88 .map(str::trim)
89 .filter(|e| !e.is_empty())
90 .map(|entry| {
91 let (who, role) = entry.rsplit_once('=').ok_or_else(|| format!("[admin] oidc_roles: {entry:?} is not group=role"))?;
92 let role = Role::parse(role).ok_or_else(|| format!("[admin] oidc_roles: {entry:?}: role must be viewer, operator or admin"))?;
93 Ok((who.trim().to_string(), role))
94 })
95 .collect()
96}
97
98pub fn role_for(map: &[(String, Role)], email: Option<&str>, groups: &[String]) -> Option<Role> {
101 map.iter()
102 .filter(|(who, _)| who == "*" || groups.iter().any(|g| g == who) || email.is_some_and(|e| who.contains('@') && who.eq_ignore_ascii_case(e)))
103 .map(|(_, role)| *role)
104 .max()
105}
106
107pub fn required_role(method: &str, route: &str) -> Role {
110 match method {
111 "GET" | "HEAD" | "OPTIONS" => Role::Viewer,
112 _ if route == "/authz/tuples" || route.starts_with("/authz/tuples/") => Role::Admin,
113 _ if route == "/authz/check" => Role::Viewer,
115 _ => Role::Operator,
116 }
117}
118
119#[derive(Debug, Clone, PartialEq)]
121pub struct Principal {
122 pub user: String,
123 pub role: Role,
124 pub via: &'static str,
126}
127
128#[derive(Debug, Clone, PartialEq)]
132pub struct AuditEntry {
133 pub at: u64,
135 pub user: String,
136 pub role: String,
137 pub via: String,
138 pub address: String,
139 pub method: String,
140 pub path: String,
141 pub detail: String,
143 pub status: u16,
144 pub node: String,
146}
147
148impl AuditEntry {
149 pub fn to_json(&self) -> String {
150 format!(
151 r#"{{"at":{},"time":"{}","user":"{}","role":"{}","via":"{}","address":"{}","method":"{}","path":"{}","detail":"{}","status":{},"node":"{}"}}"#,
152 self.at,
153 crate::http::date::format(self.at),
154 json_escape(&self.user),
155 json_escape(&self.role),
156 json_escape(&self.via),
157 json_escape(&self.address),
158 json_escape(&self.method),
159 json_escape(&self.path),
160 json_escape(&self.detail),
161 self.status,
162 json_escape(&self.node),
163 )
164 }
165}
166
167fn secret_key(key: &str) -> bool {
169 let key = key.to_ascii_lowercase();
170 ["token", "secret", "password", "passwd", "key", "credential", "authorization", "cookie"].iter().any(|s| key.contains(s))
171}
172
173pub fn redact(body: &[u8]) -> String {
176 let text = String::from_utf8_lossy(body);
177 let text = text.trim();
178 let redacted = match crate::mcp::json_rpc::object_members(text) {
179 Some(members) if !members.is_empty() => {
180 let parts: Vec<String> = members
181 .iter()
182 .map(|(k, v)| if secret_key(k) { format!("\"{}\":\"(redacted)\"", json_escape(k)) } else { format!("\"{}\":{}", json_escape(k), v.trim()) })
183 .collect();
184 format!("{{{}}}", parts.join(","))
185 }
186 _ => text.to_string(),
187 };
188 if redacted.len() > 2048 {
189 let mut end = 2048;
190 while !redacted.is_char_boundary(end) {
191 end -= 1;
192 }
193 format!("{}…", &redacted[..end])
194 } else {
195 redacted
196 }
197}
198
199struct Audit {
200 recent: VecDeque<AuditEntry>,
201 file: Option<String>,
202}
203
204fn audit() -> &'static Mutex<Audit> {
205 static AUDIT: OnceLock<Mutex<Audit>> = OnceLock::new();
206 AUDIT.get_or_init(|| Mutex::new(Audit { recent: VecDeque::new(), file: None }))
207}
208
209pub fn set_audit_file(path: &str) {
211 audit().lock().unwrap_or_else(|e| e.into_inner()).file = Some(path.trim().to_string()).filter(|p| !p.is_empty());
212}
213
214pub fn node_name() -> String {
216 std::env::var("RWS_CONFIG_CLUSTER_NODE")
217 .ok()
218 .map(|n| n.trim().to_string())
219 .filter(|n| !n.is_empty())
220 .or_else(|| std::env::var("HOSTNAME").ok().filter(|h| !h.is_empty()))
221 .or_else(|| std::fs::read_to_string("/etc/hostname").ok().map(|h| h.trim().to_string()).filter(|h| !h.is_empty()))
222 .unwrap_or_else(|| "rws".to_string())
223}
224
225pub fn record(entry: AuditEntry) {
227 let line = entry.to_json();
228 let mut audit = audit().lock().unwrap_or_else(|e| e.into_inner());
229 if audit.recent.len() >= AUDIT_CAPACITY {
230 audit.recent.pop_front();
231 }
232 audit.recent.push_back(entry);
233 if let Some(path) = audit.file.clone() {
234 let written = std::fs::OpenOptions::new().create(true).append(true).open(&path).and_then(|mut f| writeln!(f, "{line}"));
235 if let Err(e) = written {
236 eprintln!("[admin] can't append to the audit log {path}: {e}");
237 }
238 }
239}
240
241pub fn record_change(principal: &Principal, address: &str, method: &str, path: &str, body: &[u8], status: u16) {
243 record(AuditEntry {
244 at: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0),
245 user: principal.user.clone(),
246 role: principal.role.as_str().to_string(),
247 via: principal.via.to_string(),
248 address: address.to_string(),
249 method: method.to_string(),
250 path: path.to_string(),
251 detail: redact(body),
252 status,
253 node: node_name(),
254 });
255}
256
257pub fn recent_json(n: usize) -> String {
259 let audit = audit().lock().unwrap_or_else(|e| e.into_inner());
260 let entries: Vec<String> = audit.recent.iter().rev().take(n).map(AuditEntry::to_json).collect();
261 format!("[{}]", entries.join(","))
262}
263
264pub struct McpAuditLayer {
268 tools: Vec<String>,
269}
270
271impl McpAuditLayer {
272 pub fn new(tools: impl IntoIterator<Item = impl Into<String>>) -> McpAuditLayer {
273 McpAuditLayer { tools: tools.into_iter().map(Into::into).collect() }
274 }
275
276 fn call<'a>(&self, body: &'a [u8]) -> Option<(String, String)> {
278 let text = std::str::from_utf8(body).ok()?;
279 let members = crate::mcp::json_rpc::object_members(text)?;
280 let field = |k: &str| members.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone());
281 if field("method")?.trim() != "\"tools/call\"" {
282 return None;
283 }
284 let params = crate::mcp::json_rpc::object_members(&field("params")?)?;
285 let name = params.iter().find(|(k, _)| k == "name").map(|(_, v)| v.trim().trim_matches('"').to_string())?;
286 let arguments = params.iter().find(|(k, _)| k == "arguments").map(|(_, v)| v.clone()).unwrap_or_default();
287 self.tools.contains(&name).then_some((name, arguments))
288 }
289}
290
291impl crate::middleware::Middleware for McpAuditLayer {
292 fn handle(&self, request: &crate::request::Request, connection: &crate::server::ConnectionInfo, next: &dyn crate::application::Application) -> Result<crate::response::Response, String> {
293 let call = if request.method == "POST" { self.call(&request.body) } else { None };
294 let response = next.execute(request, connection)?;
295 if let Some((tool, arguments)) = call {
296 let principal = Principal { user: "mcp".to_string(), role: Role::Admin, via: "mcp" };
297 record_change(&principal, &connection.client.ip, "TOOL", &tool, arguments.as_bytes(), response.status_code as u16);
298 }
299 Ok(response)
300 }
301
302 fn accepts_streamed_body(&self, request: &crate::request::Request, _connection: &crate::server::ConnectionInfo) -> bool {
305 !is_mcp_post(request)
306 }
307
308 fn is_passthrough(&self, request: &crate::request::Request, _connection: &crate::server::ConnectionInfo) -> bool {
309 !is_mcp_post(request)
310 }
311
312 fn runs_inline(&self, _request: &crate::request::Request, _connection: &crate::server::ConnectionInfo) -> bool {
313 true
314 }
315}
316
317fn is_mcp_post(request: &crate::request::Request) -> bool {
318 request.method == "POST" && crate::url::URL::request_target_path(&request.request_uri).is_some_and(|p| p == "/mcp" || p.starts_with("/mcp/"))
319}