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/access/registry.rs | |
| parent | e50a5ced65a1f03ebdbe5b0d3b09a57b11b7edbe (diff) | |
access registry
Diffstat (limited to 'src/access/registry.rs')
| -rw-r--r-- | src/access/registry.rs | 235 |
1 files changed, 235 insertions, 0 deletions
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"))); + } +} |
