diff options
Diffstat (limited to 'src/access')
| -rw-r--r-- | src/access/rules.rs | 201 | ||||
| -rw-r--r-- | src/access/screen.rs | 25 |
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()), }) } |
