From 1769f30038c62c999cfb91be0868c740fb1c0eba Mon Sep 17 00:00:00 2001 From: tv Date: Sun, 8 Nov 2026 13:52:35 +0000 Subject: rules: protocol maps judged by what is done to them Review finding 7, first part: the protocol-map rules looked at paths, not actions. Any member could delete _continuum/members, /votes or /remakes whole, or rewrite another remake's proposal (its coordinator) and then write its decision. What holds the members' entries, votes and remake records, and a remake's record itself, are now made by a member only where there is none; a proposal is written only where there is none. Replacing or removing any of them is left to a user who may write there (the protocol writers). An abort asked for stays any member's. --- src/access/rules.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) (limited to 'src/access') diff --git a/src/access/rules.rs b/src/access/rules.rs index 320ccd8..2746cb3 100644 --- a/src/access/rules.rs +++ b/src/access/rules.rs @@ -141,6 +141,15 @@ pub(crate) fn judge(doc: &Document, epoch: Epoch, change: &Change<'_>) -> Result && store::read_path(&at, path).ok().flatten().is_none() }) .collect(); + // Whether the change only makes a map at `path`, where there is none. + let made = |path: &Path| { + change + .ops + .iter() + .filter(|op| op.path == *path) + .all(|op| op.action == Action::MakeMap) + && store::read_path(&at, path).ok().flatten().is_none() + }; for path in change.paths { if on_the_way.contains(path) { continue; @@ -168,8 +177,11 @@ pub(crate) fn judge(doc: &Document, epoch: Epoch, change: &Change<'_>) -> Result && user_may(&forgetting)) } (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, + // What holds them: made by whichever member first writes in it, where there + // is none; replaced or removed by one who may write it. + (Some(meta::MEMBERS | meta::VOTES | meta::REMAKES), None) => { + made(path) || user_may(path) + } // Written by a root only. (Some(meta::EPOCHS | meta::SWARM), _) => false, // Logging out: a user revokes its own tokens (`POST /auth/logout`); one who @@ -197,6 +209,18 @@ pub(crate) fn judge(doc: &Document, epoch: Epoch, change: &Change<'_>) -> Result .any(|other| other == &meta::path(&[meta::REMAKES, id])) } }, + // The record, made where there is none, and its proposal, written where + // there is none: by whichever member proposes. Replaced or removed by + // one who may write them. + None => made(path) || user_may(path), + Some("proposal") => { + store::read_path(&at, &meta::path(&[meta::REMAKES, id, "proposal"])) + .ok() + .flatten() + .is_none() + || user_may(path) + } + // The rest (an abort asked for), any member's. _ => true, }, _ => user_may(path), @@ -500,6 +524,26 @@ mod tests { &["_continuum/remakes/r1/decision"] ))); assert!(judged(&doc, "b", None, &["_continuum/remakes/r1/abortRequested"]).is_ok()); + // Its proposal: written where there is none; another's replaced, by one who may. + assert!(judged(&doc, "b", None, &["_continuum/remakes/r3/proposal"]).is_ok()); + assert!(refused(judged( + &doc, + "b", + None, + &["_continuum/remakes/r1/proposal"] + ))); + assert!(refused(judged( + &doc, + "b", + None, + &["_continuum/remakes/r1/proposal/coordinator"] + ))); + // What holds the records, a member's entries, votes: made where there are none, + // never replaced or removed by a member. + assert!(refused(judged(&doc, "b", None, &["_continuum/remakes"]))); + assert!(refused(judged(&doc, "b", None, &["_continuum/members"]))); + assert!(refused(judged(&doc, "b", None, &["_continuum/remakes/r1"]))); + assert!(judged(&doc, "b", None, &["_continuum/votes"]).is_ok()); // A record carried into a new epoch whole: its proposal with its decision. assert!( judged( -- cgit v1.3.1