[pbs-devel] [PATCH proxmox v2 1/7] access-control: add the proxmox-access crate to reuse acl trees

Shannon Sterz s.sterz at proxmox.com
Thu Jun 13 14:52:30 CEST 2024


this commit factors out the acl tree from proxmox-backup so we can
re-use it accross other products. to use it, the product needs to
implement the `AcmConfig` trait and provide this crate with a
location to safe its configuration files.

Signed-off-by: Shannon Sterz <s.sterz at proxmox.com>
---
 Cargo.toml                         |   2 +
 proxmox-access-control/Cargo.toml  |  22 +
 proxmox-access-control/src/acl.rs  | 999 +++++++++++++++++++++++++++++
 proxmox-access-control/src/init.rs |  76 +++
 proxmox-access-control/src/lib.rs  |   2 +
 5 files changed, 1101 insertions(+)
 create mode 100644 proxmox-access-control/Cargo.toml
 create mode 100644 proxmox-access-control/src/acl.rs
 create mode 100644 proxmox-access-control/src/init.rs
 create mode 100644 proxmox-access-control/src/lib.rs

diff --git a/Cargo.toml b/Cargo.toml
index b3c97808..ca6bf62f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,6 @@
 [workspace]
 members = [
+    "proxmox-access-control",
     "proxmox-acme",
     "proxmox-acme-api",
     "proxmox-api-macro",
@@ -110,6 +111,7 @@ zstd = { version = "0.12", features = [ "bindgen" ] }
 # workspace dependencies
 proxmox-acme = {  version = "0.5.2", path = "proxmox-acme", default-features = false }
 proxmox-api-macro = { version = "1.0.8", path = "proxmox-api-macro" }
+proxmox-auth-api = { version = "0.4.0", path = "proxmox-auth-api" }
 proxmox-async = { version = "0.4.1", path = "proxmox-async" }
 proxmox-compression = { version = "0.2.0", path = "proxmox-compression" }
 proxmox-http = { version = "0.9.0", path = "proxmox-http" }
diff --git a/proxmox-access-control/Cargo.toml b/proxmox-access-control/Cargo.toml
new file mode 100644
index 00000000..b783a21f
--- /dev/null
+++ b/proxmox-access-control/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "proxmox-access-control"
+version = "0.1.0"
+authors.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+homepage.workspace = true
+exclude.workspace = true
+rust-version.workspace = true
+description = "A collection of utilities to implement access control management."
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+anyhow.workspace = true
+nix.workspace = true
+openssl.workspace = true
+
+# proxmox-notify.workspace = true
+proxmox-auth-api = { workspace = true, features = [ "api-types" ] }
+proxmox-product-config.workspace = true
diff --git a/proxmox-access-control/src/acl.rs b/proxmox-access-control/src/acl.rs
new file mode 100644
index 00000000..6c845ea3
--- /dev/null
+++ b/proxmox-access-control/src/acl.rs
@@ -0,0 +1,999 @@
+use std::collections::{BTreeMap, BTreeSet, HashMap};
+use std::io::Write;
+use std::path::Path;
+use std::sync::{Arc, OnceLock, RwLock};
+
+use anyhow::{bail, Error};
+
+use proxmox_auth_api::types::{Authid, Userid};
+use proxmox_product_config::{open_api_lockfile, replace_privileged_config, ApiLockGuard};
+
+use crate::init::{access_conf, acl_config, acl_config_lock};
+
+pub fn split_acl_path(path: &str) -> Vec<&str> {
+    let items = path.split('/');
+
+    let mut components = vec![];
+
+    for name in items {
+        if name.is_empty() {
+            continue;
+        }
+        components.push(name);
+    }
+
+    components
+}
+
+/// Tree representing a parsed acl.cfg
+#[derive(Default)]
+pub struct AclTree {
+    /// Root node of the tree.
+    ///
+    /// The rest of the tree is available via [find_node()](AclTree::find_node()) or an
+    /// [`AclTreeNode`]'s [children](AclTreeNode::children) member.
+    pub root: AclTreeNode,
+}
+
+/// Node representing ACLs for a certain ACL path.
+#[derive(Default)]
+pub struct AclTreeNode {
+    /// [User](pbs_api_types::User) or
+    /// [Token](pbs_api_types::ApiToken) ACLs for this node.
+    pub users: HashMap<Authid, HashMap<String, bool>>,
+    /// `Group` ACLs for this node (not yet implemented)
+    pub groups: HashMap<String, HashMap<String, bool>>,
+    /// `AclTreeNodes` representing ACL paths directly below the current one.
+    pub children: BTreeMap<String, AclTreeNode>,
+}
+
+impl AclTreeNode {
+    /// Creates a new, empty AclTreeNode.
+    pub fn new() -> Self {
+        Self {
+            users: HashMap::new(),
+            groups: HashMap::new(),
+            children: BTreeMap::new(),
+        }
+    }
+
+    /// Returns applicable role and their propagation status for a given [Authid].
+    ///
+    /// If the `Authid` is a [User](pbs_api_types::User) that has no specific `Roles` configured on
+    /// this node, applicable `Group` roles will be returned instead.
+    ///
+    /// If `leaf` is `false`, only those roles where the propagate flag in the ACL is set to `true`
+    /// are returned. Otherwise, all roles will be returned.
+    pub fn extract_roles(&self, auth_id: &Authid, leaf: bool) -> HashMap<String, bool> {
+        let user_roles = self.extract_user_roles(auth_id, leaf);
+        if !user_roles.is_empty() || auth_id.is_token() {
+            // user privs always override group privs
+            return user_roles;
+        };
+
+        self.extract_group_roles(auth_id.user(), leaf)
+    }
+
+    fn extract_user_roles(&self, auth_id: &Authid, leaf: bool) -> HashMap<String, bool> {
+        let mut map = HashMap::new();
+
+        let roles = match self.users.get(auth_id) {
+            Some(m) => m,
+            None => return map,
+        };
+
+        for (role, propagate) in roles {
+            if *propagate || leaf {
+                if access_conf()
+                    .role_no_access()
+                    .map_or_else(|| false, |r| r == role)
+                {
+                    // return a map with a single role 'NoAccess'
+                    let mut map = HashMap::new();
+                    map.insert(role.to_string(), false);
+                    return map;
+                }
+                map.insert(role.to_string(), *propagate);
+            }
+        }
+
+        map
+    }
+
+    fn extract_group_roles(&self, _user: &Userid, leaf: bool) -> HashMap<String, bool> {
+        let mut map = HashMap::new();
+
+        #[allow(clippy::for_kv_map)]
+        for (_group, roles) in &self.groups {
+            let is_member = false; // fixme: check if user is member of the group
+            if !is_member {
+                continue;
+            }
+
+            for (role, propagate) in roles {
+                if *propagate || leaf {
+                    if access_conf()
+                        .role_no_access()
+                        .map_or_else(|| false, |r| r == role)
+                    {
+                        // return a map with a single role 'NoAccess'
+                        let mut map = HashMap::new();
+                        map.insert(role.to_string(), false);
+                        return map;
+                    }
+                    map.insert(role.to_string(), *propagate);
+                }
+            }
+        }
+
+        map
+    }
+
+    fn delete_group_role(&mut self, group: &str, role: &str) {
+        let roles = match self.groups.get_mut(group) {
+            Some(r) => r,
+            None => return,
+        };
+        roles.remove(role);
+    }
+
+    fn delete_user_role(&mut self, auth_id: &Authid, role: &str) {
+        let roles = match self.users.get_mut(auth_id) {
+            Some(r) => r,
+            None => return,
+        };
+        roles.remove(role);
+    }
+
+    fn delete_authid(&mut self, auth_id: &Authid) {
+        for node in self.children.values_mut() {
+            node.delete_authid(auth_id);
+        }
+        self.users.remove(auth_id);
+    }
+
+    fn insert_group_role(&mut self, group: String, role: String, propagate: bool) {
+        let map = self.groups.entry(group).or_default();
+        if let Some(no_access) = access_conf().role_no_access() {
+            if role == no_access {
+                map.clear();
+            } else {
+                map.remove(no_access);
+            }
+        }
+
+        map.insert(role, propagate);
+    }
+
+    fn insert_user_role(&mut self, auth_id: Authid, role: String, propagate: bool) {
+        let map = self.users.entry(auth_id).or_default();
+        if let Some(no_access) = access_conf().role_no_access() {
+            if role == no_access {
+                map.clear();
+            } else {
+                map.remove(no_access);
+            }
+        }
+
+        map.insert(role, propagate);
+    }
+
+    fn get_child_paths(
+        &self,
+        path: String,
+        auth_id: &Authid,
+        paths: &mut Vec<String>,
+    ) -> Result<(), Error> {
+        for (sub_comp, child_node) in &self.children {
+            let roles = child_node.extract_roles(auth_id, true);
+            let child_path = format!("{path}/{sub_comp}");
+            if !roles.is_empty() {
+                paths.push(child_path.clone());
+            }
+            child_node.get_child_paths(child_path, auth_id, paths)?;
+        }
+        Ok(())
+    }
+}
+
+impl AclTree {
+    /// Create a new, empty ACL tree with a single, empty root [node](AclTreeNode)
+    pub fn new() -> Self {
+        Self {
+            root: AclTreeNode::new(),
+        }
+    }
+
+    /// Iterates over the tree looking for a node matching `path`.
+    pub fn find_node(&mut self, path: &str) -> Option<&mut AclTreeNode> {
+        let path = split_acl_path(path);
+        self.get_node_mut(&path)
+    }
+
+    fn get_node(&self, path: &[&str]) -> Option<&AclTreeNode> {
+        let mut node = &self.root;
+        for outer in path {
+            for comp in outer.split('/') {
+                node = match node.children.get(comp) {
+                    Some(n) => n,
+                    None => return None,
+                };
+            }
+        }
+        Some(node)
+    }
+
+    fn get_node_mut(&mut self, path: &[&str]) -> Option<&mut AclTreeNode> {
+        let mut node = &mut self.root;
+        for outer in path {
+            for comp in outer.split('/') {
+                node = match node.children.get_mut(comp) {
+                    Some(n) => n,
+                    None => return None,
+                };
+            }
+        }
+        Some(node)
+    }
+
+    fn get_or_insert_node(&mut self, path: &[&str]) -> &mut AclTreeNode {
+        let mut node = &mut self.root;
+        for outer in path {
+            for comp in outer.split('/') {
+                node = node.children.entry(String::from(comp)).or_default();
+            }
+        }
+        node
+    }
+
+    /// Deletes the specified `role` from the `group`'s ACL on `path`.
+    ///
+    /// Never fails, even if the `path` has no ACLs configured, or the `group`/`role` combination
+    /// does not exist on `path`.
+    pub fn delete_group_role(&mut self, path: &str, group: &str, role: &str) {
+        let path = split_acl_path(path);
+        let node = match self.get_node_mut(&path) {
+            Some(n) => n,
+            None => return,
+        };
+        node.delete_group_role(group, role);
+    }
+
+    /// Deletes the specified `role` from the `user`'s ACL on `path`.
+    ///
+    /// Never fails, even if the `path` has no ACLs configured, or the `user`/`role` combination
+    /// does not exist on `path`.
+    pub fn delete_user_role(&mut self, path: &str, auth_id: &Authid, role: &str) {
+        let path = split_acl_path(path);
+        let node = match self.get_node_mut(&path) {
+            Some(n) => n,
+            None => return,
+        };
+        node.delete_user_role(auth_id, role);
+    }
+
+    /// Deletes the [`AclTreeNode`] at the specified patth
+    ///
+    /// Never fails, deletes a node iff the specified path exists.
+    pub fn delete_node(&mut self, path: &str) {
+        let mut path = split_acl_path(path);
+        let last = path.pop();
+        let parent = match self.get_node_mut(&path) {
+            Some(n) => n,
+            None => return,
+        };
+        if let Some(name) = last {
+            parent.children.remove(name);
+        }
+    }
+
+    /// Deletes a user or token from the ACL-tree
+    ///
+    /// Traverses the tree in-order and removes the given user/token by their Authid
+    /// from every node in the tree.
+    pub fn delete_authid(&mut self, auth_id: &Authid) {
+        self.root.delete_authid(auth_id);
+    }
+
+    /// Inserts the specified `role` into the `group` ACL on `path`.
+    ///
+    /// The [`AclTreeNode`] representing `path` will be created and inserted into the tree if
+    /// necessary.
+    pub fn insert_group_role(&mut self, path: &str, group: &str, role: &str, propagate: bool) {
+        let path = split_acl_path(path);
+        let node = self.get_or_insert_node(&path);
+        node.insert_group_role(group.to_string(), role.to_string(), propagate);
+    }
+
+    /// Inserts the specified `role` into the `user` ACL on `path`.
+    ///
+    /// The [`AclTreeNode`] representing `path` will be created and inserted into the tree if
+    /// necessary.
+    pub fn insert_user_role(&mut self, path: &str, auth_id: &Authid, role: &str, propagate: bool) {
+        let path = split_acl_path(path);
+        let node = self.get_or_insert_node(&path);
+        node.insert_user_role(auth_id.to_owned(), role.to_string(), propagate);
+    }
+
+    fn write_node_config(node: &AclTreeNode, path: &str, w: &mut dyn Write) -> Result<(), Error> {
+        let mut role_ug_map0: HashMap<_, BTreeSet<_>> = HashMap::new();
+        let mut role_ug_map1: HashMap<_, BTreeSet<_>> = HashMap::new();
+
+        for (auth_id, roles) in &node.users {
+            // no need to save, because root is always 'Administrator'
+            if !auth_id.is_token() && auth_id.user() == "root at pam" {
+                continue;
+            }
+            for (role, propagate) in roles {
+                let role = role.as_str();
+                let auth_id = auth_id.to_string();
+                if *propagate {
+                    role_ug_map1.entry(role).or_default().insert(auth_id);
+                } else {
+                    role_ug_map0.entry(role).or_default().insert(auth_id);
+                }
+            }
+        }
+
+        for (group, roles) in &node.groups {
+            for (role, propagate) in roles {
+                let group = format!("@{}", group);
+                if *propagate {
+                    role_ug_map1.entry(role).or_default().insert(group);
+                } else {
+                    role_ug_map0.entry(role).or_default().insert(group);
+                }
+            }
+        }
+
+        fn group_by_property_list(
+            item_property_map: &HashMap<&str, BTreeSet<String>>,
+        ) -> BTreeMap<String, BTreeSet<String>> {
+            let mut result_map: BTreeMap<_, BTreeSet<_>> = BTreeMap::new();
+            for (item, property_map) in item_property_map {
+                let item_list = property_map.iter().fold(String::new(), |mut acc, v| {
+                    if !acc.is_empty() {
+                        acc.push(',');
+                    }
+                    acc.push_str(v);
+                    acc
+                });
+                result_map
+                    .entry(item_list)
+                    .or_default()
+                    .insert(item.to_string());
+            }
+            result_map
+        }
+
+        let uglist_role_map0 = group_by_property_list(&role_ug_map0);
+        let uglist_role_map1 = group_by_property_list(&role_ug_map1);
+
+        fn role_list(roles: &BTreeSet<String>) -> String {
+            if let Some(no_access) = access_conf().role_no_access() {
+                if roles.contains(no_access) {
+                    return String::from(no_access);
+                }
+            }
+
+            roles.iter().fold(String::new(), |mut acc, v| {
+                if !acc.is_empty() {
+                    acc.push(',');
+                }
+                acc.push_str(v);
+                acc
+            })
+        }
+
+        for (uglist, roles) in &uglist_role_map0 {
+            let role_list = role_list(roles);
+            writeln!(
+                w,
+                "acl:0:{}:{}:{}",
+                if path.is_empty() { "/" } else { path },
+                uglist,
+                role_list
+            )?;
+        }
+
+        for (uglist, roles) in &uglist_role_map1 {
+            let role_list = role_list(roles);
+            writeln!(
+                w,
+                "acl:1:{}:{}:{}",
+                if path.is_empty() { "/" } else { path },
+                uglist,
+                role_list
+            )?;
+        }
+
+        for (name, child) in node.children.iter() {
+            let child_path = format!("{}/{}", path, name);
+            Self::write_node_config(child, &child_path, w)?;
+        }
+
+        Ok(())
+    }
+
+    fn write_config(&self, w: &mut dyn Write) -> Result<(), Error> {
+        Self::write_node_config(&self.root, "", w)
+    }
+
+    fn parse_acl_line(&mut self, line: &str) -> Result<(), Error> {
+        let items: Vec<&str> = line.split(':').collect();
+
+        if items.len() != 5 {
+            bail!("wrong number of items.");
+        }
+
+        if items[0] != "acl" {
+            bail!("line does not start with 'acl'.");
+        }
+
+        let propagate = if items[1] == "0" {
+            false
+        } else if items[1] == "1" {
+            true
+        } else {
+            bail!("expected '0' or '1' for propagate flag.");
+        };
+
+        let path_str = items[2];
+        let path = split_acl_path(path_str);
+        let node = self.get_or_insert_node(&path);
+
+        let uglist: Vec<&str> = items[3].split(',').map(|v| v.trim()).collect();
+
+        let rolelist: Vec<&str> = items[4].split(',').map(|v| v.trim()).collect();
+
+        for user_or_group in &uglist {
+            for role in &rolelist {
+                if !access_conf().roles().contains_key(role) {
+                    bail!("unknown role '{}'", role);
+                }
+                if let Some(group) = user_or_group.strip_prefix('@') {
+                    node.insert_group_role(group.to_string(), role.to_string(), propagate);
+                } else {
+                    node.insert_user_role(user_or_group.parse()?, role.to_string(), propagate);
+                }
+            }
+        }
+
+        Ok(())
+    }
+
+    fn load(filename: &Path) -> Result<(Self, [u8; 32]), Error> {
+        let mut tree = Self::new();
+
+        let raw = match std::fs::read_to_string(filename) {
+            Ok(v) => v,
+            Err(err) => {
+                if err.kind() == std::io::ErrorKind::NotFound {
+                    String::new()
+                } else {
+                    bail!("unable to read acl config {:?} - {}", filename, err);
+                }
+            }
+        };
+
+        let digest = openssl::sha::sha256(raw.as_bytes());
+
+        for (linenr, line) in raw.lines().enumerate() {
+            let line = line.trim();
+            if line.is_empty() {
+                continue;
+            }
+            if let Err(err) = tree.parse_acl_line(line) {
+                bail!(
+                    "unable to parse acl config {:?}, line {} - {}",
+                    filename,
+                    linenr + 1,
+                    err
+                );
+            }
+        }
+
+        Ok((tree, digest))
+    }
+
+    /// This is used for testing
+    pub fn from_raw(raw: &str) -> Result<Self, Error> {
+        let mut tree = Self::new();
+        for (linenr, line) in raw.lines().enumerate() {
+            let line = line.trim();
+            if line.is_empty() {
+                continue;
+            }
+            if let Err(err) = tree.parse_acl_line(line) {
+                bail!(
+                    "unable to parse acl config data, line {} - {}",
+                    linenr + 1,
+                    err
+                );
+            }
+        }
+        Ok(tree)
+    }
+
+    /// Returns a map of role name and propagation status for a given `auth_id` and `path`.
+    ///
+    /// This will collect role mappings according to the following algorithm:
+    /// - iterate over all intermediate nodes along `path` and collect roles with `propagate` set
+    /// - get all (propagating and non-propagating) roles for last component of path
+    /// - more specific role maps replace less specific role maps
+    /// -- user/token is more specific than group at each level
+    /// -- roles lower in the tree are more specific than those higher up along the path
+    pub fn roles(&self, auth_id: &Authid, path: &[&str]) -> HashMap<String, bool> {
+        let mut node = &self.root;
+        let mut role_map = node.extract_roles(auth_id, path.is_empty());
+
+        let mut comp_iter = path.iter().peekable();
+
+        while let Some(comp) = comp_iter.next() {
+            let last_comp = comp_iter.peek().is_none();
+
+            let mut sub_comp_iter = comp.split('/').peekable();
+
+            while let Some(sub_comp) = sub_comp_iter.next() {
+                let last_sub_comp = last_comp && sub_comp_iter.peek().is_none();
+
+                node = match node.children.get(sub_comp) {
+                    Some(n) => n,
+                    None => return role_map, // path not found
+                };
+
+                let new_map = node.extract_roles(auth_id, last_sub_comp);
+                if !new_map.is_empty() {
+                    // overwrite previous mappings
+                    role_map = new_map;
+                }
+            }
+        }
+
+        role_map
+    }
+
+    pub fn get_child_paths(&self, auth_id: &Authid, path: &[&str]) -> Result<Vec<String>, Error> {
+        let mut res = Vec::new();
+
+        if let Some(node) = self.get_node(path) {
+            let path = path.join("/");
+            node.get_child_paths(path, auth_id, &mut res)?;
+        }
+
+        Ok(res)
+    }
+}
+
+/// Get exclusive lock
+pub fn lock_config() -> Result<ApiLockGuard, Error> {
+    open_api_lockfile(acl_config_lock(), None, true)
+}
+
+/// Reads the [`AclTree`] from the [default path](ACL_CFG_FILENAME).
+pub fn config() -> Result<(AclTree, [u8; 32]), Error> {
+    let path = acl_config();
+    AclTree::load(&path)
+}
+
+/// Returns a cached [`AclTree`] or fresh copy read directly from the [default
+/// path](ACL_CFG_FILENAME)
+///
+/// Since the AclTree is used for every API request's permission check, this caching mechanism
+/// allows to skip reading and parsing the file again if it is unchanged.
+pub fn cached_config() -> Result<Arc<AclTree>, Error> {
+    struct ConfigCache {
+        data: Option<Arc<AclTree>>,
+        last_mtime: i64,
+        last_mtime_nsec: i64,
+    }
+
+    static CACHED_CONFIG: OnceLock<RwLock<ConfigCache>> = OnceLock::new();
+    let cached_conf = CACHED_CONFIG.get_or_init(|| {
+        RwLock::new(ConfigCache {
+            data: None,
+            last_mtime: 0,
+            last_mtime_nsec: 0,
+        })
+    });
+
+    let conf = acl_config();
+    let stat = match nix::sys::stat::stat(&conf) {
+        Ok(stat) => Some(stat),
+        Err(nix::errno::Errno::ENOENT) => None,
+        Err(err) => bail!("unable to stat '{}' - {err}", conf.display()),
+    };
+
+    {
+        // limit scope
+        let cache = cached_conf.read().unwrap();
+        if let Some(ref config) = cache.data {
+            if let Some(stat) = stat {
+                if stat.st_mtime == cache.last_mtime && stat.st_mtime_nsec == cache.last_mtime_nsec
+                {
+                    return Ok(config.clone());
+                }
+            } else if cache.last_mtime == 0 && cache.last_mtime_nsec == 0 {
+                return Ok(config.clone());
+            }
+        }
+    }
+
+    let (config, _digest) = config()?;
+    let config = Arc::new(config);
+
+    let mut cache = cached_conf.write().unwrap();
+    if let Some(stat) = stat {
+        cache.last_mtime = stat.st_mtime;
+        cache.last_mtime_nsec = stat.st_mtime_nsec;
+    }
+    cache.data = Some(config.clone());
+
+    Ok(config)
+}
+
+/// Saves an [`AclTree`] to the [default path](ACL_CFG_FILENAME), ensuring proper ownership and
+/// file permissions.
+pub fn save_config(acl: &AclTree) -> Result<(), Error> {
+    let mut raw: Vec<u8> = Vec::new();
+    acl.write_config(&mut raw)?;
+
+    let conf = acl_config();
+    replace_privileged_config(conf, &raw)?;
+
+    Ok(())
+}
+
+#[cfg(test)]
+mod test {
+    use std::{collections::HashMap, sync::OnceLock};
+
+    use crate::init::{init_access_config, AccessControlConfig};
+
+    use super::AclTree;
+    use anyhow::Error;
+
+    use proxmox_auth_api::types::Authid;
+
+    #[derive(Debug)]
+    struct TestAcmConfig<'a> {
+        roles: HashMap<&'a str, u64>,
+    }
+
+    impl AccessControlConfig for TestAcmConfig<'_> {
+        fn roles(&self) -> &HashMap<&str, u64> {
+            &self.roles
+        }
+
+        fn role_no_access(&self) -> Option<&'static str> {
+            Some("NoAccess")
+        }
+
+        fn role_admin(&self) -> Option<&'static str> {
+            Some("Admin")
+        }
+    }
+
+    fn setup_acl_tree_config() {
+        static ACL_CONFIG: OnceLock<TestAcmConfig> = OnceLock::new();
+        let config = ACL_CONFIG.get_or_init(|| {
+            let mut roles = HashMap::new();
+            roles.insert("NoAccess", 0);
+            roles.insert("Admin", u64::MAX);
+            roles.insert("DatastoreBackup", 4);
+            roles.insert("DatastoreReader", 8);
+
+            let config = TestAcmConfig { roles };
+            config
+        });
+
+        // ignore errors here, we don't care if it's initialized already
+        let _ = init_access_config(config);
+    }
+
+    fn check_roles(tree: &AclTree, auth_id: &Authid, path: &str, expected_roles: &str) {
+        let path_vec = super::split_acl_path(path);
+        let mut roles = tree
+            .roles(auth_id, &path_vec)
+            .keys()
+            .cloned()
+            .collect::<Vec<String>>();
+        roles.sort();
+        let roles = roles.join(",");
+
+        assert_eq!(
+            roles, expected_roles,
+            "\nat check_roles for '{}' on '{}'",
+            auth_id, path
+        );
+    }
+
+    #[test]
+    fn test_acl_line_compression() {
+        setup_acl_tree_config();
+
+        let tree = AclTree::from_raw(
+            "\
+            acl:0:/store/store2:user1 at pbs:Admin\n\
+            acl:0:/store/store2:user2 at pbs:Admin\n\
+            acl:0:/store/store2:user1 at pbs:DatastoreBackup\n\
+            acl:0:/store/store2:user2 at pbs:DatastoreBackup\n\
+            ",
+        )
+        .expect("failed to parse acl tree");
+
+        let mut raw: Vec<u8> = Vec::new();
+        tree.write_config(&mut raw)
+            .expect("failed to write acl tree");
+        let raw = std::str::from_utf8(&raw).expect("acl tree is not valid utf8");
+
+        assert_eq!(
+            raw,
+            "acl:0:/store/store2:user1 at pbs,user2 at pbs:Admin,DatastoreBackup\n"
+        );
+    }
+
+    #[test]
+    fn test_roles_1() -> Result<(), Error> {
+        setup_acl_tree_config();
+
+        let tree = AclTree::from_raw(
+            "\
+            acl:1:/storage:user1 at pbs:Admin\n\
+            acl:1:/storage/store1:user1 at pbs:DatastoreBackup\n\
+            acl:1:/storage/store2:user2 at pbs:DatastoreBackup\n\
+            ",
+        )?;
+        let user1: Authid = "user1 at pbs".parse()?;
+        check_roles(&tree, &user1, "/", "");
+        check_roles(&tree, &user1, "/storage", "Admin");
+        check_roles(&tree, &user1, "/storage/store1", "DatastoreBackup");
+        check_roles(&tree, &user1, "/storage/store2", "Admin");
+
+        let user2: Authid = "user2 at pbs".parse()?;
+        check_roles(&tree, &user2, "/", "");
+        check_roles(&tree, &user2, "/storage", "");
+        check_roles(&tree, &user2, "/storage/store1", "");
+        check_roles(&tree, &user2, "/storage/store2", "DatastoreBackup");
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_role_no_access() -> Result<(), Error> {
+        setup_acl_tree_config();
+
+        let tree = AclTree::from_raw(
+            "\
+            acl:1:/:user1 at pbs:Admin\n\
+            acl:1:/storage:user1 at pbs:NoAccess\n\
+            acl:1:/storage/store1:user1 at pbs:DatastoreBackup\n\
+            ",
+        )?;
+        let user1: Authid = "user1 at pbs".parse()?;
+        check_roles(&tree, &user1, "/", "Admin");
+        check_roles(&tree, &user1, "/storage", "NoAccess");
+        check_roles(&tree, &user1, "/storage/store1", "DatastoreBackup");
+        check_roles(&tree, &user1, "/storage/store2", "NoAccess");
+        check_roles(&tree, &user1, "/system", "Admin");
+
+        let tree = AclTree::from_raw(
+            "\
+            acl:1:/:user1 at pbs:Admin\n\
+            acl:0:/storage:user1 at pbs:NoAccess\n\
+            acl:1:/storage/store1:user1 at pbs:DatastoreBackup\n\
+            ",
+        )?;
+        check_roles(&tree, &user1, "/", "Admin");
+        check_roles(&tree, &user1, "/storage", "NoAccess");
+        check_roles(&tree, &user1, "/storage/store1", "DatastoreBackup");
+        check_roles(&tree, &user1, "/storage/store2", "Admin");
+        check_roles(&tree, &user1, "/system", "Admin");
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_role_add_delete() -> Result<(), Error> {
+        setup_acl_tree_config();
+
+        let mut tree = AclTree::new();
+
+        let user1: Authid = "user1 at pbs".parse()?;
+
+        tree.insert_user_role("/", &user1, "Admin", true);
+        tree.insert_user_role("/", &user1, "Audit", true);
+
+        check_roles(&tree, &user1, "/", "Admin,Audit");
+
+        tree.insert_user_role("/", &user1, "NoAccess", true);
+        check_roles(&tree, &user1, "/", "NoAccess");
+
+        let mut raw: Vec<u8> = Vec::new();
+        tree.write_config(&mut raw)?;
+        let raw = std::str::from_utf8(&raw)?;
+
+        assert_eq!(raw, "acl:1:/:user1 at pbs:NoAccess\n");
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_no_access_overwrite() -> Result<(), Error> {
+        setup_acl_tree_config();
+
+        let mut tree = AclTree::new();
+
+        let user1: Authid = "user1 at pbs".parse()?;
+
+        tree.insert_user_role("/storage", &user1, "NoAccess", true);
+
+        check_roles(&tree, &user1, "/storage", "NoAccess");
+
+        tree.insert_user_role("/storage", &user1, "Admin", true);
+        tree.insert_user_role("/storage", &user1, "Audit", true);
+
+        check_roles(&tree, &user1, "/storage", "Admin,Audit");
+
+        tree.insert_user_role("/storage", &user1, "NoAccess", true);
+
+        check_roles(&tree, &user1, "/storage", "NoAccess");
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_get_child_paths() -> Result<(), Error> {
+        setup_acl_tree_config();
+
+        let tree = AclTree::from_raw(
+            "\
+            acl:0:/store/store2:user1 at pbs:Admin\n\
+            acl:1:/store/store2/store31/store4/store6:user2 at pbs:DatastoreReader\n\
+            acl:0:/store/store2/store3:user1 at pbs:Admin\n\
+            ",
+        )
+        .expect("failed to parse acl tree");
+
+        let user1: Authid = "user1 at pbs".parse()?;
+        let user2: Authid = "user2 at pbs".parse()?;
+
+        // user1 has admin on "/store/store2/store3" -> return paths
+        let paths = tree.get_child_paths(&user1, &["store"])?;
+        assert!(
+            paths.len() == 2
+                && paths.contains(&"store/store2".to_string())
+                && paths.contains(&"store/store2/store3".to_string())
+        );
+
+        // user2 has no privileges under "/store/store2/store3" --> return empty
+        assert!(tree
+            .get_child_paths(&user2, &["store", "store2", "store3"],)?
+            .is_empty());
+
+        // user2 has DatastoreReader privileges under "/store/store2/store31" --> return paths
+        let paths = tree.get_child_paths(&user2, &["store/store2/store31"])?;
+        assert!(
+            paths.len() == 1 && paths.contains(&"store/store2/store31/store4/store6".to_string())
+        );
+
+        // user2 has no privileges under "/store/store2/foo/bar/baz"
+        assert!(tree
+            .get_child_paths(&user2, &["store", "store2", "foo/bar/baz"])?
+            .is_empty());
+
+        // user2 has DatastoreReader privileges on "/store/store2/store31/store4/store6", but not
+        // on any child paths --> return empty
+        assert!(tree
+            .get_child_paths(&user2, &["store/store2/store31/store4/store6"],)?
+            .is_empty());
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_delete_node() -> Result<(), Error> {
+        setup_acl_tree_config();
+
+        let mut tree = AclTree::new();
+
+        let user1: Authid = "user1 at pbs".parse()?;
+
+        tree.insert_user_role("/storage", &user1, "NoAccess", true);
+        tree.insert_user_role("/storage/a", &user1, "NoAccess", true);
+        tree.insert_user_role("/storage/b", &user1, "NoAccess", true);
+        tree.insert_user_role("/storage/b/a", &user1, "NoAccess", true);
+        tree.insert_user_role("/storage/b/b", &user1, "NoAccess", true);
+        tree.insert_user_role("/datastore/c", &user1, "NoAccess", true);
+        tree.insert_user_role("/datastore/d", &user1, "NoAccess", true);
+
+        assert!(tree.find_node("/storage/b/a").is_some());
+        tree.delete_node("/storage/b/a");
+        assert!(tree.find_node("/storage/b/a").is_none());
+
+        assert!(tree.find_node("/storage/b/b").is_some());
+        assert!(tree.find_node("/storage/b").is_some());
+        tree.delete_node("/storage/b");
+        assert!(tree.find_node("/storage/b/b").is_none());
+        assert!(tree.find_node("/storage/b").is_none());
+
+        assert!(tree.find_node("/storage").is_some());
+        assert!(tree.find_node("/storage/a").is_some());
+        tree.delete_node("/storage");
+        assert!(tree.find_node("/storage").is_none());
+        assert!(tree.find_node("/storage/a").is_none());
+
+        assert!(tree.find_node("/datastore/c").is_some());
+        tree.delete_node("/datastore/c");
+        assert!(tree.find_node("/datastore/c").is_none());
+
+        assert!(tree.find_node("/datastore/d").is_some());
+        tree.delete_node("/datastore/d");
+        assert!(tree.find_node("/datastore/d").is_none());
+
+        // '/' should not be deletable
+        assert!(tree.find_node("/").is_some());
+        tree.delete_node("/");
+        assert!(tree.find_node("/").is_some());
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_delete_authid() -> Result<(), Error> {
+        setup_acl_tree_config();
+
+        let mut tree = AclTree::new();
+
+        let user1: Authid = "user1 at pbs".parse()?;
+        let user2: Authid = "user2 at pbs".parse()?;
+
+        let user1_paths = vec![
+            "/",
+            "/storage",
+            "/storage/a",
+            "/storage/a/b",
+            "/storage/b",
+            "/storage/b/a",
+            "/storage/b/b",
+            "/storage/a/a",
+        ];
+        let user2_paths = vec!["/", "/storage", "/storage/a/b", "/storage/a/a"];
+
+        for path in &user1_paths {
+            tree.insert_user_role(path, &user1, "NoAccess", true);
+        }
+        for path in &user2_paths {
+            tree.insert_user_role(path, &user2, "NoAccess", true);
+        }
+
+        tree.delete_authid(&user1);
+
+        for path in &user1_paths {
+            let node = tree.find_node(path);
+            assert!(node.is_some());
+            if let Some(node) = node {
+                assert!(node.users.get(&user1).is_none());
+            }
+        }
+        for path in &user2_paths {
+            let node = tree.find_node(path);
+            assert!(node.is_some());
+            if let Some(node) = node {
+                assert!(node.users.get(&user2).is_some());
+            }
+        }
+
+        tree.delete_authid(&user2);
+
+        for path in &user2_paths {
+            let node = tree.find_node(path);
+            assert!(node.is_some());
+            if let Some(node) = node {
+                assert!(node.users.get(&user2).is_none());
+            }
+        }
+
+        Ok(())
+    }
+}
diff --git a/proxmox-access-control/src/init.rs b/proxmox-access-control/src/init.rs
new file mode 100644
index 00000000..195ee131
--- /dev/null
+++ b/proxmox-access-control/src/init.rs
@@ -0,0 +1,76 @@
+use anyhow::{format_err, Error};
+use std::{
+    collections::HashMap,
+    path::{Path, PathBuf},
+    sync::OnceLock,
+};
+
+static ACCESS_CONF: OnceLock<&'static dyn AccessControlConfig> = OnceLock::new();
+static ACCESS_CONF_DIR: OnceLock<PathBuf> = OnceLock::new();
+
+/// This trait specifies the functions a product needs to implement to get ACL tree based access
+/// control management from this plugin.
+pub trait AccessControlConfig: Send + Sync {
+    /// Returns a mapping of all recognized privileges and their corresponding `u64` value.
+    fn privileges(&self) -> &HashMap<&str, u64>;
+
+    /// Returns a mapping of all recognized roles and their corresponding `u64` value.
+    fn roles(&self) -> &HashMap<&str, u64>;
+
+    /// Optionally returns a role that has no access to any resource.
+    ///
+    /// Default: Returns `None`.
+    fn role_no_access(&self) -> Option<&str> {
+        None
+    }
+
+    /// Optionally returns a role that is allowed to access all resources.
+    ///
+    /// Default: Returns `None`.
+    fn role_admin(&self) -> Option<&str> {
+        None
+    }
+}
+
+pub fn init<P: AsRef<Path>>(
+    acm_config: &'static dyn AccessControlConfig,
+    config_dir: P,
+) -> Result<(), Error> {
+    init_access_config(acm_config)?;
+    init_access_config_dir(config_dir)
+}
+
+pub(crate) fn init_access_config_dir<P: AsRef<Path>>(config_dir: P) -> Result<(), Error> {
+    ACCESS_CONF_DIR
+        .set(config_dir.as_ref().to_owned())
+        .map_err(|_e| format_err!("cannot initialize acl tree config twice!"))
+}
+
+pub(crate) fn init_access_config(config: &'static dyn AccessControlConfig) -> Result<(), Error> {
+    ACCESS_CONF
+        .set(config)
+        .map_err(|_e| format_err!("cannot initialize acl tree config twice!"))
+}
+
+
+pub(crate) fn access_conf() -> &'static dyn AccessControlConfig {
+    *ACCESS_CONF
+        .get()
+        .expect("please initialize the acm config before using it!")
+}
+
+
+fn conf_dir() -> &'static PathBuf {
+    ACCESS_CONF_DIR
+        .get()
+        .expect("please initialize acm config dir before using it!")
+}
+
+pub(crate) fn acl_config() -> PathBuf {
+    conf_dir().with_file_name("acl.cfg")
+}
+
+pub(crate) fn acl_config_lock() -> PathBuf {
+    conf_dir().with_file_name(".acl.lck")
+}
+
diff --git a/proxmox-access-control/src/lib.rs b/proxmox-access-control/src/lib.rs
new file mode 100644
index 00000000..8ad2c83d
--- /dev/null
+++ b/proxmox-access-control/src/lib.rs
@@ -0,0 +1,2 @@
+pub mod acl;
+pub mod init;
-- 
2.39.2





More information about the pbs-devel mailing list