aboutsummaryrefslogtreecommitdiffstats
path: root/src/access/rules.rs
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-07 22:35:21 +0100
committertv <tv@krebsco.de>2026-11-07 22:35:21 +0100
commitd7a6bc3ed201ff2ca953f74f62154e5e17728f22 (patch)
treeab5c1e4ebfab500539f313b8e8bebf95a6edf29e /src/access/rules.rs
parentcc691bade836d27d02bb2330f0fa323a1d88a1bb (diff)
access: rules by path
access::rules::judge: a change judged as its writer saw the document (at its dependencies): committer a member or registering itself; members' entries and votes their own; epochs and swarm ID from roots; a remake's decision by its coordinator; the rest by the registry for the user the change names. Auth plan M6.
Diffstat (limited to 'src/access/rules.rs')
-rw-r--r--src/access/rules.rs404
1 files changed, 404 insertions, 0 deletions
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])));
+ }
+}