aboutsummaryrefslogtreecommitdiffstats
path: root/src/access/rules.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/access/rules.rs')
-rw-r--r--src/access/rules.rs201
1 files changed, 200 insertions, 1 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: &[],
},
)
};