diff options
| -rw-r--r-- | src/access/mod.rs | 3 | ||||
| -rw-r--r-- | src/access/rules.rs | 404 | ||||
| -rw-r--r-- | src/document/meta.rs | 2 | ||||
| -rw-r--r-- | src/document/signatures.rs | 4 |
4 files changed, 411 insertions, 2 deletions
diff --git a/src/access/mod.rs b/src/access/mod.rs index 83a47b5..99c8ecd 100644 --- a/src/access/mod.rs +++ b/src/access/mod.rs @@ -14,6 +14,9 @@ use serde::{Deserialize, Serialize}; use crate::document::meta; pub(crate) mod registry; +// Judging changes on receipt is what uses it; until then, the tests. +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) mod rules; pub(crate) mod token; /// What a capability lets its holder do, at its path and below. diff --git a/src/access/rules.rs b/src/access/rules.rs new file mode 100644 index 0000000..9a9e5e2 --- /dev/null +++ b/src/access/rules.rs @@ -0,0 +1,404 @@ +//! 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 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<Path>, +} + +/// 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.deps).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<String> { + 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<Path> { + 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, + 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, + 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: &[], + 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]))); + } +} diff --git a/src/document/meta.rs b/src/document/meta.rs index 0c3ee89..5966b3c 100644 --- a/src/document/meta.rs +++ b/src/document/meta.rs @@ -16,6 +16,7 @@ //! writer if need be (`swarm::topology::swarm_ways`). //! * `users/<name>`: the user registry: who the users are, and what each may do //! (`access::registry`). +//! * `access/signed`: the paths whose writes are signed (`document::signatures`). /// The key every protocol path starts with. pub(crate) const ROOT: &str = "_continuum"; @@ -30,6 +31,7 @@ pub(crate) const RELAYS: &str = "relays"; pub(crate) const TOPOLOGY: &str = "topology"; pub(crate) const DISSEMINATION: &str = "dissemination"; pub(crate) const USERS: &str = "users"; +pub(crate) const ACCESS: &str = "access"; /// The store path `_continuum/<parts…>`. pub(crate) fn path(parts: &[&str]) -> Vec<String> { diff --git a/src/document/signatures.rs b/src/document/signatures.rs index d347e10..47e5917 100644 --- a/src/document/signatures.rs +++ b/src/document/signatures.rs @@ -24,7 +24,7 @@ use super::{ChangeHash, Document}; /// Where the swarm lists the paths whose writes are signed: a list of paths, each a /// list of segments, as capabilities name them (`access.md`). -pub(crate) const SIGNED_PATHS: [&str; 3] = ["_continuum", "access", "signed"]; +pub(crate) const SIGNED_PATHS: [&str; 3] = [super::meta::ROOT, super::meta::ACCESS, "signed"]; /// What a signature is made over: a domain, then the change's hash. pub(crate) fn message(hash: &ChangeHash) -> Vec<u8> { @@ -50,7 +50,7 @@ impl Sign { Sign::Protocol => true, Sign::Write { path, asked } => { *asked - || path.first().map(String::as_str) == Some(SIGNED_PATHS[0]) + || super::meta::is_protocol(path) || listed(doc).iter().any(|signed| related(signed, path)) } } |
