diff options
| -rw-r--r-- | docs/access.md | 6 | ||||
| -rw-r--r-- | src/access/rules.rs | 53 | ||||
| -rw-r--r-- | src/api/mod.rs | 1 | ||||
| -rw-r--r-- | src/document/store.rs | 4 | ||||
| -rw-r--r-- | src/simtest/policy.rs | 94 | ||||
| -rw-r--r-- | src/state/mod.rs | 54 | ||||
| -rw-r--r-- | src/swarm/refusals.rs | 53 |
7 files changed, 204 insertions, 61 deletions
diff --git a/docs/access.md b/docs/access.md index 3bc824b..f533c5f 100644 --- a/docs/access.md +++ b/docs/access.md @@ -235,6 +235,12 @@ stays as it is, wherever it comes from later (a joining node catching up, say). a node, below). So a node registers before anything else it writes, through the API or its own: a change refused stays out with every change built on it. +A node judges each change it makes the same way before it leaves, its own writes and +those through the API alike: one every node would refuse is rolled back, never +written, and the request refused (`403`, saying why). So a write the API's checks let +through (they look at the path asked for, the rules at everything the change does) +can't cut its node off. + With `"signatures": "required"`, a change also needs a signature by the node it names ([`api.md`](api.md), Signed changes): every node then signs every change it makes, and one no signature vouches for yet waits, with whatever builds on it, until one does diff --git a/src/access/rules.rs b/src/access/rules.rs index 0de901a..320ccd8 100644 --- a/src/access/rules.rs +++ b/src/access/rules.rs @@ -215,6 +215,59 @@ pub(crate) fn judge(doc: &Document, epoch: Epoch, change: &Change<'_>) -> Result Ok(()) } +/// `hash`, which `doc` (of `epoch`) holds, judged by its rules now, as received: why +/// they refuse it, if they do; None if `doc` doesn't hold it. A change this node just +/// made, before it leaves (`AppState::mutate_doc_for`), or one a peer says it refused +/// (`swarm::refusals`). +pub(crate) fn judge_held( + doc: &crate::document::Document, + epoch: Epoch, + hash: &ChangeHash, +) -> Option<Result<(), String>> { + let change = doc.get_change_by_hash(hash)?; + // An epoch's root is what the document is: never refused, whatever a peer says. + if change.deps().is_empty() { + return Some(Ok(())); + } + let decoded = change.decode(); + let ops = crate::document::inspect::Inspector::new(doc) + .ops(std::slice::from_ref(&decoded)) + .pop() + .unwrap_or_default(); + let paths = ops.iter().map(|op| op.path.clone()).collect(); + let committer = doc + .get_author_for_actor(change.actor_id()) + .and_then(|author| { + std::str::from_utf8(author.as_bytes()) + .ok() + .map(str::to_owned) + }); + let user = change + .message() + .and_then(crate::document::replica::user_of) + .map(str::to_owned); + let deps = change.deps().to_vec(); + let judged = judge( + doc, + epoch, + &Change { + hash: *hash, + deps: &deps, + seen: &deps, + committer: committer.as_deref(), + user: user.as_deref(), + paths: &paths, + ops: &ops, + }, + ); + Some(match judged { + Ok(()) => Ok(()), + Err(Refusal::Rule(why)) => Err(why), + // Its dependencies are in the document: it is. + Err(Refusal::Unready) => Ok(()), + }) +} + /// Where values are declared sealed (`declared`), `ops` write only sealed values as /// declared, judged at `at`, as their writer saw it (`sealed::check`): at such a place, a /// string put there that is one (or deleting what is there); nothing within one; nothing diff --git a/src/api/mod.rs b/src/api/mod.rs index 9405a06..0e7fc21 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -335,6 +335,7 @@ pub(crate) fn store_status(error: StoreError, context: &str) -> ApiError { StoreError::NotFound | StoreError::UnknownHeads => StatusCode::NOT_FOUND, StoreError::Rejoining => StatusCode::SERVICE_UNAVAILABLE, StoreError::UnknownPolicy(_) => StatusCode::CONFLICT, + StoreError::Refused(_) => StatusCode::FORBIDDEN, other => { error!(error = %format!("{other:#}"), "{context}"); return StatusCode::INTERNAL_SERVER_ERROR.into(); diff --git a/src/document/store.rs b/src/document/store.rs index c946fef..d323928 100644 --- a/src/document/store.rs +++ b/src/document/store.rs @@ -181,6 +181,10 @@ pub(crate) enum StoreError { "the swarm's policy (_continuum/access/policy) is one this node doesn't know ({0}); upgrade it to write" )] UnknownPolicy(String), + /// Every node would refuse the change this write makes (`access::rules`), why: + /// the write is rolled back. + #[error("every node would refuse this write: {0}")] + Refused(String), #[error(transparent)] Doc(#[from] automerge::AutomergeError), #[error(transparent)] diff --git a/src/simtest/policy.rs b/src/simtest/policy.rs index da046bb..8057660 100644 --- a/src/simtest/policy.rs +++ b/src/simtest/policy.rs @@ -33,6 +33,36 @@ async fn write_as(node: &AppState, user: &str, at: &str, value: serde_json::Valu .expect("write"); } +/// Writes `value` at `at` on `node` as `user`, past the checks a node makes of its own +/// writes (the API's, and judging the change as every node will): as a member that +/// bypasses them would. Signed by the node, as its own. +async fn forge(node: &AppState, user: &str, at: &str, value: serde_json::Value) { + let at = path(at); + let user = user.to_owned(); + let state = node.clone(); + tokio::task::spawn_blocking(move || { + let mut replica = state.doc.blocking_write(); + let writer = crate::document::replica::Writer { + node: state.local_id(), + user: Some(&user), + }; + replica + .transact(state.clock.now_millis(), &writer, |doc| { + store::write_path(doc, &at, &value) + }) + .expect("forged write"); + let heads = replica.doc.get_heads(); + if let (Some(key), [hash]) = (&state.node_key, &heads[..]) { + let replica = &mut *replica; + replica.signatures.sign(&replica.doc, *hash, key); + } + replica.refresh_heads(); + }) + .await + .expect("forged write"); + node.publish_change(); +} + async fn holds(node: &AppState, at: &str) -> bool { let at = path(at); let replica = node.doc.read().await; @@ -83,7 +113,7 @@ async fn a_swarm_judging_every_change() { // A write c lets through for a user the registry doesn't have: refused by the // others, and so is what c builds on it. - write_as(&c, "mallory", "notes/forged", json!(4)).await; + forge(&c, "mallory", "notes/forged", json!(4)).await; for node in [&a, &b, &d] { sim.wait_until("the forged write is refused", SOON, move || async move { refused(node).await > 0 @@ -305,8 +335,8 @@ async fn a_strict_swarm() { // c writes the registry plain, as a user who may manage users: refused by the // others, and so is a plain write over the signed value. let mallory = crate::access::registry::entry("alice", 1, &crate::access::registry::all_caps()); - write_as(&c, "alice", "_continuum/users/mallory", mallory).await; - write_as(&c, "alice", "config", json!({"mode": "forged"})).await; + forge(&c, "alice", "_continuum/users/mallory", mallory).await; + forge(&c, "alice", "config", json!({"mode": "forged"})).await; for node in [&a, &b] { sim.wait_until("the plain writes are refused", SOON, move || async move { refused(node).await > 0 @@ -382,8 +412,8 @@ async fn sealed_values() { // c writes one sealed for a stranger too: refused by the others, and so is what it // builds on it. let stray = sealed_for(&[alice_key, PublicKey([22; 32])]); - write_as(&c, "alice", "secrets/web", json!(stray)).await; - write_as(&c, "alice", "secrets/api", json!("hunter2")).await; + forge(&c, "alice", "secrets/web", json!(stray)).await; + forge(&c, "alice", "secrets/api", json!("hunter2")).await; for node in [&a, &b] { sim.wait_until( "the badly sealed writes are refused", @@ -514,7 +544,7 @@ async fn a_member_writing_against_the_rules() { .await; sim.wait_converged(&[&a, &b, &c], SOON).await; - write_as(&c, "mallory", "notes/forged", json!(1)).await; + forge(&c, "mallory", "notes/forged", json!(1)).await; let rogue = c.local_id().to_owned(); // By whichever node judges it first: the other may still be waiting for its @@ -613,3 +643,55 @@ async fn a_refusal_naming_the_root() { assert!(holds(&b, "notes/x").await, "b dropped its document"); assert_eq!(b.remake.list_quarantine(|entry| entry.id.clone()).len(), 0); } + +/// A write the API would let through but every node's rules refuse (here: past the +/// API, to a path its user may not write): judged before it leaves, refused, rolled +/// back; what the node writes next goes everywhere. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_write_every_node_would_refuse() { + let sim = Sim::with_identity(79); + let a = sim.start_with("a", &[], Start::Genesis, |config| { + config.first_users = [("alice".to_owned(), Default::default())].into(); + }); + let b = sim.start("b", &["a"], Start::Join); + sim.wait_ready(&[&a, &b], SOON).await; + let bob = crate::access::registry::entry( + "alice", + 1, + &[(path("notes"), &[crate::access::Right::Write][..])], + ); + write_as(&a, "alice", "_continuum/users/bob", bob).await; + write_as( + &a, + "alice", + "_continuum/access/policy", + json!({"version": 1}), + ) + .await; + sim.wait_converged(&[&a, &b], SOON).await; + + let heads = b.doc.read().await.doc.get_heads(); + let at = path("secrets/x"); + let refused_write = b + .mutate_doc_for( + Some("bob".to_owned()), + Sign::Write { + path: at.clone(), + asked: false, + }, + move |doc| store::write_path(doc, &at, &json!(1)), + ) + .await; + assert!( + matches!(refused_write, Err(store::StoreError::Refused(_))), + "a write every node would refuse" + ); + assert_eq!(b.doc.read().await.doc.get_heads(), heads, "rolled back"); + write_as(&b, "bob", "notes/x", json!(2)).await; + sim.wait_until("bob's next write reaches a", SOON, || async { + holds(&a, "notes/x").await + }) + .await; + assert!(!holds(&a, "secrets/x").await); + assert_eq!(refused(&a).await, 0); +} 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 diff --git a/src/swarm/refusals.rs b/src/swarm/refusals.rs index fd857fa..30facb4 100644 --- a/src/swarm/refusals.rs +++ b/src/swarm/refusals.rs @@ -66,7 +66,7 @@ pub(crate) async fn received(state: &AppState, peer: &str, message: RefusalsMess let mut refused = None; let mut taken = Vec::new(); for hash in changes { - match judge_held(&replica.doc, replica.epoch, &hash) { + match crate::access::rules::judge_held(&replica.doc, replica.epoch, &hash) { Some(Err(why)) => { refused = Some((hash, why)); break; @@ -90,54 +90,3 @@ pub(crate) async fn received(state: &AppState, peer: &str, message: RefusalsMess warn!(peer = %peer, changes = ?taken, "A peer refuses changes our rules take: its policy or ours is off"); } } - -/// `hash`, which `doc` (of `epoch`) holds, judged by its rules now: why they refuse it, -/// if they do; None if `doc` doesn't hold it. -fn judge_held( - doc: &crate::document::Document, - epoch: Epoch, - hash: &ChangeHash, -) -> Option<Result<(), String>> { - let change = doc.get_change_by_hash(hash)?; - // An epoch's root is what the document is: never refused, whatever a peer says. - if change.deps().is_empty() { - return Some(Ok(())); - } - let decoded = change.decode(); - let ops = crate::document::inspect::Inspector::new(doc) - .ops(std::slice::from_ref(&decoded)) - .pop() - .unwrap_or_default(); - let paths = ops.iter().map(|op| op.path.clone()).collect(); - let committer = doc - .get_author_for_actor(change.actor_id()) - .and_then(|author| { - std::str::from_utf8(author.as_bytes()) - .ok() - .map(str::to_owned) - }); - let user = change - .message() - .and_then(crate::document::replica::user_of) - .map(str::to_owned); - let deps = change.deps().to_vec(); - let judged = crate::access::rules::judge( - doc, - epoch, - &crate::access::rules::Change { - hash: *hash, - deps: &deps, - seen: &deps, - committer: committer.as_deref(), - user: user.as_deref(), - paths: &paths, - ops: &ops, - }, - ); - Some(match judged { - Ok(()) => Ok(()), - Err(crate::access::rules::Refusal::Rule(why)) => Err(why), - // Its dependencies are in the document: it is. - Err(crate::access::rules::Refusal::Unready) => Ok(()), - }) -} |
