aboutsummaryrefslogtreecommitdiffstats
path: root/src/simtest/policy.rs
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-08 09:44:28 +0000
committertv <tv@krebsco.de>2026-11-08 14:29:01 +0100
commite63984e99bef66cb05d7bcf781812ea5e6276fa8 (patch)
treeaad499e46a8b63ebc2c329e5984ef48e45dc6a0a /src/simtest/policy.rs
parent4ecb4ee5c0a49fc6d78ae06e21f6bb1571e290cb (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/simtest/policy.rs')
-rw-r--r--src/simtest/policy.rs94
1 files changed, 88 insertions, 6 deletions
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);
+}