From 4479bb89595958721a2dfef57dbcb89b255a7ad8 Mon Sep 17 00:00:00 2001 From: tv Date: Sat, 7 Nov 2026 01:43:00 +0100 Subject: access registry --- src/access/mod.rs | 179 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 src/access/mod.rs (limited to 'src/access/mod.rs') diff --git a/src/access/mod.rs b/src/access/mod.rs new file mode 100644 index 0000000..8961312 --- /dev/null +++ b/src/access/mod.rs @@ -0,0 +1,179 @@ +//! Who may do what. The users and what each may do are the swarm's: a registry in the +//! document (`_continuum/users`, `registry`), each user with capabilities, each a path +//! and the rights it gives there and below (`Capability::covers`). How a user proves who +//! it is stays each node's (`--htpasswd`, `--user-header`: `api::auth`). Enforced at +//! the HTTP API ([`access.md`](../../docs/access.md)). + +// Used by the genesis node and the API from the next patches on. +#![cfg_attr(not(test), allow(dead_code))] + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +use crate::document::meta; + +pub(crate) mod registry; + +/// What a capability lets its holder do, at its path and below. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum Right { + Read, + Write, +} + +impl Right { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Read => "read", + Self::Write => "write", + } + } +} + +/// A path, and what it lets its holder do there and below: as the registry records it +/// (`registry::User::caps`). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct Capability { + /// Segments, as the store's API takes them (`[]`: everything). + pub(crate) path: Vec, + /// `read`, `write`. Others, from a later version (`grant`), are kept and give + /// nothing here. + pub(crate) rights: BTreeSet, + /// Who granted it: a user (`user:`), or for those the genesis node records, the + /// node (its node ID). Kept so that granting on, by delegation, can come later + /// without a new format (`future-work.md`, Authentication across nodes). + #[serde(default)] + pub(crate) by: String, + /// When it was granted (Unix ms). + #[serde(default)] + pub(crate) since: u64, +} + +impl Capability { + /// Whether it lets its holder do `right` at `path`: its path is a prefix of `path` + /// (segment by segment) and it gives `right`. One that isn't within protocol data + /// (`_continuum`) never covers it: operators are those given protocol data itself. + pub(crate) fn covers(&self, right: Right, path: &[String]) -> bool { + self.gives(right) + && path.starts_with(&self.path) + && (meta::is_protocol(&self.path) || !meta::is_protocol(path)) + } + + /// Whether `path` leads to a place where it lets its holder do `right`: its path + /// lies below `path` (or is it, or above it). What a listing of `path` shows of a + /// tree its holder may only partly read (`Caps::reaches`). + pub(crate) fn reaches(&self, right: Right, path: &[String]) -> bool { + self.gives(right) + && (self.path.starts_with(path) || self.covers(right, path)) + && (meta::is_protocol(&self.path) || !meta::is_protocol(path)) + } + + fn gives(&self, right: Right) -> bool { + self.rights.contains(right.as_str()) + } +} + +/// Everything a user may do: its capabilities, as its registry entry has them; none if +/// it has none, or is disabled. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct Caps(Vec); + +impl Caps { + pub(crate) fn new(caps: Vec) -> Self { + Self(caps) + } + + /// Whether one of them lets its holder do `right` at `path`. + pub(crate) fn allows(&self, right: Right, path: &[String]) -> bool { + self.0.iter().any(|cap| cap.covers(right, path)) + } + + /// Whether `path` leads to a place where one of them lets its holder do `right`: a + /// listing of `path` shows the way there, as a directory one may pass through but not + /// list does (`Capability::reaches`). + pub(crate) fn reaches(&self, right: Right, path: &[String]) -> bool { + self.0.iter().any(|cap| cap.reaches(right, path)) + } + + pub(crate) fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn path(text: &str) -> Vec { + text.split('/') + .filter(|segment| !segment.is_empty()) + .map(str::to_owned) + .collect() + } + + fn cap(at: &str, rights: &[&str]) -> Capability { + Capability { + path: path(at), + rights: rights.iter().map(|right| (*right).to_owned()).collect(), + by: String::new(), + since: 0, + } + } + + #[test] + fn a_capability_covers_its_path_and_below() { + let servers = cap("servers/eu", &["read"]); + assert!(servers.covers(Right::Read, &path("servers/eu"))); + assert!(servers.covers(Right::Read, &path("servers/eu/0/name"))); + // By segment, not by text. + assert!(!servers.covers(Right::Read, &path("servers/europe"))); + assert!(!servers.covers(Right::Read, &path("servers"))); + assert!(!servers.covers(Right::Write, &path("servers/eu"))); + } + + #[test] + fn protocol_data_only_by_capabilities_within_it() { + let everything = cap("", &["read", "write"]); + assert!(everything.covers(Right::Write, &path("accounts/1"))); + assert!(everything.covers(Right::Read, &[])); + assert!(!everything.covers(Right::Read, &path("_continuum"))); + assert!(!everything.covers(Right::Write, &path("_continuum/users/alice"))); + let operator = cap("_continuum", &["read", "write"]); + assert!(operator.covers(Right::Write, &path("_continuum/users/alice"))); + assert!(!operator.covers(Right::Read, &path("accounts"))); + let remakes = cap("_continuum/remakes", &["write"]); + assert!(remakes.covers(Right::Write, &path("_continuum/remakes/r1"))); + assert!(!remakes.covers(Right::Write, &path("_continuum/users"))); + } + + #[test] + fn rights_from_later_versions_give_nothing() { + let later = cap("", &["grant"]); + assert!(!later.covers(Right::Read, &path("x"))); + assert!(!later.covers(Right::Write, &path("x"))); + } + + #[test] + fn a_listing_shows_the_way_to_what_may_be_read() { + let caps = Caps::new(vec![cap("servers/eu", &["read"])]); + // The way there, as a directory passed through. + assert!(caps.reaches(Right::Read, &[])); + assert!(caps.reaches(Right::Read, &path("servers"))); + // There and below. + assert!(caps.reaches(Right::Read, &path("servers/eu"))); + assert!(caps.reaches(Right::Read, &path("servers/eu/0"))); + // Elsewhere, nothing. + assert!(!caps.reaches(Right::Read, &path("servers/us"))); + assert!(!caps.reaches(Right::Read, &path("accounts"))); + assert!(!caps.allows(Right::Read, &path("servers"))); + // A capability on everything doesn't lead into protocol data. + let everything = Caps::new(vec![cap("", &["read"])]); + assert!(everything.reaches(Right::Read, &[])); + assert!(!everything.reaches(Right::Read, &path("_continuum"))); + let members = Caps::new(vec![cap("_continuum/members", &["read"])]); + assert!(members.reaches(Right::Read, &[])); + assert!(members.reaches(Right::Read, &path("_continuum"))); + assert!(!members.reaches(Right::Read, &path("_continuum/users"))); + } +} -- cgit v1.3.1