aboutsummaryrefslogtreecommitdiffstats
path: root/src/access
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-08 13:52:35 +0000
committertv <tv@krebsco.de>2026-11-08 15:04:01 +0100
commit1769f30038c62c999cfb91be0868c740fb1c0eba (patch)
tree50a6e4aa4d660b4f8cdc8821ad6b27caceece2bb /src/access
parentb3526df29412400cfaa8c3a8d8692e37fc44d19b (diff)
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.
Diffstat (limited to 'src/access')
-rw-r--r--src/access/rules.rs48
1 files changed, 46 insertions, 2 deletions
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(