//! Rules by path every node judges a change by, the same way, before it applies it //! (`judge`): who may write which part of the document. A change is judged by what it //! touches (`document::inspect`), by who made it (its committer, the node a signature //! proves, `document::signatures`; and the user its message names, `User:`), and by the //! document as its writer saw it: at its dependencies (`document::at`), so a later //! revocation doesn't refuse what was allowed when it was written, and every node, //! whatever it holds since, judges alike. //! //! * A change without dependencies is an epoch's root: the genesis change, the first a //! document holds, or a remake's snapshot, whose hash is the epoch's ID. Nothing else //! is judged of it: it writes the protocol data it starts with. //! * Any other is its committer's, a member at its dependencies, or registering itself. //! * Protocol data (`_continuum`, `document::meta`): a member's entry and its votes by //! that member only (removing an entry also by a user who may write there); epochs //! and the swarm ID in a root only; a remake's decision by the coordinator its //! proposal names, the rest of a record by any member; a user's tokens revoked by that //! user too (logging out); the rest by a user who may write there. //! * User data: by a user who may write there. //! //! In a swarm without a user registry (`access::registry`), whatever a user would need, //! any member may. use std::collections::BTreeSet; use crate::access::Right; use crate::access::registry; use crate::document::at::At; use crate::document::inspect::Path; use crate::document::meta; use crate::document::replica::Epoch; use crate::document::{ChangeHash, Document, store}; /// A change to judge, as its bytes say. #[derive(Debug, Clone)] pub(crate) struct Change<'a> { pub(crate) hash: ChangeHash, pub(crate) deps: &'a [ChangeHash], /// The heads to judge it at: its dependencies, or, for those not applied yet, the /// changes of the document they build on, where nothing between changed what rules /// read (`access::screen`). pub(crate) seen: &'a [ChangeHash], /// The node that made it (its node ID), as its signature proves. pub(crate) committer: Option<&'a str>, /// The user it names (`User:`), if any. pub(crate) user: Option<&'a str>, /// What it touches (`inspect::Inspector`). pub(crate) paths: &'a BTreeSet, } /// Why a change is refused. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum Refusal { /// Its dependencies aren't all in the document: it can't be judged yet. Unready, /// A rule refuses it: which, and what it touched. Rule(String), } impl std::fmt::Display for Refusal { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Refusal::Unready => f.write_str("its dependencies aren't all here"), Refusal::Rule(why) => f.write_str(why), } } } /// Judges `change`, about to be applied to `doc` (of `epoch`). pub(crate) fn judge(doc: &Document, epoch: Epoch, change: &Change<'_>) -> Result<(), Refusal> { if change.deps.is_empty() { let root = if epoch.generation == 0 { doc.get_heads().is_empty() } else { change.hash.0 == epoch.id }; return if root { Ok(()) } else { Err(Refusal::Rule( "a change without dependencies that isn't the epoch's root".to_owned(), )) }; } let at = At::new(doc, change.seen).ok_or(Refusal::Unready)?; let refuse = |why: String| Err(Refusal::Rule(why)); let Some(node) = change.committer else { return refuse("it names no node".to_owned()); }; let own_entry = meta::path(&[meta::MEMBERS, node]); let member = store::read_path(&at, &own_entry).ok().flatten().is_some(); let registering = change .paths .iter() .all(|path| path.starts_with(&own_entry) || *path == meta::path(&[meta::MEMBERS])); if !member && !registering { return refuse(format!("{node} isn't a member")); } let registry = registry::exists(&at); // Whether the user may write `path`: as the registry at its dependencies says, or, // without one, any member. let user_may = |path: &Path| match (registry, change.user) { (false, _) => true, (true, Some(user)) => registry::caps_of(&at, user).allows(Right::Write, path), (true, None) => false, }; for path in change.paths { if !meta::is_protocol(path) { if !user_may(path) { return refuse(format!("{} may not write {}", who(change), shown(path))); } continue; } let part = path.get(1).map(String::as_str); let owner = path.get(2).map(String::as_str); let allowed = match (part, owner) { // A member's entry, its votes: its own. Removing an entry, also a user's. (Some(meta::MEMBERS), Some(owner)) => owner == node || user_may(path), (Some(meta::VOTES), Some(owner)) => owner == node, // What holds them, made by whichever member first writes in it. (Some(meta::MEMBERS | meta::VOTES | meta::REMAKES), None) => true, // Written by a root only. (Some(meta::EPOCHS | meta::SWARM), _) => false, // Logging out: a user revokes its own tokens (`POST /auth/logout`). (Some(meta::USERS), Some(owner)) if path.get(3).map(String::as_str) == Some("tokensNotBefore") && change.user == Some(owner) => { true } (Some(meta::REMAKES), Some(id)) => match path.get(3).map(String::as_str) { // Its coordinator's; or a record carried into a new epoch whole, by the // node that coordinated it (`remake::switch`): its proposal written with it. Some("decision") => match coordinator(&at, id) { Some(coordinator) => coordinator == node, None => { change .paths .iter() .any(|other| other == &meta::path(&[meta::REMAKES, id, "proposal"])) || change .paths .iter() .any(|other| other == &meta::path(&[meta::REMAKES, id])) } }, _ => true, }, _ => user_may(path), }; if !allowed { return refuse(format!("{} may not write {}", who(change), shown(path))); } } Ok(()) } /// Who wrote it, for a refusal. fn who(change: &Change<'_>) -> String { match (change.committer, change.user) { (Some(node), Some(user)) => format!("{user} at {node}"), (Some(node), None) => node.to_owned(), (None, _) => "nobody".to_owned(), } } fn shown(path: &Path) -> String { format!("/{}", path.join("/")) } /// The coordinator remake `id`'s proposal names, at `at`. fn coordinator(at: &At<'_>, id: &str) -> Option { let proposal = store::read_path(at, &meta::path(&[meta::REMAKES, id, "proposal"])) .ok() .flatten()?; proposal .get("coordinator") .and_then(serde_json::Value::as_str) .map(str::to_owned) } #[cfg(test)] mod tests { use super::*; use crate::clock::UnixMillis; use crate::document::replica::{Writer, transact}; use serde_json::json; fn path(text: &str) -> Path { text.split('/').map(str::to_owned).collect() } fn paths(texts: &[&str]) -> BTreeSet { texts.iter().map(|text| path(text)).collect() } fn write(doc: &mut Document, at: &str, value: serde_json::Value) { let writer = Writer { node: "genesis", user: None, }; let at = path(at); transact(doc, UnixMillis(1), &writer, |doc| { store::write_path(doc, &at, &value) }) .unwrap(); } /// A swarm with members `a` and `b`, a remake proposed by `a`, and, if `users`, /// user `alice` who may write `notes` and `bob` who may not write at all. fn swarm(users: bool) -> Document { let mut doc = Document::new(); write(&mut doc, "_continuum/swarm", json!("s")); write(&mut doc, "_continuum/members/a", json!({"since": 1})); write(&mut doc, "_continuum/members/b", json!({"since": 1})); write( &mut doc, "_continuum/remakes/r1/proposal", json!({"coordinator": "a"}), ); if users { let alice = registry::entry("a", 1, &[(path("notes"), &[Right::Write][..])]); write(&mut doc, "_continuum/users/alice", alice); write( &mut doc, "_continuum/users/bob", registry::entry("a", 1, &[]), ); } doc } fn judged( doc: &Document, committer: &str, user: Option<&str>, touched: &[&str], ) -> Result<(), Refusal> { let deps = doc.get_heads(); let touched = paths(touched); judge( doc, Epoch::GENESIS, &Change { hash: ChangeHash([9; 32]), deps: &deps, seen: &deps, committer: Some(committer), user, paths: &touched, }, ) } fn refused(result: Result<(), Refusal>) -> bool { matches!(result, Err(Refusal::Rule(_))) } /// A member's entry and votes are its own; epochs and the swarm ID a root's. #[test] fn protocol_data() { let doc = swarm(true); assert!(judged(&doc, "a", None, &["_continuum/members/a/alive"]).is_ok()); assert!(refused(judged( &doc, "a", None, &["_continuum/members/b/alive"] ))); assert!(judged(&doc, "b", None, &["_continuum/votes/b"]).is_ok()); assert!(refused(judged(&doc, "a", None, &["_continuum/votes/b"]))); assert!(refused(judged(&doc, "a", None, &["_continuum/swarm"]))); assert!(refused(judged(&doc, "a", None, &["_continuum/epochs/1"]))); // A remake's decision: its coordinator's; the rest of the record, any member's. assert!(judged(&doc, "a", None, &["_continuum/remakes/r1/decision"]).is_ok()); assert!(refused(judged( &doc, "b", None, &["_continuum/remakes/r1/decision"] ))); assert!(judged(&doc, "b", None, &["_continuum/remakes/r1/abortRequested"]).is_ok()); // A record carried into a new epoch whole: its proposal with its decision. assert!( judged( &doc, "b", None, &[ "_continuum/remakes/r2", "_continuum/remakes/r2/proposal", "_continuum/remakes/r2/decision" ] ) .is_ok() ); assert!(refused(judged( &doc, "b", None, &["_continuum/remakes/r2/decision"] ))); // The rest: a user's who may write there. assert!(refused(judged(&doc, "a", None, &["_continuum/neighbours"]))); assert!(refused(judged( &doc, "a", Some("alice"), &["_continuum/neighbours"] ))); // Logging out: its own tokens only. let tokens = "_continuum/users/bob/tokensNotBefore"; assert!(judged(&doc, "a", Some("bob"), &[tokens]).is_ok()); assert!(refused(judged(&doc, "a", Some("alice"), &[tokens]))); } /// A node that isn't a member may only register itself. #[test] fn strangers_register_themselves_only() { let doc = swarm(false); assert!( judged( &doc, "c", None, &[ "_continuum/members", "_continuum/members/c", "_continuum/members/c/alive" ] ) .is_ok() ); assert!(refused(judged(&doc, "c", None, &["notes/x"]))); assert!(refused(judged(&doc, "c", None, &["_continuum/members/a"]))); } /// User data: as the registry at the change's dependencies says; without one, any /// member's. #[test] fn user_data() { let doc = swarm(true); assert!(judged(&doc, "a", Some("alice"), &["notes/x"]).is_ok()); assert!(refused(judged(&doc, "a", Some("alice"), &["config"]))); assert!(refused(judged(&doc, "a", Some("bob"), &["notes/x"]))); assert!(refused(judged(&doc, "a", None, &["notes/x"]))); assert!(refused(judged(&doc, "a", Some("nobody"), &["notes/x"]))); let legacy = swarm(false); assert!(judged(&legacy, "a", None, &["notes/x"]).is_ok()); assert!(judged(&legacy, "a", None, &["_continuum/neighbours"]).is_ok()); } /// Judged by the registry its writer saw: a revocation since refuses nothing it /// allowed then. #[test] fn judged_as_its_writer_saw_it() { let mut doc = swarm(true); let then = doc.get_heads(); write( &mut doc, "_continuum/users/alice", registry::entry("a", 2, &[]), ); let touched = paths(&["notes/x"]); let change = |deps: &[ChangeHash]| { judge( &doc, Epoch::GENESIS, &Change { hash: ChangeHash([9; 32]), deps, seen: deps, committer: Some("a"), user: Some("alice"), paths: &touched, }, ) }; assert!(change(&then).is_ok()); assert!(refused(change(&doc.get_heads()))); // Unknown dependencies: not judged yet. assert_eq!(change(&[ChangeHash([7; 32])]), Err(Refusal::Unready)); } /// Roots: the first change of a swarm's first epoch, a later epoch's snapshot. #[test] fn roots() { let root = |doc: &Document, epoch: Epoch, hash: [u8; 32]| { let touched = paths(&["_continuum/swarm"]); judge( doc, epoch, &Change { hash: ChangeHash(hash), deps: &[], seen: &[], committer: None, user: None, paths: &touched, }, ) }; assert!(root(&Document::new(), Epoch::GENESIS, [1; 32]).is_ok()); assert!(refused(root(&swarm(false), Epoch::GENESIS, [1; 32]))); let epoch = Epoch { generation: 3, id: [5; 32], }; assert!(root(&swarm(false), epoch, [5; 32]).is_ok()); assert!(refused(root(&swarm(false), epoch, [6; 32]))); } }