aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-07 01:43:00 +0100
committertv <tv@krebsco.de>2026-11-07 01:43:00 +0100
commit4479bb89595958721a2dfef57dbcb89b255a7ad8 (patch)
treec19a89b7a4e4c11ef4dd0b82d5195cdf789d326a
parente50a5ced65a1f03ebdbe5b0d3b09a57b11b7edbe (diff)
access registry
-rw-r--r--src/access/mod.rs179
-rw-r--r--src/access/registry.rs235
-rw-r--r--src/document/meta.rs3
-rw-r--r--src/main.rs1
4 files changed, 418 insertions, 0 deletions
diff --git a/src/access/mod.rs b/src/access/mod.rs
new file mode 100644
index 0000000..8961312
--- /dev/null
+++ b/src/access/mod.rs
@@ -0,0 +1,179 @@
+//! Who may do what. The users and what each may do are the swarm's: a registry in the
+//! document (`_continuum/users`, `registry`), each user with capabilities, each a path
+//! and the rights it gives there and below (`Capability::covers`). How a user proves who
+//! it is stays each node's (`--htpasswd`, `--user-header`: `api::auth`). Enforced at
+//! the HTTP API ([`access.md`](../../docs/access.md)).
+
+// Used by the genesis node and the API from the next patches on.
+#![cfg_attr(not(test), allow(dead_code))]
+
+use std::collections::BTreeSet;
+
+use serde::{Deserialize, Serialize};
+
+use crate::document::meta;
+
+pub(crate) mod registry;
+
+/// What a capability lets its holder do, at its path and below.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub(crate) enum Right {
+ Read,
+ Write,
+}
+
+impl Right {
+ pub(crate) fn as_str(self) -> &'static str {
+ match self {
+ Self::Read => "read",
+ Self::Write => "write",
+ }
+ }
+}
+
+/// A path, and what it lets its holder do there and below: as the registry records it
+/// (`registry::User::caps`).
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub(crate) struct Capability {
+ /// Segments, as the store's API takes them (`[]`: everything).
+ pub(crate) path: Vec<String>,
+ /// `read`, `write`. Others, from a later version (`grant`), are kept and give
+ /// nothing here.
+ pub(crate) rights: BTreeSet<String>,
+ /// Who granted it: a user (`user:<name>`), or for those the genesis node records, the
+ /// node (its node ID). Kept so that granting on, by delegation, can come later
+ /// without a new format (`future-work.md`, Authentication across nodes).
+ #[serde(default)]
+ pub(crate) by: String,
+ /// When it was granted (Unix ms).
+ #[serde(default)]
+ pub(crate) since: u64,
+}
+
+impl Capability {
+ /// Whether it lets its holder do `right` at `path`: its path is a prefix of `path`
+ /// (segment by segment) and it gives `right`. One that isn't within protocol data
+ /// (`_continuum`) never covers it: operators are those given protocol data itself.
+ pub(crate) fn covers(&self, right: Right, path: &[String]) -> bool {
+ self.gives(right)
+ && path.starts_with(&self.path)
+ && (meta::is_protocol(&self.path) || !meta::is_protocol(path))
+ }
+
+ /// Whether `path` leads to a place where it lets its holder do `right`: its path
+ /// lies below `path` (or is it, or above it). What a listing of `path` shows of a
+ /// tree its holder may only partly read (`Caps::reaches`).
+ pub(crate) fn reaches(&self, right: Right, path: &[String]) -> bool {
+ self.gives(right)
+ && (self.path.starts_with(path) || self.covers(right, path))
+ && (meta::is_protocol(&self.path) || !meta::is_protocol(path))
+ }
+
+ fn gives(&self, right: Right) -> bool {
+ self.rights.contains(right.as_str())
+ }
+}
+
+/// Everything a user may do: its capabilities, as its registry entry has them; none if
+/// it has none, or is disabled.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub(crate) struct Caps(Vec<Capability>);
+
+impl Caps {
+ pub(crate) fn new(caps: Vec<Capability>) -> Self {
+ Self(caps)
+ }
+
+ /// Whether one of them lets its holder do `right` at `path`.
+ pub(crate) fn allows(&self, right: Right, path: &[String]) -> bool {
+ self.0.iter().any(|cap| cap.covers(right, path))
+ }
+
+ /// Whether `path` leads to a place where one of them lets its holder do `right`: a
+ /// listing of `path` shows the way there, as a directory one may pass through but not
+ /// list does (`Capability::reaches`).
+ pub(crate) fn reaches(&self, right: Right, path: &[String]) -> bool {
+ self.0.iter().any(|cap| cap.reaches(right, path))
+ }
+
+ pub(crate) fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn path(text: &str) -> Vec<String> {
+ text.split('/')
+ .filter(|segment| !segment.is_empty())
+ .map(str::to_owned)
+ .collect()
+ }
+
+ fn cap(at: &str, rights: &[&str]) -> Capability {
+ Capability {
+ path: path(at),
+ rights: rights.iter().map(|right| (*right).to_owned()).collect(),
+ by: String::new(),
+ since: 0,
+ }
+ }
+
+ #[test]
+ fn a_capability_covers_its_path_and_below() {
+ let servers = cap("servers/eu", &["read"]);
+ assert!(servers.covers(Right::Read, &path("servers/eu")));
+ assert!(servers.covers(Right::Read, &path("servers/eu/0/name")));
+ // By segment, not by text.
+ assert!(!servers.covers(Right::Read, &path("servers/europe")));
+ assert!(!servers.covers(Right::Read, &path("servers")));
+ assert!(!servers.covers(Right::Write, &path("servers/eu")));
+ }
+
+ #[test]
+ fn protocol_data_only_by_capabilities_within_it() {
+ let everything = cap("", &["read", "write"]);
+ assert!(everything.covers(Right::Write, &path("accounts/1")));
+ assert!(everything.covers(Right::Read, &[]));
+ assert!(!everything.covers(Right::Read, &path("_continuum")));
+ assert!(!everything.covers(Right::Write, &path("_continuum/users/alice")));
+ let operator = cap("_continuum", &["read", "write"]);
+ assert!(operator.covers(Right::Write, &path("_continuum/users/alice")));
+ assert!(!operator.covers(Right::Read, &path("accounts")));
+ let remakes = cap("_continuum/remakes", &["write"]);
+ assert!(remakes.covers(Right::Write, &path("_continuum/remakes/r1")));
+ assert!(!remakes.covers(Right::Write, &path("_continuum/users")));
+ }
+
+ #[test]
+ fn rights_from_later_versions_give_nothing() {
+ let later = cap("", &["grant"]);
+ assert!(!later.covers(Right::Read, &path("x")));
+ assert!(!later.covers(Right::Write, &path("x")));
+ }
+
+ #[test]
+ fn a_listing_shows_the_way_to_what_may_be_read() {
+ let caps = Caps::new(vec![cap("servers/eu", &["read"])]);
+ // The way there, as a directory passed through.
+ assert!(caps.reaches(Right::Read, &[]));
+ assert!(caps.reaches(Right::Read, &path("servers")));
+ // There and below.
+ assert!(caps.reaches(Right::Read, &path("servers/eu")));
+ assert!(caps.reaches(Right::Read, &path("servers/eu/0")));
+ // Elsewhere, nothing.
+ assert!(!caps.reaches(Right::Read, &path("servers/us")));
+ assert!(!caps.reaches(Right::Read, &path("accounts")));
+ assert!(!caps.allows(Right::Read, &path("servers")));
+ // A capability on everything doesn't lead into protocol data.
+ let everything = Caps::new(vec![cap("", &["read"])]);
+ assert!(everything.reaches(Right::Read, &[]));
+ assert!(!everything.reaches(Right::Read, &path("_continuum")));
+ let members = Caps::new(vec![cap("_continuum/members", &["read"])]);
+ assert!(members.reaches(Right::Read, &[]));
+ assert!(members.reaches(Right::Read, &path("_continuum")));
+ assert!(!members.reaches(Right::Read, &path("_continuum/users")));
+ }
+}
diff --git a/src/access/registry.rs b/src/access/registry.rs
new file mode 100644
index 0000000..c4ad0e6
--- /dev/null
+++ b/src/access/registry.rs
@@ -0,0 +1,235 @@
+//! The user registry in the document: `_continuum/users/<name>`, one entry per user.
+//! Protocol data, so a remake's snapshot carries it into the next epoch, and only those
+//! who may write protocol data change it. No secrets: every member reads the document.
+//!
+//! ```json
+//! { "since": 1762400000000, "by": "user:alice",
+//! "caps": { "<id>": { "path": [], "rights": ["read", "write"],
+//! "by": "user:alice", "since": 1762400000000 } },
+//! "disabled": false }
+//! ```
+//!
+//! Capabilities are a map by a random ID, not a list: two operators granting at once
+//! both add theirs, and revoking one deletes its entry, never rewriting the others.
+//! `disabled` keeps the entry (who wrote what stays readable) and gives nothing.
+
+use std::collections::BTreeMap;
+
+use serde_json::{Value as JsonValue, json};
+
+use super::{Capability, Caps, Right};
+use crate::document::{ReadDoc, meta, store};
+
+/// `_continuum/users`.
+pub(crate) fn users_dir() -> Vec<String> {
+ meta::path(&[meta::USERS])
+}
+
+/// `_continuum/users/<name>`.
+pub(crate) fn user_path(name: &str) -> Vec<String> {
+ let mut path = users_dir();
+ path.push(name.to_owned());
+ path
+}
+
+/// Whether the swarm has a registry: until it has, every user a node lets in may do
+/// what all could before (`docs/access.md`, An existing swarm).
+pub(crate) fn exists(doc: &impl ReadDoc) -> bool {
+ store::keys_at(doc, &users_dir()).is_ok()
+}
+
+/// The registered users' names, sorted.
+pub(crate) fn names(doc: &impl ReadDoc) -> Vec<String> {
+ let mut names = store::keys_at(doc, &users_dir()).unwrap_or_default();
+ names.sort();
+ names
+}
+
+/// A user name: what `--htpasswd` and trailers take, and a map key without `/`. 1 to 64
+/// of `A-Z a-z 0-9 . _ @ -`.
+pub(crate) fn valid_name(name: &str) -> bool {
+ (1..=64).contains(&name.len())
+ && name
+ .bytes()
+ .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'@' | b'-'))
+}
+
+/// A user's entry, as read from the document. Read leniently: a field that doesn't
+/// read is as if absent, and a capability that doesn't read is left out, so a damaged
+/// entry gives less, never more.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub(crate) struct User {
+ pub(crate) since: u64,
+ pub(crate) by: String,
+ pub(crate) caps: BTreeMap<String, Capability>,
+ pub(crate) disabled: bool,
+}
+
+impl User {
+ fn from_json(value: &JsonValue) -> Self {
+ let caps = value
+ .get("caps")
+ .and_then(JsonValue::as_object)
+ .map(|caps| {
+ caps.iter()
+ .filter_map(|(id, cap)| {
+ Some((id.clone(), serde_json::from_value(cap.clone()).ok()?))
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+ Self {
+ since: value.get("since").and_then(JsonValue::as_u64).unwrap_or(0),
+ by: value
+ .get("by")
+ .and_then(JsonValue::as_str)
+ .unwrap_or_default()
+ .to_owned(),
+ caps,
+ // Anything but `false` disables: a damaged flag gives less.
+ disabled: value
+ .get("disabled")
+ .is_some_and(|disabled| disabled != &JsonValue::Bool(false)),
+ }
+ }
+
+ /// What it may do: its capabilities, none while it is disabled.
+ pub(crate) fn caps(&self) -> Caps {
+ if self.disabled {
+ return Caps::default();
+ }
+ Caps::new(self.caps.values().cloned().collect())
+ }
+}
+
+/// The entry of user `name`, if it has one.
+pub(crate) fn user(doc: &impl ReadDoc, name: &str) -> Option<User> {
+ let value = store::read_path(doc, &user_path(name)).ok()??;
+ value.is_object().then(|| User::from_json(&value))
+}
+
+/// What user `name` may do: none if it isn't registered (or is disabled).
+pub(crate) fn caps_of(doc: &impl ReadDoc, name: &str) -> Caps {
+ user(doc, name).map(|user| user.caps()).unwrap_or_default()
+}
+
+/// A new user's entry, as written to `user_path`: granted by `by` at `since`, with
+/// `caps` (each a path and its rights), each under an ID of its own (`new_id`).
+pub(crate) fn entry(by: &str, since: u64, caps: &[(Vec<String>, &[Right])]) -> JsonValue {
+ let caps: serde_json::Map<String, JsonValue> = caps
+ .iter()
+ .map(|(path, rights)| (new_id(), capability(by, since, path, rights)))
+ .collect();
+ json!({ "since": since, "by": by, "caps": caps, "disabled": false })
+}
+
+/// A capability as the registry records it.
+pub(crate) fn capability(by: &str, since: u64, path: &[String], rights: &[Right]) -> JsonValue {
+ json!({
+ "path": path,
+ "rights": rights.iter().map(|right| right.as_str()).collect::<Vec<_>>(),
+ "by": by,
+ "since": since,
+ })
+}
+
+/// A random ID for a capability (8 bytes, hex): concurrent grants don't collide.
+pub(crate) fn new_id() -> String {
+ let mut bytes = [0u8; 8];
+ getrandom::fill(&mut bytes).expect("the system's random numbers");
+ bytes.iter().map(|byte| format!("{byte:02x}")).collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::document::Document;
+ use crate::document::testing::change;
+
+ fn path(text: &str) -> Vec<String> {
+ text.split('/')
+ .filter(|segment| !segment.is_empty())
+ .map(str::to_owned)
+ .collect()
+ }
+
+ #[test]
+ fn names() {
+ assert!(valid_name("alice"));
+ assert!(valid_name("alice.smith@example.org"));
+ for bad in ["", "a/b", "a:b", "a b", &"x".repeat(65)] {
+ assert!(!valid_name(bad), "{bad:?}");
+ }
+ }
+
+ #[test]
+ fn entries_written_and_read() {
+ let mut doc = Document::new();
+ assert!(!exists(&doc));
+ let entry = entry(
+ "continuum:00",
+ 7,
+ &[
+ (vec![], &[Right::Read, Right::Write]),
+ (path("_continuum"), &[Right::Read, Right::Write]),
+ ],
+ );
+ change(&mut doc, |tx| {
+ store::write_path(tx, &user_path("alice"), &entry).unwrap()
+ });
+ assert!(exists(&doc));
+ assert_eq!(super::names(&doc), vec!["alice"]);
+ let alice = user(&doc, "alice").unwrap();
+ assert_eq!((alice.since, alice.by.as_str()), (7, "continuum:00"));
+ assert_eq!(alice.caps.len(), 2);
+ let caps = caps_of(&doc, "alice");
+ assert!(caps.allows(Right::Write, &path("accounts/1")));
+ assert!(caps.allows(Right::Write, &path("_continuum/users/bob")));
+ assert!(caps_of(&doc, "bob").is_empty());
+ }
+
+ #[test]
+ fn a_disabled_or_damaged_entry_gives_less() {
+ let mut doc = Document::new();
+ let mut entry = entry("x", 0, &[(vec![], &[Right::Read])]);
+ entry["disabled"] = json!(true);
+ change(&mut doc, |tx| {
+ store::write_path(tx, &user_path("carol"), &entry).unwrap()
+ });
+ assert!(user(&doc, "carol").is_some());
+ assert!(caps_of(&doc, "carol").is_empty());
+
+ let damaged = json!({
+ "caps": { "a": { "path": "not a list", "rights": ["read"] },
+ "b": { "path": ["notes"], "rights": ["read"] } },
+ "disabled": "no"
+ });
+ change(&mut doc, |tx| {
+ store::write_path(tx, &user_path("dave"), &damaged).unwrap()
+ });
+ let dave = user(&doc, "dave").unwrap();
+ assert_eq!(dave.caps.len(), 1);
+ assert!(dave.disabled);
+ assert!(dave.caps().is_empty());
+ }
+
+ #[test]
+ fn concurrent_grants_both_hold() {
+ let mut ours = Document::new();
+ let entry = entry("x", 0, &[]);
+ change(&mut ours, |tx| {
+ store::write_path(tx, &user_path("erin"), &entry).unwrap()
+ });
+ let mut theirs = ours.fork();
+ for (doc, at) in [(&mut ours, "notes"), (&mut theirs, "logs")] {
+ let mut cap_path = user_path("erin");
+ cap_path.extend(["caps".to_owned(), new_id()]);
+ let cap = capability("x", 0, &path(at), &[Right::Read]);
+ change(doc, |tx| store::write_path(tx, &cap_path, &cap).unwrap());
+ }
+ ours.merge(&mut theirs).unwrap();
+ let caps = caps_of(&ours, "erin");
+ assert!(caps.allows(Right::Read, &path("notes")));
+ assert!(caps.allows(Right::Read, &path("logs")));
+ }
+}
diff --git a/src/document/meta.rs b/src/document/meta.rs
index a60efc1..0c3ee89 100644
--- a/src/document/meta.rs
+++ b/src/document/meta.rs
@@ -14,6 +14,8 @@
//! * `topology`, `dissemination`: how members' neighbours are chosen, and how a change
//! reaches every member, written by the genesis node, and changed by a protocol
//! writer if need be (`swarm::topology::swarm_ways`).
+//! * `users/<name>`: the user registry: who the users are, and what each may do
+//! (`access::registry`).
/// The key every protocol path starts with.
pub(crate) const ROOT: &str = "_continuum";
@@ -27,6 +29,7 @@ pub(crate) const NEIGHBOURS: &str = "neighbours";
pub(crate) const RELAYS: &str = "relays";
pub(crate) const TOPOLOGY: &str = "topology";
pub(crate) const DISSEMINATION: &str = "dissemination";
+pub(crate) const USERS: &str = "users";
/// The store path `_continuum/<parts…>`.
pub(crate) fn path(parts: &[&str]) -> Vec<String> {
diff --git a/src/main.rs b/src/main.rs
index 0a626e2..114f595 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -11,6 +11,7 @@ use anyhow::{Context, Result};
use tokio::sync::watch;
use tracing::{debug, error, info, warn};
+mod access;
mod api;
mod backoff;
mod clock;