From b08c0bfd3ffcfd39ef2689882fafafbc31f16ffd Mon Sep 17 00:00:00 2001 From: tv Date: Sun, 8 Nov 2026 09:39:52 +0000 Subject: access: forgetting and revoking as the API lets Two writes the API lets a user make were refused by the rules: * DELETE /peers/{pk}, with write on _continuum/forgotten alone, writes the forget record and removes the member's entry in one change; the rules wanted write on the entry. Removing an entry is now also taken in a change forgetting that member, from a user who may forget it. * DELETE /admin/users/{name}/tokens by a user who manages users writes _continuum/logouts/; the rules wanted write there. One who may write _continuum/users may now write anyone's logouts entry. Review finding 3 (DELETE /peers, revoking tokens). --- src/access/rules.rs | 50 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 4 deletions(-) (limited to 'src/access') diff --git a/src/access/rules.rs b/src/access/rules.rs index 78557b0..0de901a 100644 --- a/src/access/rules.rs +++ b/src/access/rules.rs @@ -154,15 +154,31 @@ pub(crate) fn judge(doc: &Document, epoch: Epoch, change: &Change<'_>) -> Result 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), + // A member's entry, its votes: its own. Removing an entry, also a user's: one + // who may write it, or forgets the member in the same change + // (`DELETE /peers/{pk}`, which needs `_continuum/forgotten` alone). + (Some(meta::MEMBERS), Some(owner)) => { + let forgetting = meta::path(&[meta::FORGOTTEN, owner]); + owner == node + || user_may(path) + || (change + .paths + .iter() + .any(|other| other.starts_with(&forgetting)) + && 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, // Written by a root only. (Some(meta::EPOCHS | meta::SWARM), _) => false, - // Logging out: a user revokes its own tokens (`POST /auth/logout`). - (Some(meta::LOGOUTS), Some(owner)) => change.user == Some(owner) || user_may(path), + // Logging out: a user revokes its own tokens (`POST /auth/logout`); one who + // manages users, anyone's (`DELETE /admin/users/{name}/tokens`). + (Some(meta::LOGOUTS), Some(owner)) => { + change.user == Some(owner) + || user_may(path) + || user_may(&meta::path(&[meta::USERS])) + } // A node forgotten while alive cancels its own record (`swarm::peer::forget`). (Some(meta::FORGOTTEN), Some(owner)) => owner == node || user_may(path), (Some(meta::REMAKES), Some(id)) => match path.get(3).map(String::as_str) { @@ -463,6 +479,32 @@ mod tests { let tokens = "_continuum/logouts/bob"; assert!(judged(&doc, "a", Some("bob"), &[tokens]).is_ok()); assert!(refused(judged(&doc, "a", Some("alice"), &[tokens]))); + // Revoked by one who manages users. + let mut managed = swarm(true); + write( + &mut managed, + "_continuum/users/carol", + registry::entry("a", 2, &[(path("_continuum/users"), &[Right::Write][..])]), + ); + assert!(judged(&managed, "a", Some("carol"), &[tokens]).is_ok()); + // A member forgotten, its entry removed with it, by one who may forget only. + write( + &mut managed, + "_continuum/users/dave", + registry::entry( + "a", + 3, + &[(path("_continuum/forgotten"), &[Right::Write][..])], + ), + ); + let forgetting = ["_continuum/forgotten/b", "_continuum/members/b"]; + assert!(judged(&managed, "a", Some("dave"), &forgetting).is_ok()); + assert!(refused(judged( + &managed, + "a", + Some("dave"), + &["_continuum/members/b"] + ))); // A forget record: cancelled by the node it names, not by another. let forgotten = "_continuum/forgotten/a"; assert!(judged(&doc, "a", None, &[forgotten]).is_ok()); -- cgit v1.3.1