aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/access.md6
-rw-r--r--src/access/rules.rs53
-rw-r--r--src/api/mod.rs1
-rw-r--r--src/document/store.rs4
-rw-r--r--src/simtest/policy.rs94
-rw-r--r--src/state/mod.rs54
-rw-r--r--src/swarm/refusals.rs53
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(()),
- })
-}