From e63984e99bef66cb05d7bcf781812ea5e6276fa8 Mon Sep 17 00:00:00 2001 From: tv Date: Sun, 8 Nov 2026 09:44:28 +0000 Subject: access: a node judges its own changes before they leave The API checks the path a request asks for; the rules judge everything the change does. Where they disagreed, the honest node's change was refused by every other node, and with signatures required the node was refused as a whole. The plan had both gates run the same judgement. Under a policy, each change a node makes (its own writes and those through the API) is now judged by rules::judge as receivers will judge it, before it is signed or sent; one every node would refuse is rolled back (the document forked at the heads before it, same actor: the change never left) and the request refused with 403 (StoreError:: Refused). rules::judge_held, judging a change the document holds, moves from swarm::refusals to access::rules for both. The simulations that play a member past its own checks write with a helper that bypasses them (forge). Review finding 3 (the same judgement at both gates). --- src/state/mod.rs | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) (limited to 'src/state') diff --git a/src/state/mod.rs b/src/state/mod.rs index 5a36faa..87b7fe7 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -236,6 +236,41 @@ pub(crate) struct CatchUpSource { pub(crate) refusals: u32, } +/// The change just made in `replica` (the one head after `before`), judged by the +/// swarm's rules as every node will judge it on receipt (`access::rules`), under a policy +/// judging every change: a write the API let through and the rules refuse would be +/// refused everywhere, with everything built on it. +fn judged_here( + replica: &crate::document::replica::Replica, + before: &[crate::document::ChangeHash], +) -> Result<(), String> { + if !crate::access::policy::of(&replica.doc).enforced() { + return Ok(()); + } + let after = replica.doc.get_heads(); + let [hash] = after[..] else { + return Ok(()); + }; + if after == before { + return Ok(()); + } + match crate::access::rules::judge_held(&replica.doc, replica.epoch, &hash) { + Some(judged) => judged, + None => Ok(()), + } +} + +/// The document back to `before`, the change just made dropped: it never left this node +/// (the write lock is held since), so its actor goes on with the same sequence. +fn roll_back( + replica: &mut crate::document::replica::Replica, + before: &[crate::document::ChangeHash], +) -> Result<(), StoreError> { + let actor = replica.doc.get_actor().clone(); + replica.doc = replica.doc.fork_at(before)?.with_actor(actor); + Ok(()) +} + impl AppState { /// Asks the sync worker to look at `peer` (`SyncEvent::PeerNeedsSync`): whether to /// send it a sync message now. Nothing if the worker is gone (the node stops). @@ -589,9 +624,22 @@ impl AppState { }; let before = replica.doc.get_heads(); let result = replica.transact(state.clock.now_millis(), &writer, f); - if result.is_ok() { - state.sign_made(&mut replica, &before, &sign); - } + let result = match result { + // Judged as every node will judge it, before it leaves; refused: as + // if never written. + Ok(out) => match judged_here(&replica, &before) { + Ok(()) => { + state.sign_made(&mut replica, &before, &sign); + Ok(out) + } + Err(why) => { + roll_back(&mut replica, &before)?; + tracing::debug!(reason = %why, "Refused a write every node would refuse"); + Err(E::from(StoreError::Refused(why))) + } + }, + Err(error) => Err(error), + }; replica.refresh_heads(); result.map(|out| (out, replica.heads().to_vec())) }; // lock released before notifying -- cgit v1.3.1