diff options
| author | tv <tv@krebsco.de> | 2026-11-07 22:35:09 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-07 22:35:09 +0100 |
| commit | c82a62186a8e0a46fd41e08345a2fa3e54ddffe2 (patch) | |
| tree | 068e5bc12c7d13a51f7ba4a4e3e9723cbe33cc7e /src/api/access.rs | |
| parent | a8ee17b7513c0367b5a9e5a30ae290fe53dad88b (diff) | |
access: authorization at the API
Every request is judged by the registry the node holds: store reads
pruned to what the user may read, writes refused where it may not,
admin endpoints by protocol capabilities. Without a registry, as
before (--protocol-writers for protocol data).
Auth plan M1.
Diffstat (limited to 'src/api/access.rs')
| -rw-r--r-- | src/api/access.rs | 362 |
1 files changed, 362 insertions, 0 deletions
diff --git a/src/api/access.rs b/src/api/access.rs new file mode 100644 index 0000000..8a3e630 --- /dev/null +++ b/src/api/access.rs @@ -0,0 +1,362 @@ +//! What the user of a request may do, from the user registry (`access`), checked on +//! every request ([`access.md`](../../docs/access.md)): the endpoints by `authorize`, +//! the store's paths by its handlers, through the `Access` it leaves on the request. + +use axum::{ + body::Body, + extract::State, + http::{Method, Request, StatusCode}, + response::Response, +}; +use serde_json::Value as JsonValue; +use tracing::debug; + +use super::auth::AuthenticatedUser; +use super::error_response; +use crate::access::{Caps, Right, registry}; +use crate::document::meta; +use crate::state::AppState; + +/// What the user of a request may do (a request extension, set by `authorize`). +#[derive(Clone, Debug)] +pub(crate) enum Access { + /// The swarm has no registry (one from before it, `access.md`): every user the + /// node lets in may do what all could before; protocol data only those in + /// `--protocol-writers` (`store::parse_write_path`). + Everything, + /// What its registry entry gives. + Caps(Caps), +} + +/// How much of a place a user may read (`Access::visible`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Visible { + /// It and all below it. + All, + /// Only what lies below it on the way to what it may read (a map's keys leading + /// there, as a directory one may pass through but not list): `prune`. + Partly, + Nothing, +} + +impl Access { + pub(crate) fn allows(&self, right: Right, path: &[String]) -> bool { + match self { + Self::Everything => true, + Self::Caps(caps) => caps.allows(right, path), + } + } + + pub(crate) fn visible(&self, path: &[String]) -> Visible { + match self { + Self::Everything => Visible::All, + // The root holds protocol data too, which a capability on `[]` doesn't give. + Self::Caps(caps) + if caps.allows(Right::Read, path) + && (!path.is_empty() || caps.allows(Right::Read, &meta::path(&[]))) => + { + Visible::All + } + Self::Caps(caps) if caps.reaches(Right::Read, path) => Visible::Partly, + Self::Caps(_) => Visible::Nothing, + } + } +} + +/// `value`, read at `path`, as much of it as `access` lets its user read: of a map it +/// may only partly read, the keys on the way to what it may (recursively); a list, a +/// text or a scalar it may only partly read is left out, as a list's items have no path +/// of their own to give rights to. `typed`: `value` is typed JSON +/// (`typed::JsonForm::typed`), whose maps may be tagged (`{"$map": {…}}`) and whose +/// tagged values (`{"$uint": 5}`) are scalars. None if nothing of it is readable. +pub(crate) fn prune( + value: JsonValue, + path: &mut Vec<String>, + access: &Access, + typed: bool, +) -> Option<JsonValue> { + match access.visible(path) { + Visible::All => return Some(value), + Visible::Nothing => return None, + Visible::Partly => {} + } + let JsonValue::Object(map) = value else { + return None; + }; + let tag = |map: &serde_json::Map<String, JsonValue>| { + (typed && map.len() == 1) + .then(|| { + map.keys() + .next() + .filter(|key| key.starts_with('$')) + .cloned() + }) + .flatten() + }; + let map = match tag(&map) { + None => map, + Some(tag) if tag == "$map" => match map.into_iter().next() { + Some((_, JsonValue::Object(inner))) => inner, + _ => return None, + }, + // A tagged scalar or text. + Some(_) => return None, + }; + let mut out = serde_json::Map::new(); + for (key, child) in map { + path.push(key.clone()); + let child = prune(child, path, access, typed); + path.pop(); + if let Some(child) = child { + out.insert(key, child); + } + } + // A map whose only key looks like a tag is tagged itself. + Some(match tag(&out) { + Some(_) => serde_json::json!({ "$map": out }), + None => JsonValue::Object(out), + }) +} + +/// A shallow read of `path` (`store::read_shallow`), its `entries` only those `access` +/// lets its user read at least partly. +pub(crate) fn prune_shallow(mut value: JsonValue, path: &[String], access: &Access) -> JsonValue { + if access.visible(path) == Visible::All { + return value; + } + if let Some(entries) = value.get_mut("entries").and_then(JsonValue::as_object_mut) { + let mut child = path.to_vec(); + entries.retain(|key, _| { + child.push(key.clone()); + let visible = access.visible(&child) != Visible::Nothing; + child.pop(); + visible + }); + let size = entries.len(); + value["size"] = size.into(); + } + value +} + +/// What an endpoint needs (`needed`). +#[derive(Debug, PartialEq, Eq)] +enum Need { + /// The store and its streams: their handlers check each path. + Store, + /// Looking at this node (its figures, peers, dashboard): any capability. + Observe, + /// A right on protocol data. + Protocol(Right, Vec<String>), +} + +/// What a request to `path` by `method` needs. Remakes' records and forget records +/// are protocol data of their own: proposing or aborting a remake needs `write` on +/// `_continuum/remakes`, forgetting a peer on `_continuum/forgotten`; everything else +/// this node's operators do (its log filter, exports, quarantine, the changes) needs +/// protocol data itself, reading it to look, writing it to act. +fn needed(method: &Method, path: &str) -> Need { + let looking = method == Method::GET || method == Method::HEAD; + let protocol = |right, parts: &[&str]| Need::Protocol(right, meta::path(parts)); + let under = |prefix: &str| path == prefix || path.starts_with(&format!("{prefix}/")); + if under("/store") || under("/stream") { + Need::Store + } else if (path == "/peers" && looking) || path == "/admin/stats" || under("/admin/dashboard") { + Need::Observe + } else if under("/peers") { + protocol(Right::Write, &[meta::FORGOTTEN]) + } else if under("/admin/remakes") { + if looking { + protocol(Right::Read, &[meta::REMAKES]) + } else { + protocol(Right::Write, &[meta::REMAKES]) + } + } else if looking && !under("/debug") { + protocol(Right::Read, &[]) + } else { + protocol(Right::Write, &[]) + } +} + +/// Finds what the request's user may do, refuses the request if its endpoint needs +/// more (`needed`), and leaves `Access` on it for the store's handlers. Inside +/// `auth::basic_auth`, which found the user. A user the registry doesn't have may do +/// nothing: `403`. +pub(crate) async fn authorize( + State(state): State<AppState>, + mut request: Request<Body>, + next: axum::middleware::Next, +) -> Response { + let Some(AuthenticatedUser(user)) = request.extensions().get::<AuthenticatedUser>().cloned() + else { + return error_response(StatusCode::UNAUTHORIZED, "not authenticated", None); + }; + let access = { + let replica = state.doc.read().await; + if !registry::exists(&replica.doc) { + Access::Everything + } else { + match registry::user(&replica.doc, &user) { + Some(entry) => Access::Caps(entry.caps()), + None => { + debug!(user = %user, "Refused a user the registry doesn't have"); + return error_response( + StatusCode::FORBIDDEN, + format!("{user} is not a registered user (docs/access.md)"), + None, + ); + } + } + } + }; + let allowed = match needed(request.method(), request.uri().path()) { + Need::Store => true, + Need::Observe => match &access { + Access::Everything => true, + Access::Caps(caps) => !caps.is_empty(), + }, + Need::Protocol(right, path) => access.allows(right, &path), + }; + if !allowed { + debug!(user = %user, method = %request.method(), path = %request.uri().path(), "Refused a request the user's capabilities don't cover"); + return error_response( + StatusCode::FORBIDDEN, + format!("{user} may not do that here (docs/access.md)"), + None, + ); + } + request.extensions_mut().insert(access); + next.run(request).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::access::Capability; + use serde_json::json; + + fn path(text: &str) -> Vec<String> { + text.split('/') + .filter(|segment| !segment.is_empty()) + .map(str::to_owned) + .collect() + } + + fn caps(list: &[(&str, &[&str])]) -> Access { + Access::Caps(Caps::new( + list.iter() + .map(|(at, rights)| Capability { + path: path(at), + rights: rights.iter().map(|right| (*right).to_owned()).collect(), + by: String::new(), + since: 0, + }) + .collect(), + )) + } + + #[test] + fn endpoints_and_what_they_need() { + let read = |parts: &[&str]| Need::Protocol(Right::Read, meta::path(parts)); + let write = |parts: &[&str]| Need::Protocol(Right::Write, meta::path(parts)); + assert_eq!(needed(&Method::GET, "/store"), Need::Store); + assert_eq!(needed(&Method::PUT, "/store/a/b"), Need::Store); + assert_eq!(needed(&Method::GET, "/stream/a"), Need::Store); + assert_eq!(needed(&Method::GET, "/storehouse"), read(&[])); + assert_eq!(needed(&Method::GET, "/peers"), Need::Observe); + assert_eq!(needed(&Method::GET, "/admin/stats"), Need::Observe); + assert_eq!( + needed(&Method::GET, "/admin/dashboard/state"), + Need::Observe + ); + assert_eq!( + needed(&Method::POST, "/admin/dashboard/refresh"), + Need::Observe + ); + assert_eq!( + needed(&Method::DELETE, "/peers/continuum:00"), + write(&[meta::FORGOTTEN]) + ); + assert_eq!( + needed(&Method::GET, "/admin/remakes/r1"), + read(&[meta::REMAKES]) + ); + assert_eq!( + needed(&Method::POST, "/admin/remakes"), + write(&[meta::REMAKES]) + ); + assert_eq!( + needed(&Method::DELETE, "/admin/remakes/r1"), + write(&[meta::REMAKES]) + ); + assert_eq!(needed(&Method::GET, "/admin/config"), read(&[])); + assert_eq!(needed(&Method::GET, "/admin/quarantine/q"), read(&[])); + assert_eq!( + needed(&Method::POST, "/admin/quarantine/q/replay"), + write(&[]) + ); + assert_eq!(needed(&Method::PUT, "/admin/log"), write(&[])); + assert_eq!(needed(&Method::GET, "/debug/firewall"), write(&[])); + } + + #[test] + fn what_a_user_sees_of_a_tree() { + let tree = json!({ + "servers": { "eu": { "a": 1 }, "us": { "b": 2 } }, + "list": [1, 2], + "_continuum": { "users": {} } + }); + let reader = caps(&[("servers/eu", &["read"]), ("list/0", &["read"])]); + assert_eq!( + prune(tree.clone(), &mut Vec::new(), &reader, false), + Some(json!({ "servers": { "eu": { "a": 1 } } })) + ); + let data = caps(&[("", &["read", "write"])]); + assert_eq!( + prune(tree.clone(), &mut Vec::new(), &data, false), + Some(json!({ "servers": { "eu": { "a": 1 }, "us": { "b": 2 } }, "list": [1, 2] })) + ); + assert_eq!( + prune(tree.clone(), &mut Vec::new(), &Access::Everything, false), + Some(tree.clone()) + ); + assert_eq!( + prune(tree, &mut Vec::new(), &caps(&[("x", &["read"])]), false), + Some(json!({})) + ); + assert_eq!( + prune(json!(1), &mut path("y"), &caps(&[("x", &["read"])]), false), + None + ); + } + + #[test] + fn typed_json_keeps_its_tags() { + let reader = caps(&[("m/$x", &["read"]), ("n/k", &["read"])]); + let tree = json!({ + "m": { "$map": { "$x": 1, "$y": 2 } }, + "n": { "k": { "$uint": 5 }, "$z": 1 } + }); + assert_eq!( + prune(tree, &mut Vec::new(), &reader, true), + Some(json!({ "m": { "$map": { "$x": 1 } }, "n": { "k": { "$uint": 5 } } })) + ); + // A tagged scalar on the way is no map. + let tagged = json!({ "t": { "$text": "hello" } }); + assert_eq!( + prune(tagged, &mut Vec::new(), &caps(&[("t/x", &["read"])]), true), + Some(json!({})) + ); + } + + #[test] + fn a_shallow_read_lists_the_way() { + let read = json!({ "type": "map", "size": 3, + "entries": { "a": {}, "b": {}, "_continuum": {} } }); + let reader = caps(&[("a/x", &["read"])]); + assert_eq!( + prune_shallow(read.clone(), &[], &reader), + json!({ "type": "map", "size": 1, "entries": { "a": {} } }) + ); + assert_eq!(prune_shallow(read.clone(), &[], &Access::Everything), read); + } +} |
