aboutsummaryrefslogtreecommitdiffstats
path: root/src/access/registry.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/access/registry.rs')
-rw-r--r--src/access/registry.rs235
1 files changed, 235 insertions, 0 deletions
diff --git a/src/access/registry.rs b/src/access/registry.rs
new file mode 100644
index 0000000..c4ad0e6
--- /dev/null
+++ b/src/access/registry.rs
@@ -0,0 +1,235 @@
+//! The user registry in the document: `_continuum/users/<name>`, one entry per user.
+//! Protocol data, so a remake's snapshot carries it into the next epoch, and only those
+//! who may write protocol data change it. No secrets: every member reads the document.
+//!
+//! ```json
+//! { "since": 1762400000000, "by": "user:alice",
+//! "caps": { "<id>": { "path": [], "rights": ["read", "write"],
+//! "by": "user:alice", "since": 1762400000000 } },
+//! "disabled": false }
+//! ```
+//!
+//! Capabilities are a map by a random ID, not a list: two operators granting at once
+//! both add theirs, and revoking one deletes its entry, never rewriting the others.
+//! `disabled` keeps the entry (who wrote what stays readable) and gives nothing.
+
+use std::collections::BTreeMap;
+
+use serde_json::{Value as JsonValue, json};
+
+use super::{Capability, Caps, Right};
+use crate::document::{ReadDoc, meta, store};
+
+/// `_continuum/users`.
+pub(crate) fn users_dir() -> Vec<String> {
+ meta::path(&[meta::USERS])
+}
+
+/// `_continuum/users/<name>`.
+pub(crate) fn user_path(name: &str) -> Vec<String> {
+ let mut path = users_dir();
+ path.push(name.to_owned());
+ path
+}
+
+/// Whether the swarm has a registry: until it has, every user a node lets in may do
+/// what all could before (`docs/access.md`, An existing swarm).
+pub(crate) fn exists(doc: &impl ReadDoc) -> bool {
+ store::keys_at(doc, &users_dir()).is_ok()
+}
+
+/// The registered users' names, sorted.
+pub(crate) fn names(doc: &impl ReadDoc) -> Vec<String> {
+ let mut names = store::keys_at(doc, &users_dir()).unwrap_or_default();
+ names.sort();
+ names
+}
+
+/// A user name: what `--htpasswd` and trailers take, and a map key without `/`. 1 to 64
+/// of `A-Z a-z 0-9 . _ @ -`.
+pub(crate) fn valid_name(name: &str) -> bool {
+ (1..=64).contains(&name.len())
+ && name
+ .bytes()
+ .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'@' | b'-'))
+}
+
+/// A user's entry, as read from the document. Read leniently: a field that doesn't
+/// read is as if absent, and a capability that doesn't read is left out, so a damaged
+/// entry gives less, never more.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub(crate) struct User {
+ pub(crate) since: u64,
+ pub(crate) by: String,
+ pub(crate) caps: BTreeMap<String, Capability>,
+ pub(crate) disabled: bool,
+}
+
+impl User {
+ fn from_json(value: &JsonValue) -> Self {
+ let caps = value
+ .get("caps")
+ .and_then(JsonValue::as_object)
+ .map(|caps| {
+ caps.iter()
+ .filter_map(|(id, cap)| {
+ Some((id.clone(), serde_json::from_value(cap.clone()).ok()?))
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+ Self {
+ since: value.get("since").and_then(JsonValue::as_u64).unwrap_or(0),
+ by: value
+ .get("by")
+ .and_then(JsonValue::as_str)
+ .unwrap_or_default()
+ .to_owned(),
+ caps,
+ // Anything but `false` disables: a damaged flag gives less.
+ disabled: value
+ .get("disabled")
+ .is_some_and(|disabled| disabled != &JsonValue::Bool(false)),
+ }
+ }
+
+ /// What it may do: its capabilities, none while it is disabled.
+ pub(crate) fn caps(&self) -> Caps {
+ if self.disabled {
+ return Caps::default();
+ }
+ Caps::new(self.caps.values().cloned().collect())
+ }
+}
+
+/// The entry of user `name`, if it has one.
+pub(crate) fn user(doc: &impl ReadDoc, name: &str) -> Option<User> {
+ let value = store::read_path(doc, &user_path(name)).ok()??;
+ value.is_object().then(|| User::from_json(&value))
+}
+
+/// What user `name` may do: none if it isn't registered (or is disabled).
+pub(crate) fn caps_of(doc: &impl ReadDoc, name: &str) -> Caps {
+ user(doc, name).map(|user| user.caps()).unwrap_or_default()
+}
+
+/// A new user's entry, as written to `user_path`: granted by `by` at `since`, with
+/// `caps` (each a path and its rights), each under an ID of its own (`new_id`).
+pub(crate) fn entry(by: &str, since: u64, caps: &[(Vec<String>, &[Right])]) -> JsonValue {
+ let caps: serde_json::Map<String, JsonValue> = caps
+ .iter()
+ .map(|(path, rights)| (new_id(), capability(by, since, path, rights)))
+ .collect();
+ json!({ "since": since, "by": by, "caps": caps, "disabled": false })
+}
+
+/// A capability as the registry records it.
+pub(crate) fn capability(by: &str, since: u64, path: &[String], rights: &[Right]) -> JsonValue {
+ json!({
+ "path": path,
+ "rights": rights.iter().map(|right| right.as_str()).collect::<Vec<_>>(),
+ "by": by,
+ "since": since,
+ })
+}
+
+/// A random ID for a capability (8 bytes, hex): concurrent grants don't collide.
+pub(crate) fn new_id() -> String {
+ let mut bytes = [0u8; 8];
+ getrandom::fill(&mut bytes).expect("the system's random numbers");
+ bytes.iter().map(|byte| format!("{byte:02x}")).collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::document::Document;
+ use crate::document::testing::change;
+
+ fn path(text: &str) -> Vec<String> {
+ text.split('/')
+ .filter(|segment| !segment.is_empty())
+ .map(str::to_owned)
+ .collect()
+ }
+
+ #[test]
+ fn names() {
+ assert!(valid_name("alice"));
+ assert!(valid_name("alice.smith@example.org"));
+ for bad in ["", "a/b", "a:b", "a b", &"x".repeat(65)] {
+ assert!(!valid_name(bad), "{bad:?}");
+ }
+ }
+
+ #[test]
+ fn entries_written_and_read() {
+ let mut doc = Document::new();
+ assert!(!exists(&doc));
+ let entry = entry(
+ "continuum:00",
+ 7,
+ &[
+ (vec![], &[Right::Read, Right::Write]),
+ (path("_continuum"), &[Right::Read, Right::Write]),
+ ],
+ );
+ change(&mut doc, |tx| {
+ store::write_path(tx, &user_path("alice"), &entry).unwrap()
+ });
+ assert!(exists(&doc));
+ assert_eq!(super::names(&doc), vec!["alice"]);
+ let alice = user(&doc, "alice").unwrap();
+ assert_eq!((alice.since, alice.by.as_str()), (7, "continuum:00"));
+ assert_eq!(alice.caps.len(), 2);
+ let caps = caps_of(&doc, "alice");
+ assert!(caps.allows(Right::Write, &path("accounts/1")));
+ assert!(caps.allows(Right::Write, &path("_continuum/users/bob")));
+ assert!(caps_of(&doc, "bob").is_empty());
+ }
+
+ #[test]
+ fn a_disabled_or_damaged_entry_gives_less() {
+ let mut doc = Document::new();
+ let mut entry = entry("x", 0, &[(vec![], &[Right::Read])]);
+ entry["disabled"] = json!(true);
+ change(&mut doc, |tx| {
+ store::write_path(tx, &user_path("carol"), &entry).unwrap()
+ });
+ assert!(user(&doc, "carol").is_some());
+ assert!(caps_of(&doc, "carol").is_empty());
+
+ let damaged = json!({
+ "caps": { "a": { "path": "not a list", "rights": ["read"] },
+ "b": { "path": ["notes"], "rights": ["read"] } },
+ "disabled": "no"
+ });
+ change(&mut doc, |tx| {
+ store::write_path(tx, &user_path("dave"), &damaged).unwrap()
+ });
+ let dave = user(&doc, "dave").unwrap();
+ assert_eq!(dave.caps.len(), 1);
+ assert!(dave.disabled);
+ assert!(dave.caps().is_empty());
+ }
+
+ #[test]
+ fn concurrent_grants_both_hold() {
+ let mut ours = Document::new();
+ let entry = entry("x", 0, &[]);
+ change(&mut ours, |tx| {
+ store::write_path(tx, &user_path("erin"), &entry).unwrap()
+ });
+ let mut theirs = ours.fork();
+ for (doc, at) in [(&mut ours, "notes"), (&mut theirs, "logs")] {
+ let mut cap_path = user_path("erin");
+ cap_path.extend(["caps".to_owned(), new_id()]);
+ let cap = capability("x", 0, &path(at), &[Right::Read]);
+ change(doc, |tx| store::write_path(tx, &cap_path, &cap).unwrap());
+ }
+ ours.merge(&mut theirs).unwrap();
+ let caps = caps_of(&ours, "erin");
+ assert!(caps.allows(Right::Read, &path("notes")));
+ assert!(caps.allows(Right::Read, &path("logs")));
+ }
+}