Skip to main content

rust_web_server/admin/
access.rs

1//! Who may do what on the admin API (18.22.0, #75): users, roles and the
2//! audit log.
3//!
4//! - **Users**: the `[admin] token_env` token is user `admin` with role
5//!   `admin`; `[admin] users = ["deploy-bot:RWS_ADMIN_DEPLOY:operator"]`
6//!   adds named tokens (`name:ENV_VAR:role`), and OIDC sign-in maps groups
7//!   to roles.
8//! - **Roles**: `viewer` reads everything (every `GET`); `operator` also
9//!   reloads, drains, switches maintenance, blocks IPs, flips flags, purges
10//!   caches and renews certificates; `admin` also changes authorization
11//!   tuples. [`required_role`] is the one table.
12//! - **Audit log**: every change (and every refused attempt) as a JSON line
13//!   — time, user, role, how they signed in, source address, method, path,
14//!   the body with secrets redacted, the status — kept in memory (the last
15//!   [`AUDIT_CAPACITY`]) and appended to `[admin] audit_log` when set. MCP
16//!   tools that change the server record here too.
17
18use std::collections::VecDeque;
19use std::io::Write;
20use std::sync::{Mutex, OnceLock};
21
22use crate::mcp::json_escape;
23
24/// Entries kept in memory.
25pub const AUDIT_CAPACITY: usize = 1000;
26
27/// What a user may do; ordered (`Viewer < Operator < Admin`).
28#[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/// A user with a bearer token.
55#[derive(Debug, Clone, PartialEq)]
56pub struct TokenUser {
57    pub name: String,
58    pub token: String,
59    pub role: Role,
60}
61
62/// `name:ENV_VAR:role` entries (comma-separated) → users whose token is in
63/// `ENV_VAR`. `Err` names the first bad entry or unset variable.
64pub 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
84/// `group=role` entries (comma-separated): a group (or an email address, or
85/// `*` for anyone signed in) → role. First match in order wins.
86pub 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
98/// The role `map` gives a user with `email` and `groups`: the highest of the
99/// entries that match; `None` when none does.
100pub 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
107/// The role a request needs: reads are `viewer`, authorization tuples
108/// `admin`, any other change `operator`.
109pub 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        // A check reads; it's a POST only to carry its arguments.
114        _ if route == "/authz/check" => Role::Viewer,
115        _ => Role::Operator,
116    }
117}
118
119/// Who made a request.
120#[derive(Debug, Clone, PartialEq)]
121pub struct Principal {
122    pub user: String,
123    pub role: Role,
124    /// `token`, `oidc` or `mcp`.
125    pub via: &'static str,
126}
127
128// ── Audit log ────────────────────────────────────────────────────────────────
129
130/// One audited change.
131#[derive(Debug, Clone, PartialEq)]
132pub struct AuditEntry {
133    /// Unix seconds.
134    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    /// The request body, secrets redacted, at most 2 KB.
142    pub detail: String,
143    pub status: u16,
144    /// The replica that served it (`[cluster] node`, else the host name).
145    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
167/// Keys whose values never reach the audit log.
168fn 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
173/// A request body for the log: a JSON object's secret-looking values
174/// replaced by `"(redacted)"`, anything longer than 2 KB cut.
175pub 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
209/// Append audit entries to `path` too (empty: memory only).
210pub 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
214/// This replica's name in audit entries and cluster heartbeats.
215pub 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
225/// Record a change.
226pub 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
241/// Record a change made by `principal` from `address`.
242pub 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
257/// The `n` newest entries, newest first, as a JSON array.
258pub 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
264/// Audits `tools/call` requests for the named MCP tools (the ones that
265/// change the server) as user `mcp`, with the arguments redacted like an
266/// admin request body. Wrap the MCP server with it.
267pub 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    /// The audited tool a JSON-RPC body calls, and its arguments.
277    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    /// Only `POST /mcp…` bodies are looked at; everything else passes
303    /// straight through (streamed bodies, the async forwarder, inline runs).
304    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}