use std::collections::HashMap;
use anyhow::{Result, bail};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Grant {
pub prefix: String,
pub read: bool,
pub write: bool,
}
impl Grant {
pub fn full() -> Self {
Grant {
prefix: String::new(),
read: true,
write: true,
}
}
fn covers(&self, namespace: &str) -> bool {
let mut actual = segments(namespace);
for wanted in segments(&self.prefix) {
match actual.next() {
Some(segment) if segment == wanted => {}
_ => return false,
}
}
true
}
}
fn segments(path: &str) -> impl Iterator<Item = &str> {
path.split('/').filter(|segment| !segment.is_empty())
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Acl {
keys: HashMap<String, Vec<Grant>>,
}
impl Acl {
pub fn new() -> Self {
Self::default()
}
pub fn grant_full(&mut self, key: &str) {
self.keys.insert(key.to_string(), vec![Grant::full()]);
}
pub fn set(&mut self, key: &str, grants: Vec<Grant>) {
self.keys.insert(key.to_string(), grants);
}
pub fn remove(&mut self, key: &str) -> bool {
self.keys.remove(key).is_some()
}
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
pub fn len(&self) -> usize {
self.keys.len()
}
pub fn can_read(&self, key: &str, namespace: &str) -> bool {
self.allows(key, namespace, |grant| grant.read)
}
pub fn can_write(&self, key: &str, namespace: &str) -> bool {
self.allows(key, namespace, |grant| grant.write)
}
fn allows(&self, key: &str, namespace: &str, permitted: impl Fn(&Grant) -> bool) -> bool {
let Some(grants) = self.keys.get(key) else {
return false;
};
grants
.iter()
.any(|grant| permitted(grant) && grant.covers(namespace))
}
}
pub fn parse_grants(spec: &str) -> Result<Vec<Grant>> {
let mut grants = Vec::new();
for part in spec.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
let (permissions, prefix) = match part.split_once(':') {
Some((permissions, prefix)) => (permissions.trim(), prefix.trim()),
None => (part, ""),
};
let (read, write) = match permissions {
"r" => (true, false),
"w" => (false, true),
"rw" | "wr" => (true, true),
other => bail!("unknown permission '{other}', expected one of r, w, rw"),
};
grants.push(Grant {
prefix: prefix.to_string(),
read,
write,
});
}
if grants.is_empty() {
bail!("no grants given; use 'r', 'w' or 'rw', optionally as 'rw:<namespace>'");
}
Ok(grants)
}
#[cfg(test)]
mod tests {
use super::*;
fn acl(key: &str, spec: &str) -> Acl {
let mut acl = Acl::new();
acl.set(key, parse_grants(spec).unwrap());
acl
}
#[test]
fn a_bare_permission_is_global() {
assert_eq!(
parse_grants("rw").unwrap(),
vec![Grant {
prefix: String::new(),
read: true,
write: true
}]
);
}
#[test]
fn permissions_can_be_scoped_to_a_prefix() {
assert_eq!(
parse_grants("r:feeds/misp").unwrap(),
vec![Grant {
prefix: "feeds/misp".to_string(),
read: true,
write: false
}]
);
}
#[test]
fn several_grants_can_be_listed() {
let grants = parse_grants(" r , w:staging ,rw:feeds/misp ").unwrap();
assert_eq!(grants.len(), 3);
assert!(grants[0].read && !grants[0].write && grants[0].prefix.is_empty());
assert!(!grants[1].read && grants[1].write && grants[1].prefix == "staging");
assert!(grants[2].read && grants[2].write);
}
#[test]
fn rw_and_wr_mean_the_same_thing() {
assert_eq!(parse_grants("rw").unwrap(), parse_grants("wr").unwrap());
}
#[test]
fn an_unknown_permission_is_an_error() {
let err = parse_grants("admin").unwrap_err().to_string();
assert!(err.contains("unknown permission 'admin'"), "{err}");
}
#[test]
fn an_empty_spec_is_an_error() {
assert!(parse_grants(" ").is_err());
assert!(parse_grants(",,").is_err());
}
#[test]
fn a_global_grant_covers_every_namespace() {
let acl = acl("k", "rw");
assert!(acl.can_read("k", "anything/at/all"));
assert!(acl.can_write("k", ""));
}
#[test]
fn a_scoped_grant_covers_its_own_subtree() {
let acl = acl("k", "rw:feeds/misp");
assert!(acl.can_read("k", "feeds/misp"));
assert!(acl.can_read("k", "feeds/misp/ips"));
assert!(acl.can_write("k", "feeds/misp/ips/v4"));
}
#[test]
fn a_scoped_grant_does_not_leak_into_similarly_named_siblings() {
let acl = acl("k", "rw:feeds/misp");
assert!(!acl.can_read("k", "feeds/misp-internal"));
assert!(!acl.can_read("k", "feeds/misperfect/data"));
assert!(!acl.can_write("k", "feeds"));
assert!(!acl.can_read("k", "other/feeds/misp"));
}
#[test]
fn surrounding_slashes_do_not_matter() {
let acl = acl("k", "rw:/feeds/misp/");
assert!(acl.can_read("k", "feeds/misp"));
assert!(acl.can_read("k", "/feeds/misp/"));
assert!(acl.can_read("k", "feeds/misp/ips/"));
}
#[test]
fn read_and_write_are_separate() {
let acl = acl("k", "r:public, w:inbox");
assert!(acl.can_read("k", "public/data"));
assert!(!acl.can_write("k", "public/data"));
assert!(acl.can_write("k", "inbox/data"));
assert!(!acl.can_read("k", "inbox/data"));
}
#[test]
fn grants_are_unioned() {
let acl = acl("k", "r:a, w:a");
assert!(acl.can_read("k", "a/x"));
assert!(acl.can_write("k", "a/x"));
}
#[test]
fn an_unknown_key_is_denied_everything() {
let acl = acl("k", "rw");
assert!(!acl.can_read("other", "anything"));
assert!(!acl.can_write("other", "anything"));
assert!(!acl.can_read("", "anything"));
}
#[test]
fn a_key_with_no_matching_grant_is_denied() {
let acl = acl("k", "rw:feeds");
assert!(!acl.can_read("k", "secrets"));
}
#[test]
fn keys_are_case_sensitive() {
let acl = acl("SeCret", "rw");
assert!(acl.can_read("SeCret", "ns"));
assert!(!acl.can_read("secret", "ns"));
}
#[test]
fn an_empty_acl_denies_everyone() {
let acl = Acl::new();
assert!(acl.is_empty());
assert!(!acl.can_read("anything", "anywhere"));
}
#[test]
fn grant_full_replaces_existing_grants() {
let mut acl = acl("k", "r:narrow");
acl.grant_full("k");
assert!(acl.can_write("k", "anywhere"));
assert_eq!(acl.len(), 1);
}
#[test]
fn removing_a_key_revokes_it() {
let mut acl = acl("k", "rw");
assert!(acl.remove("k"));
assert!(!acl.remove("k"));
assert!(!acl.can_read("k", "ns"));
}
}