aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/access.rs
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-07 22:35:09 +0100
committertv <tv@krebsco.de>2026-11-07 22:35:09 +0100
commitc82a62186a8e0a46fd41e08345a2fa3e54ddffe2 (patch)
tree068e5bc12c7d13a51f7ba4a4e3e9723cbe33cc7e /src/api/access.rs
parenta8ee17b7513c0367b5a9e5a30ae290fe53dad88b (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.rs362
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);
+ }
+}