aboutsummaryrefslogtreecommitdiffstats
path: root/src/access
diff options
context:
space:
mode:
Diffstat (limited to 'src/access')
-rw-r--r--src/access/rules.rs201
-rw-r--r--src/access/screen.rs25
2 files changed, 219 insertions, 7 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()),
})
}