From 98a9180e39742725a61fb1966e342d7d212beee9 Mon Sep 17 00:00:00 2001 From: tv Date: Mon, 9 Nov 2026 04:46:19 +0000 Subject: access: what a node holds alone by `_continuum/local//` Setting a node's log filter took `write` on `_continuum`, all of the protocol data: whoever could change one node's logging could change the swarm's members, remakes and users too. Now what a node holds alone has paths of its own for capabilities, under `_continuum/local//`: `log` (`/admin/log`), `config` (`/admin/config`) and `debug` (`/debug`, always `write`), `read` to look and `write` to act (`api::access::needed`, which takes the node's ID). So `_continuum/local/` is everything on one node, `_continuum/local/*/log` the log filter on every node, and holders of `_continuum` keep it all. Nothing is stored there: the store refuses writes under `_continuum/local`, and the rules refuse a change touching it, as for epoch metadata. The quarantine stays with `_continuum`: its entries are everyone's writes, which a node-local capability mustn't show. --- src/api/access.rs | 86 +++++++++++++++++++++++++++++++++++++++---------------- src/api/store.rs | 11 +++++-- 2 files changed, 70 insertions(+), 27 deletions(-) (limited to 'src/api') diff --git a/src/api/access.rs b/src/api/access.rs index 29d796a..8737ef8 100644 --- a/src/api/access.rs +++ b/src/api/access.rs @@ -247,10 +247,12 @@ enum Need { /// `_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 { +fn needed(method: &Method, path: &str, node: &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}/")); + // What this node holds alone: `_continuum/local//`, never stored. + let local = |right, name: &str| protocol(right, &[meta::LOCAL, node, name]); if under("/store") || under("/stream") { Need::Store } else if under("/auth") { @@ -273,7 +275,12 @@ fn needed(method: &Method, path: &str) -> Need { } else { protocol(Right::Write, &[meta::REMAKES]) } - } else if looking && !under("/debug") { + } else if under("/admin/log") || under("/admin/config") { + let name = if under("/admin/log") { "log" } else { "config" }; + local(if looking { Right::Read } else { Right::Write }, name) + } else if under("/debug") { + local(Right::Write, "debug") + } else if looking { protocol(Right::Read, &[]) } else { protocol(Right::Write, &[]) @@ -318,7 +325,7 @@ pub(crate) async fn authorize( } } }; - let allowed = match needed(request.method(), request.uri().path()) { + let allowed = match needed(request.method(), request.uri().path(), state.local_id()) { Need::Store | Need::Auth => true, Need::Observe => match &access { Access::Everything => true, @@ -377,51 +384,82 @@ mod tests { 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::POST, "/auth/login"), Need::Auth); - assert_eq!(needed(&Method::GET, "/auth/whoami"), Need::Auth); - 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, "/store", "n1"), Need::Store); + assert_eq!(needed(&Method::POST, "/auth/login", "n1"), Need::Auth); + assert_eq!(needed(&Method::GET, "/auth/whoami", "n1"), Need::Auth); + assert_eq!(needed(&Method::PUT, "/store/a/b", "n1"), Need::Store); + assert_eq!(needed(&Method::GET, "/stream/a", "n1"), Need::Store); + assert_eq!(needed(&Method::GET, "/storehouse", "n1"), read(&[])); + assert_eq!(needed(&Method::GET, "/peers", "n1"), Need::Observe); + assert_eq!(needed(&Method::GET, "/admin/stats", "n1"), Need::Observe); assert_eq!( - needed(&Method::GET, "/admin/dashboard/state"), + needed(&Method::GET, "/admin/dashboard/state", "n1"), Need::Observe ); assert_eq!( - needed(&Method::POST, "/admin/dashboard/refresh"), + needed(&Method::POST, "/admin/dashboard/refresh", "n1"), Need::Observe ); assert_eq!( - needed(&Method::DELETE, "/peers/continuum:00"), + needed(&Method::DELETE, "/peers/continuum:00", "n1"), write(&[meta::FORGOTTEN]) ); assert_eq!( - needed(&Method::GET, "/admin/remakes/r1"), + needed(&Method::GET, "/admin/remakes/r1", "n1"), read(&[meta::REMAKES]) ); assert_eq!( - needed(&Method::POST, "/admin/remakes"), + needed(&Method::POST, "/admin/remakes", "n1"), write(&[meta::REMAKES]) ); assert_eq!( - needed(&Method::DELETE, "/admin/remakes/r1"), + needed(&Method::DELETE, "/admin/remakes/r1", "n1"), write(&[meta::REMAKES]) ); - assert_eq!(needed(&Method::GET, "/admin/config"), read(&[])); assert_eq!( - needed(&Method::GET, "/admin/users/alice"), + needed(&Method::GET, "/admin/config", "n1"), + read(&[meta::LOCAL, "n1", "config"]) + ); + assert_eq!( + needed(&Method::GET, "/admin/users/alice", "n1"), read(&[meta::USERS]) ); - assert_eq!(needed(&Method::POST, "/admin/users"), write(&[meta::USERS])); - assert_eq!(needed(&Method::GET, "/admin/quarantine/q"), read(&[])); assert_eq!( - needed(&Method::POST, "/admin/quarantine/q/replay"), + needed(&Method::POST, "/admin/users", "n1"), + write(&[meta::USERS]) + ); + assert_eq!(needed(&Method::GET, "/admin/quarantine/q", "n1"), read(&[])); + assert_eq!( + needed(&Method::POST, "/admin/quarantine/q/replay", "n1"), write(&[]) ); - assert_eq!(needed(&Method::PUT, "/admin/log"), write(&[])); - assert_eq!(needed(&Method::GET, "/debug/firewall"), write(&[])); + assert_eq!( + needed(&Method::GET, "/admin/log", "n1"), + read(&[meta::LOCAL, "n1", "log"]) + ); + assert_eq!( + needed(&Method::PUT, "/admin/log", "n1"), + write(&[meta::LOCAL, "n1", "log"]) + ); + assert_eq!( + needed(&Method::GET, "/debug/firewall", "n1"), + write(&[meta::LOCAL, "n1", "debug"]) + ); + // Every node's log filter, by one capability. + let logs = Caps::new(vec![Capability { + path: path("_continuum/local/*/log"), + rights: ["write".to_owned()].into(), + by: String::new(), + since: 0, + }]); + let Need::Protocol(right, at) = needed(&Method::PUT, "/admin/log", "n2") else { + unreachable!() + }; + assert!(logs.allows(right, &at)); + let Need::Protocol(right, at) = needed(&Method::GET, "/admin/config", "n2") else { + unreachable!() + }; + assert!(!logs.allows(right, &at)); } #[test] diff --git a/src/api/store.rs b/src/api/store.rs index e43d129..00ced48 100644 --- a/src/api/store.rs +++ b/src/api/store.rs @@ -662,7 +662,8 @@ fn parse_key_path(key: &str) -> Result, StatusCode> { /// what every node acts on, so a write there acts on the whole swarm, and each is /// logged. Epoch metadata (and `_continuum` as a whole, which contains it) is never /// writable: the protocol reads it at the snapshot change, so an edit only misleads -/// whoever reads it later; nor is the swarm ID. A path inside a list or text is +/// whoever reads it later; nor is the swarm ID, nor `_continuum/local`, which names +/// what a node holds alone for capabilities and is never stored. A path inside a list or text is /// judged where the rules judge the write (`inspect::placed`): at the list. async fn parse_write_path( state: &AppState, @@ -674,8 +675,12 @@ async fn parse_write_path( let path = parse_key_path(key)?; let placed = crate::document::inspect::placed(&state.doc.read().await.doc, &path); let protocol = meta::is_protocol(&path); - // `_continuum` itself, epoch metadata and the swarm ID: nobody. - let fixed = protocol && (path.len() < 2 || path[1] == meta::EPOCHS || path[1] == meta::SWARM); + // `_continuum` itself, epoch metadata, the swarm ID and what is never stored: nobody. + let fixed = protocol + && (path.len() < 2 + || path[1] == meta::EPOCHS + || path[1] == meta::SWARM + || path[1] == meta::LOCAL); let allowed = match access { Access::Everything => !protocol || state.protocol_writers.contains(&user.0), Access::Caps(caps) => caps.allows(crate::access::Right::Write, &placed), -- cgit v1.3.1