aboutsummaryrefslogtreecommitdiffstats
path: root/src/access/mod.rs
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-07 01:43:00 +0100
committertv <tv@krebsco.de>2026-11-07 01:43:00 +0100
commit4479bb89595958721a2dfef57dbcb89b255a7ad8 (patch)
treec19a89b7a4e4c11ef4dd0b82d5195cdf789d326a /src/access/mod.rs
parente50a5ced65a1f03ebdbe5b0d3b09a57b11b7edbe (diff)
access registry
Diffstat (limited to 'src/access/mod.rs')
-rw-r--r--src/access/mod.rs179
1 files changed, 179 insertions, 0 deletions
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<String>,
+ /// `read`, `write`. Others, from a later version (`grant`), are kept and give
+ /// nothing here.
+ pub(crate) rights: BTreeSet<String>,
+ /// Who granted it: a user (`user:<name>`), 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<Capability>);
+
+impl Caps {
+ pub(crate) fn new(caps: Vec<Capability>) -> 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<String> {
+ 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")));
+ }
+}