Skip to main content

rws_proxy/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//! - **Tamper evidence** (18.33.0): each line appended to the file carries
18//!   `prev` (the SHA-256 of the line before it) and `hash` (of itself), so
19//!   [`verify_chain`] — `GET /admin/api/v1/audit/verify`, `rws ctl audit
20//!   --verify [FILE]` — finds an edited, removed or inserted entry.
21
22use std::collections::VecDeque;
23use std::io::Write;
24use std::sync::{Mutex, OnceLock};
25
26use crate::core::json::escape as json_escape;
27
28/// Entries kept in memory.
29pub const AUDIT_CAPACITY: usize = 1000;
30
31/// What a user may do; ordered (`Viewer < Operator < Admin`).
32#[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/// A user with a bearer token.
59#[derive(Debug, Clone, PartialEq)]
60pub struct TokenUser {
61    pub name: String,
62    pub token: String,
63    pub role: Role,
64}
65
66/// `name:ENV_VAR:role` entries (comma-separated) → users whose token is in
67/// `ENV_VAR`. `Err` names the first bad entry or unset variable.
68pub 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
88/// `group=role` entries (comma-separated): a group (or an email address, or
89/// `*` for anyone signed in) → role. First match in order wins.
90pub 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
102/// The role `map` gives a user with `email` and `groups`: the highest of the
103/// entries that match; `None` when none does.
104pub 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
111/// The role a request needs: reads are `viewer`, authorization tuples
112/// `admin`, any other change `operator`.
113pub 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        // A check reads; it's a POST only to carry its arguments.
118        _ if route == "/authz/check" => Role::Viewer,
119        _ => Role::Operator,
120    }
121}
122
123/// Who made a request.
124#[derive(Debug, Clone, PartialEq)]
125pub struct Principal {
126    pub user: String,
127    pub role: Role,
128    /// `token`, `oidc` or `mcp`.
129    pub via: &'static str,
130}
131
132// ── Audit log ────────────────────────────────────────────────────────────────
133
134/// One audited change.
135#[derive(Debug, Clone, PartialEq)]
136pub struct AuditEntry {
137    /// Unix seconds.
138    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    /// The request body, secrets redacted, at most 2 KB.
146    pub detail: String,
147    pub status: u16,
148    /// The replica that served it (`[cluster] node`, else the host name).
149    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
171/// Keys whose values never reach the audit log.
172fn 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
177/// A request body for the log: a JSON object's secret-looking values
178/// replaced by `"(redacted)"`, anything longer than 2 KB cut.
179pub 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    /// The `prev` of the next line appended to `file`: the hash of the last
207    /// line written (or found there at start). Kept in memory, not re-read,
208    /// so an edit to the file's last line can't be chained over.
209    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
217/// Append audit entries to `path` too (empty: memory only). The hash
218/// chain continues from the file's last line.
219pub 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
227// ── The hash chain (18.33.0) ─────────────────────────────────────────────────
228//
229// Each line appended to the file ends with `"prev":"<P>","hash":"<H>"}`:
230// P is the SHA-256 (hex) of the whole line before it in the file (64 zeros
231// for the first line of a new file), H the SHA-256 of this line as it reads
232// up to and including `"prev":"<P>"` and the closing `}` — i.e. the line
233// without its `,"hash":"<H>"`. Editing a line breaks its H; removing,
234// inserting or reordering lines breaks the next line's P. Lines without
235// them (written before 18.33) are accepted until the first chained line.
236
237/// The `prev` of a chain's first line.
238pub const CHAIN_START: &str = "0000000000000000000000000000000000000000000000000000000000000000";
239
240fn hex(bytes: &[u8]) -> String {
241    bytes.iter().map(|b| format!("{b:02x}")).collect()
242}
243
244/// The SHA-256 of a whole line (without its newline), as the next line's `prev`.
245pub fn line_hash(line: &str) -> String {
246    hex(&crate::core::sha256::sha256(line.as_bytes()))
247}
248
249/// `json` (an [`AuditEntry::to_json`] object) as a chained file line after
250/// a line whose [`line_hash`] is `prev`.
251pub 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
258/// A chained line's text without its hash, its `prev`, and its `hash`;
259/// `None` for a line without them.
260fn 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
269/// The last non-empty line of the file at `path`.
270fn 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    // Entries are a few KB at most; the tail holds the last one.
275    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/// What [`verify_chain`] found.
284#[derive(Debug, Clone, PartialEq, Default)]
285pub struct ChainReport {
286    /// Non-empty lines read.
287    pub entries: usize,
288    /// Lines carrying `prev` and `hash`, all verified.
289    pub chained: usize,
290    /// Lines from before the chain (no hash) at the start of the file.
291    pub unchained: usize,
292    /// The first chained line's `prev` when it isn't [`CHAIN_START`]: the
293    /// file continues one rotated away (or its start was cut off).
294    pub continues_from: Option<String>,
295    /// The first line (1-based) that fails, and why.
296    pub broken: Option<(usize, String)>,
297}
298
299impl ChainReport {
300    /// Nothing failed.
301    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
318/// Check an audit log's hash chain; stops at the first broken line.
319pub 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
355/// [`verify_chain`] on the configured `[admin] audit_log` file; `Err` when
356/// none is configured or it can't be read.
357pub 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
363/// This replica's name in audit entries and cluster heartbeats.
364pub 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
374/// Record a change.
375pub 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
392/// Record a change made by `principal` from `address`.
393pub 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
408/// The `n` newest entries, newest first, as a JSON array.
409pub 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
415/// Audits `tools/call` requests for the named MCP tools (the ones that
416/// change the server) as user `mcp`, with the arguments redacted like an
417/// admin request body. Wrap the MCP server with it.
418pub 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    /// The audited tool a JSON-RPC body calls, and its arguments.
428    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    /// Only `POST /mcp…` bodies are looked at; everything else passes
454    /// straight through (streamed bodies, the async forwarder, inline runs).
455    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}