diff options
| author | tv <tv@krebsco.de> | 2026-11-08 09:44:28 +0000 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-08 14:29:01 +0100 |
| commit | e63984e99bef66cb05d7bcf781812ea5e6276fa8 (patch) | |
| tree | aad499e46a8b63ebc2c329e5984ef48e45dc6a0a /src/state/mod.rs | |
| parent | 4ecb4ee5c0a49fc6d78ae06e21f6bb1571e290cb (diff) | |
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).
Diffstat (limited to 'src/state/mod.rs')
| -rw-r--r-- | src/state/mod.rs | 54 |
1 files changed, 51 insertions, 3 deletions
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 |
