aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/access/rules.rs201
-rw-r--r--src/access/screen.rs25
-rw-r--r--src/api/store.rs6
-rw-r--r--src/document/inspect.rs97
4 files changed, 313 insertions, 16 deletions
diff --git a/src/access/rules.rs b/src/access/rules.rs
index 4df0ba8..1ce22ee 100644
--- a/src/access/rules.rs
+++ b/src/access/rules.rs
@@ -17,6 +17,8 @@
//! proposal names, the rest of a record by any member; a user's tokens revoked by that
//! user too (logging out); the rest by a user who may write there.
//! * User data: by a user who may write there.
+//! * Under a strict policy (`access::policy`), the values their users sign
+//! (`access::envelope`) only as they signed them (`signed_values`).
//!
//! In a swarm without a user registry (`access::registry`), whatever a user would need,
//! any member may.
@@ -26,7 +28,7 @@ use std::collections::BTreeSet;
use crate::access::Right;
use crate::access::registry;
use crate::document::at::At;
-use crate::document::inspect::Path;
+use crate::document::inspect::{Action, Op, Path};
use crate::document::meta;
use crate::document::replica::Epoch;
use crate::document::{ChangeHash, Document, store};
@@ -46,6 +48,8 @@ pub(crate) struct Change<'a> {
pub(crate) user: Option<&'a str>,
/// What it touches (`inspect::Inspector`).
pub(crate) paths: &'a BTreeSet<Path>,
+ /// Its operations, where each is and what it does there (`inspect::Inspector::ops`).
+ pub(crate) ops: &'a [Op],
}
/// Why a change is refused.
@@ -150,9 +154,87 @@ pub(crate) fn judge(doc: &Document, epoch: Epoch, change: &Change<'_>) -> Result
return refuse(format!("{} may not write {}", who(change), shown(path)));
}
}
+ if super::policy::of(&at).strict() {
+ signed_values(&at, change.ops).or_else(refuse)?;
+ }
+ Ok(())
+}
+
+/// Under a strict policy, `ops` change the values their users sign (`access::envelope`)
+/// only as they signed them, judged at `at`, as their writer saw it: at such a place, a
+/// string put there that is an envelope by a user who may write there, replacing the
+/// one there (`envelope::check`), and nothing else (but deleting what is there first,
+/// as a write replacing it does); nothing within one; above one,
+/// nothing replacing or deleting what holds one (a map made where there is none yet,
+/// what is inside a list or text, which holds none).
+fn signed_values(at: &At<'_>, ops: &[Op]) -> Result<(), String> {
+ let signed = super::envelope::signed_paths(at);
+ for op in ops {
+ let path = &op.path;
+ if op.action == Action::Nowhere {
+ return Err("it writes in an object nowhere to be found".to_owned());
+ }
+ if super::envelope::is_signed(&signed, path) {
+ let replaced = || {
+ ops.iter().any(|other| {
+ other.path == *path && matches!(other.action, Action::PutString(_))
+ })
+ };
+ let envelope = match &op.action {
+ Action::PutString(envelope) => envelope,
+ // Cleared for the string put there, which is checked.
+ Action::Delete if replaced() => continue,
+ _ => return Err(format!("{} holds a value its user signs", shown(path))),
+ };
+ let previous = match store::read_path(at, path) {
+ Ok(Some(serde_json::Value::String(text)))
+ if super::envelope::read(&text).is_ok() =>
+ {
+ Some(text)
+ }
+ _ => None,
+ };
+ super::envelope::check(at, envelope, path, previous.as_deref())
+ .map_err(|why| format!("{}: {why}", shown(path)))?;
+ } else if super::envelope::signed_path(&signed, path).is_some() {
+ return Err(format!("{} is within a value its user signs", shown(path)));
+ } else if op.action != Action::Within
+ && super::envelope::above_signed(&signed, path)
+ && holds_signed(at, path, &signed)
+ {
+ return Err(format!("{} holds values their users sign", shown(path)));
+ }
+ }
Ok(())
}
+/// Whether what is at `path` in `at` holds a value at a place for one its user signs,
+/// of `signed` (`envelope::signed_paths`), signed or from before the policy; one that
+/// doesn't read, as if it did.
+fn holds_signed(at: &At<'_>, path: &[String], signed: &[Vec<String>]) -> bool {
+ fn within(value: &serde_json::Value, place: &mut Vec<String>, signed: &[Vec<String>]) -> bool {
+ if super::envelope::is_signed(signed, place) {
+ return true;
+ }
+ match value {
+ serde_json::Value::Object(entries) if super::envelope::above_signed(signed, place) => {
+ entries.iter().any(|(key, value)| {
+ place.push(key.clone());
+ let holds = within(value, place, signed);
+ place.pop();
+ holds
+ })
+ }
+ _ => false,
+ }
+ }
+ match store::read_path(at, path) {
+ Ok(Some(value)) => within(&value, &mut path.to_vec(), signed),
+ Ok(None) => false,
+ Err(_) => true,
+ }
+}
+
/// Who wrote it, for a refusal.
fn who(change: &Change<'_>) -> String {
match (change.committer, change.user) {
@@ -246,6 +328,7 @@ mod tests {
committer: Some(committer),
user,
paths: &touched,
+ ops: &[],
},
)
}
@@ -335,6 +418,7 @@ mod tests {
committer: Some("b"),
user: None,
paths: &touched,
+ ops: &[],
},
);
assert!(earlier.is_ok());
@@ -399,6 +483,7 @@ mod tests {
committer: Some("a"),
user: Some("alice"),
paths: &touched,
+ ops: &[],
},
)
};
@@ -408,6 +493,119 @@ mod tests {
assert_eq!(change(&[ChangeHash([7; 32])]), Err(Refusal::Unready));
}
+ /// Under a strict policy, a value its user signs only as signed: an envelope that
+ /// checks, replacing the one there; nothing else there, nothing within one, nothing
+ /// above replacing what holds one.
+ #[test]
+ fn signed_values_as_signed() {
+ use crate::access::{envelope, sshsig};
+ let key = ed25519_dalek::SigningKey::from_bytes(&[3; 32]);
+ let mut doc = swarm(false);
+ let mut alice = registry::entry(
+ "a",
+ 1,
+ &[
+ (Vec::new(), &[Right::Write][..]),
+ (path("_continuum"), &[Right::Write][..]),
+ ],
+ );
+ alice["keys"] = json!({ "k": sshsig::PublicKey(key.verifying_key().to_bytes()).line() });
+ write(&mut doc, "_continuum/users/alice", alice);
+ write(
+ &mut doc,
+ "_continuum/access/userSigned",
+ json!([["config"], ["a", "b"]]),
+ );
+ write(
+ &mut doc,
+ "_continuum/access/policy",
+ json!({"version": 1, "values": "signed"}),
+ );
+ let sealed = |at: &str, prev: &str| {
+ let payload = envelope::Payload {
+ path: path(at),
+ value: json!({"mode": "fast"}),
+ user: "alice".to_owned(),
+ at: 1,
+ prev: prev.to_owned(),
+ };
+ let bytes = serde_json::to_vec(&payload).unwrap();
+ envelope::seal(&bytes, &sshsig::sign(&key, envelope::NAMESPACE, &bytes)).unwrap()
+ };
+ let judged = |doc: &Document, ops: Vec<Op>| {
+ let deps = doc.get_heads();
+ let touched: BTreeSet<Path> = ops.iter().map(|op| op.path.clone()).collect();
+ judge(
+ doc,
+ Epoch::GENESIS,
+ &Change {
+ hash: ChangeHash([9; 32]),
+ deps: &deps,
+ seen: &deps,
+ committer: Some("a"),
+ user: Some("alice"),
+ paths: &touched,
+ ops: &ops,
+ },
+ )
+ };
+ let op = |at: &str, action: Action| Op {
+ path: path(at),
+ action,
+ };
+ let first = sealed("config", "");
+ assert!(judged(&doc, vec![op("config", Action::PutString(first.clone()))]).is_ok());
+ assert!(refused(judged(
+ &doc,
+ vec![op("config", Action::PutString("plain".to_owned()))]
+ )));
+ assert!(refused(judged(
+ &doc,
+ vec![op("config", Action::PutString(sealed("other", "")))]
+ )));
+ assert!(refused(judged(&doc, vec![op("config", Action::Delete)])));
+ assert!(refused(judged(
+ &doc,
+ vec![
+ op("config", Action::Delete),
+ op("config", Action::PutString("plain".to_owned()))
+ ]
+ )));
+ assert!(refused(judged(&doc, vec![op("config/x", Action::Other)])));
+ // Replacing: only the one there.
+ write(&mut doc, "config", json!(first));
+ let second = sealed("config", &envelope::digest(Some(&first)));
+ assert!(judged(&doc, vec![op("config", Action::PutString(second.clone()))]).is_ok());
+ // As a write replacing it makes it: what is there deleted, then the string put.
+ assert!(
+ judged(
+ &doc,
+ vec![
+ op("config", Action::Delete),
+ op("config", Action::PutString(second))
+ ]
+ )
+ .is_ok()
+ );
+ assert!(refused(judged(
+ &doc,
+ vec![op("config", Action::PutString(sealed("config", "")))]
+ )));
+ // Above: a map made where there is none, not what holds a signed value.
+ assert!(judged(&doc, vec![op("a", Action::MakeMap)]).is_ok());
+ assert!(refused(judged(
+ &doc,
+ vec![op("_continuum/users", Action::Delete)]
+ )));
+ assert!(refused(judged(
+ &doc,
+ vec![op("_continuum/users/alice/caps", Action::Other)]
+ )));
+ // Elsewhere: as without the policy.
+ assert!(judged(&doc, vec![op("notes/x", Action::PutString("x".to_owned()))]).is_ok());
+ assert!(refused(judged(&doc, vec![op("", Action::Nowhere)])));
+ }
+
/// Roots: the first change of a swarm's first epoch, a later epoch's snapshot.
#[test]
fn roots() {
@@ -423,6 +621,7 @@ mod tests {
committer: None,
user: None,
paths: &touched,
+ ops: &[],
},
)
};
diff --git a/src/access/screen.rs b/src/access/screen.rs
index 507de1d..736e04a 100644
--- a/src/access/screen.rs
+++ b/src/access/screen.rs
@@ -4,8 +4,8 @@
//!
//! A change is judged at the heads its writer saw. Those of a batch aren't in the
//! document yet, so one is judged at the changes of the document its batch ancestors
-//! build on: what rules read there (the members, the user registry, remake proposals)
-//! is what its writer saw, unless one of those ancestors changed it. So a change that
+//! build on: what rules read there (the members, the user registry, remake proposals,
+//! the policy, bans) is what its writer saw, unless one of those ancestors changed it. So a change that
//! does (`relevant`) is applied before the next is judged: the rest go in at once.
//!
//! A change whose dependencies are neither in the document nor among those received
@@ -19,7 +19,7 @@ use std::collections::{BTreeSet, HashMap, HashSet};
use anyhow::Result;
use super::rules::{self, Refusal};
-use crate::document::inspect::{Inspector, Path};
+use crate::document::inspect::{Inspector, Op, Path};
use crate::document::meta;
use crate::document::replica::{Epoch, user_of};
use crate::document::signatures::Signatures;
@@ -133,7 +133,11 @@ pub(crate) fn apply(
let decoded: Vec<crate::document::ExpandedChange> =
ready.iter().map(|change| change.decode()).collect();
- let paths = Inspector::new(doc).paths(&decoded);
+ let ops: Vec<Vec<Op>> = Inspector::new(doc).ops(&decoded);
+ let paths: Vec<BTreeSet<Path>> = ops
+ .iter()
+ .map(|ops| ops.iter().map(|op| op.path.clone()).collect())
+ .collect();
// Who made each: its author, or that of its actor's first change (in the document,
// or among these).
let mut authors: HashMap<crate::document::ActorId, String> = HashMap::new();
@@ -216,6 +220,7 @@ pub(crate) fn apply(
committer,
user: user.as_deref(),
paths: &paths[index],
+ ops: &ops[index],
},
);
match judged {
@@ -285,13 +290,21 @@ pub(crate) fn apply_bytes(
}
/// Whether a change touching `paths` changes what rules read: the members, the user
-/// registry, remake records, the protocol data as a whole.
+/// registry, remake records, the policy and the rest of `_continuum/access`, bans, the
+/// protocol data as a whole.
fn relevant(paths: &BTreeSet<Path>) -> bool {
paths.iter().any(|path| match path.as_slice() {
[] => true,
[root, ..] if root != meta::ROOT => false,
[_] => true,
- [_, part, ..] => [meta::MEMBERS, meta::USERS, meta::REMAKES].contains(&part.as_str()),
+ [_, part, ..] => [
+ meta::MEMBERS,
+ meta::USERS,
+ meta::REMAKES,
+ meta::ACCESS,
+ meta::BANNED,
+ ]
+ .contains(&part.as_str()),
})
}
diff --git a/src/api/store.rs b/src/api/store.rs
index b5baa23..e61a09d 100644
--- a/src/api/store.rs
+++ b/src/api/store.rs
@@ -564,7 +564,11 @@ async fn put_signed(
envelope::check(&*doc, &sealed, &path, previous.as_deref())
.map_err(|error| ApiError(StatusCode::FORBIDDEN, Some(error)))?;
let before = Before::of(&*doc, &writer);
- store::write_path(doc, &path, &JsonValue::String(sealed))
+ // A plain string, whatever was there: what every node checks it as
+ // (`access::rules`).
+ let envelope = Val::Scalar(ScalarValue::Str(sealed.into()));
+ let writing = store::Writing::new(store::Sticky::No, TimestampFormat::default(), true);
+ store::write_val_path(doc, &path, &envelope, writing)
.map_err(|e| store_status(e, "Failed to write store value"))?;
registry_kept(&*doc, &path, before, &writer)?;
read_shown(
diff --git a/src/document/inspect.rs b/src/document/inspect.rs
index b39386e..d395008 100644
--- a/src/document/inspect.rs
+++ b/src/document/inspect.rs
@@ -11,9 +11,6 @@
//! change lacks a dependency, and can't be applied anyway) is placed at the root, which
//! is within every path's reach.
-// Judging changes on receipt is what uses it; until then, the benchmark and tests.
-#![cfg_attr(not(test), allow(dead_code))]
-
use std::collections::{BTreeSet, HashMap};
use automerge::legacy::{Key, ObjectId, OpId, OpType};
@@ -24,6 +21,31 @@ use super::{Document, ReadDoc};
/// A store path, by segments.
pub(crate) type Path = Vec<String>;
+/// An operation, as rules see it (`Inspector::ops`): where, and what it does there.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct Op {
+ /// What it touches.
+ pub(crate) path: Path,
+ pub(crate) action: Action,
+}
+
+/// What an operation does at its path.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) enum Action {
+ /// Makes a map there.
+ MakeMap,
+ /// Puts a string there.
+ PutString(String),
+ /// Deletes what is there.
+ Delete,
+ /// Changes what is there, a list or text it is in (`Place::in_list`).
+ Within,
+ /// In an object nowhere to be found (`Inspector::known`).
+ Nowhere,
+ /// Anything else there: makes another object, puts another scalar, increments.
+ Other,
+}
+
/// Where an object is: its path, cut at the first list or text on the way
/// (`in_list`: it is inside one, or is one).
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -79,10 +101,20 @@ impl<'a> Inspector<'a> {
}
}
- /// The paths each of `changes` touches, in their order. The objects they make are
- /// placed first, whatever their order, so a change is judged the same however the
- /// changes came.
+ /// The paths each of `changes` touches, in their order (`ops`): for the benchmark
+ /// and tests.
+ #[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn paths(&mut self, changes: &[ExpandedChange]) -> Vec<BTreeSet<Path>> {
+ self.ops(changes)
+ .into_iter()
+ .map(|ops| ops.into_iter().map(|op| op.path).collect())
+ .collect()
+ }
+
+ /// The operations of each of `changes`, in their order, as rules see them. The
+ /// objects they make are placed first, whatever their order, so a change is judged
+ /// the same however the changes came.
+ pub(crate) fn ops(&mut self, changes: &[ExpandedChange]) -> Vec<Vec<Op>> {
// The objects made, by the operation that made them: placed as their parents
// are, until no more can be (a parent made later in the list, placed in a later
// round).
@@ -119,9 +151,23 @@ impl<'a> Inspector<'a> {
.operations
.iter()
.map(|op| match self.known(&op.obj) {
- Some(place) => place.touched(&op.key),
+ Some(place) => Op {
+ path: place.touched(&op.key),
+ action: match &op.action {
+ _ if place.in_list || op.insert => Action::Within,
+ OpType::Make(automerge::ObjType::Map) => Action::MakeMap,
+ OpType::Put(automerge::ScalarValue::Str(text)) => {
+ Action::PutString(text.to_string())
+ }
+ OpType::Delete => Action::Delete,
+ _ => Action::Other,
+ },
+ },
// Nowhere to be found: within every path's reach.
- None => Vec::new(),
+ None => Op {
+ path: Vec::new(),
+ action: Action::Nowhere,
+ },
})
.collect()
})
@@ -214,6 +260,41 @@ mod tests {
assert_eq!(backwards, all);
}
+ /// What each operation does where: a map made, a string put, a deletion, what is
+ /// inside a list, anything else.
+ #[test]
+ fn what_operations_do() {
+ let mut doc = Document::new();
+ change(&mut doc, |doc| {
+ let config = doc.put_object(ROOT, "config", ObjType::Map).unwrap();
+ doc.put(&config, "mode", "fast").unwrap();
+ doc.put(&config, "level", 3).unwrap();
+ doc.delete(&config, "mode").unwrap();
+ let queue = doc.put_object(ROOT, "queue", ObjType::List).unwrap();
+ doc.insert(&queue, 0, "job").unwrap();
+ });
+ let empty = Document::new();
+ let ops = Inspector::new(&empty).ops(&decoded(&doc, &[]));
+ let actions: Vec<(Path, Action)> = ops[0]
+ .iter()
+ .map(|op| (op.path.clone(), op.action.clone()))
+ .collect();
+ assert_eq!(
+ actions,
+ vec![
+ (path(&["config"]), Action::MakeMap),
+ (
+ path(&["config", "mode"]),
+ Action::PutString("fast".to_owned())
+ ),
+ (path(&["config", "level"]), Action::Other),
+ (path(&["config", "mode"]), Action::Delete),
+ (path(&["queue"]), Action::Other),
+ (path(&["queue"]), Action::Within),
+ ]
+ );
+ }
+
/// Inside a list or text, a path stops at it: items move.
#[test]
fn lists_and_text_stop_paths() {