//! The user registry in the document: `_continuum/users/`, 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": { "": { "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 { meta::path(&[meta::USERS]) } /// `_continuum/users/`. pub(crate) fn user_path(name: &str) -> Vec { 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 { 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, 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 { 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, &[Right])]) -> JsonValue { let caps: serde_json::Map = 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::>(), "by": by, "since": since, }) } /// The first users of a new swarm, as its genesis node records them (`first_users`): /// each name, and whether it may do everything (protocol data and managing users /// included), not only read and write the swarm's data. pub(crate) type FirstUsers = Vec<(String, bool)>; /// The first users of a new swarm, from the genesis node's options: those of /// `bootstrap_users`, each may do everything; unset, the users of its htpasswd file /// (`htpasswd`) may read and write the swarm's data, and those of them, or of the proxy's /// (`--user-header`), in `protocol_writers` everything. Refused if nobody could manage /// the swarm: a registry nobody may change would lock everyone out for good. pub(crate) fn first_users( bootstrap_users: &[String], htpasswd: &[String], protocol_writers: &[String], ) -> anyhow::Result { let mut users: BTreeMap = BTreeMap::new(); if bootstrap_users.is_empty() { for name in htpasswd.iter().filter(|name| valid_name(name)) { users.insert(name.clone(), false); } for name in protocol_writers.iter().filter(|name| valid_name(name)) { users.insert(name.clone(), true); } } else { for name in bootstrap_users { users.insert(name.clone(), true); } } if !users.values().any(|admin| *admin) { anyhow::bail!( "genesis: nobody could manage the new swarm's users; name one with bootstrap-users, or put one of htpasswd's users in protocol-writers (docs/access.md)" ); } Ok(users.into_iter().collect()) } /// The capabilities of a first user (`first_users`): the swarm's data, and for one who /// may do everything protocol data too. pub(crate) fn first_caps(admin: bool) -> Vec<(Vec, &'static [Right])> { const BOTH: &[Right] = &[Right::Read, Right::Write]; let mut caps = vec![(Vec::new(), BOTH)]; if admin { caps.push((meta::path(&[]), BOTH)); } caps } /// 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 { 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 the_first_users() { let names = |list: &[&str]| list.iter().map(|n| (*n).to_owned()).collect::>(); assert_eq!( first_users(&[], &names(&["alice", "bob"]), &names(&["alice"])).unwrap(), vec![("alice".to_owned(), true), ("bob".to_owned(), false)] ); // A protocol writer the proxy names, not in htpasswd. assert_eq!( first_users(&[], &names(&["bob"]), &names(&["carol"])).unwrap(), vec![("bob".to_owned(), false), ("carol".to_owned(), true)] ); assert_eq!( first_users(&names(&["root"]), &names(&["alice"]), &names(&["alice"])).unwrap(), vec![("root".to_owned(), true)] ); assert!(first_users(&[], &names(&["alice"]), &[]).is_err()); assert!(first_users(&[], &[], &[]).is_err()); let admin = Caps::new( first_caps(true) .into_iter() .map(|(path, rights)| { serde_json::from_value(capability("x", 0, &path, rights)).unwrap() }) .collect(), ); assert!(admin.allows(Right::Write, &path("_continuum/users/x"))); assert!(admin.allows(Right::Write, &path("accounts"))); let user = Caps::new( first_caps(false) .into_iter() .map(|(path, rights)| { serde_json::from_value(capability("x", 0, &path, rights)).unwrap() }) .collect(), ); assert!(user.allows(Right::Write, &path("accounts"))); assert!(!user.allows(Right::Read, &path("_continuum"))); } #[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"))); } }