diff options
| author | tv <tv@krebsco.de> | 2026-11-07 01:43:00 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-07 01:43:00 +0100 |
| commit | 4479bb89595958721a2dfef57dbcb89b255a7ad8 (patch) | |
| tree | c19a89b7a4e4c11ef4dd0b82d5195cdf789d326a /src | |
| parent | e50a5ced65a1f03ebdbe5b0d3b09a57b11b7edbe (diff) | |
access registry
Diffstat (limited to 'src')
| -rw-r--r-- | src/access/mod.rs | 179 | ||||
| -rw-r--r-- | src/access/registry.rs | 235 | ||||
| -rw-r--r-- | src/document/meta.rs | 3 | ||||
| -rw-r--r-- | src/main.rs | 1 |
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; |
