aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-09 04:46:19 +0000
committertv <tv@krebsco.de>2026-11-09 05:48:17 +0100
commit98a9180e39742725a61fb1966e342d7d212beee9 (patch)
treecc4b8b5b8a1f022a00e5b46e2f849a3edc72301d /src
parent97a69e09021244e994602ac5038b4bd7bc16ba78 (diff)
access: what a node holds alone by `_continuum/local/<node ID>/<name>`
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/<node ID>/`: `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/<node ID>` 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.
Diffstat (limited to 'src')
-rw-r--r--src/access/rules.rs2
-rw-r--r--src/api/access.rs86
-rw-r--r--src/api/store.rs11
-rw-r--r--src/document/meta.rs4
4 files changed, 76 insertions, 27 deletions
diff --git a/src/access/rules.rs b/src/access/rules.rs
index d8162a6..d120961 100644
--- a/src/access/rules.rs
+++ b/src/access/rules.rs
@@ -265,6 +265,8 @@ pub(crate) fn judge_reading(
}
// Written by a root only.
(Some(meta::EPOCHS | meta::SWARM), _) => false,
+ // Never stored: named for capabilities only (`api::access::needed`).
+ (Some(meta::LOCAL), _) => false,
// Logging out: a user revokes its own tokens (`POST /auth/logout`); one who
// manages users, anyone's (`DELETE /admin/users/{name}/tokens`).
(Some(meta::LOGOUTS), Some(owner)) => {
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/<node>/<name>`, 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<Vec<String>, 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),
diff --git a/src/document/meta.rs b/src/document/meta.rs
index 691cf40..e040ba0 100644
--- a/src/document/meta.rs
+++ b/src/document/meta.rs
@@ -21,6 +21,9 @@
//! * `access/userSigned`: the paths whose values their users sign (`access::envelope`).
//! * `banned/<pk>`: banned nodes (`access::bans`).
//! * `logouts/<name>`: when a user's tokens were last revoked (`access::registry`).
+//! * `local/<pk>/<name>`: never stored, and never writable: what a node holds alone
+//! (its log filter, its configuration, its debug endpoints), named for capabilities
+//! (`api::access::needed`).
/// The key every protocol path starts with.
pub(crate) const ROOT: &str = "_continuum";
@@ -38,6 +41,7 @@ pub(crate) const USERS: &str = "users";
pub(crate) const ACCESS: &str = "access";
pub(crate) const BANNED: &str = "banned";
pub(crate) const LOGOUTS: &str = "logouts";
+pub(crate) const LOCAL: &str = "local";
/// The store path `_continuum/<parts…>`.
pub(crate) fn path(parts: &[&str]) -> Vec<String> {