From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from ronja.mits.lan by ronja.mits.lan with LMTP id GGcyDQSrcmZ4MQAAxxbTJA (envelope-from ); Wed, 19 Jun 2024 11:55:16 +0200 Received: from proxmox-new.maurer-it.com (unknown [192.168.2.33]) by ronja.mits.lan (Postfix) with ESMTPS id 2184CF63D90; Wed, 19 Jun 2024 11:55:16 +0200 (CEST) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 06E6142F62; Wed, 19 Jun 2024 11:55:16 +0200 (CEST) Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (4096 bits)) (No client certificate requested) by proxmox-new.maurer-it.com (Proxmox) with ESMTPS; Wed, 19 Jun 2024 11:55:12 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id B570718FB; Wed, 19 Jun 2024 11:55:11 +0200 (CEST) From: Shannon Sterz To: pbs-devel@lists.proxmox.com Date: Wed, 19 Jun 2024 11:54:12 +0200 Message-Id: <20240619095418.126368-2-s.sterz@proxmox.com> X-Mailer: git-send-email 2.39.2 In-Reply-To: <20240619095418.126368-1-s.sterz@proxmox.com> References: <20240619095418.126368-1-s.sterz@proxmox.com> MIME-Version: 1.0 Subject: [pbs-devel] [PATCH proxmox v3 1/7] access-control: add the proxmox-access crate to reuse acl trees X-BeenThere: pbs-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Backup Server development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Backup Server development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pbs-devel-bounces@lists.proxmox.com Sender: "pbs-devel" X-SPAM-LEVEL: Spam detection results: 0 DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment KAM_LAZY_DOMAIN_SECURITY 1 Sending domain does not have any anti-forgery methods MAILING_LIST_MULTI -2 Multiple indicators imply a widely-seen list manager RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_NONE 0.001 SPF: sender does not publish an SPF Record T_SCC_BODY_TEXT_LINE -0.01 - 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 --- Cargo.toml | 2 + proxmox-access-control/Cargo.toml | 22 + proxmox-access-control/src/acl.rs | 999 +++++++++++++++++++++++++++++ proxmox-access-control/src/init.rs | 74 +++ proxmox-access-control/src/lib.rs | 2 + 5 files changed, 1099 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>, + /// `Group` ACLs for this node (not yet implemented) + pub groups: HashMap>, + /// `AclTreeNodes` representing ACL paths directly below the current one. + pub children: BTreeMap, +} + +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 { + 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 { + 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 { + 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, + ) -> 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@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>, + ) -> BTreeMap> { + 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 { + 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 { + 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 { + 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, 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 { + 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, Error> { + struct ConfigCache { + data: Option>, + last_mtime: i64, + last_mtime_nsec: i64, + } + + static CACHED_CONFIG: OnceLock> = 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 = 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 = 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::>(); + 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@pbs:Admin\n\ + acl:0:/store/store2:user2@pbs:Admin\n\ + acl:0:/store/store2:user1@pbs:DatastoreBackup\n\ + acl:0:/store/store2:user2@pbs:DatastoreBackup\n\ + ", + ) + .expect("failed to parse acl tree"); + + let mut raw: Vec = 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@pbs,user2@pbs:Admin,DatastoreBackup\n" + ); + } + + #[test] + fn test_roles_1() -> Result<(), Error> { + setup_acl_tree_config(); + + let tree = AclTree::from_raw( + "\ + acl:1:/storage:user1@pbs:Admin\n\ + acl:1:/storage/store1:user1@pbs:DatastoreBackup\n\ + acl:1:/storage/store2:user2@pbs:DatastoreBackup\n\ + ", + )?; + let user1: Authid = "user1@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@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@pbs:Admin\n\ + acl:1:/storage:user1@pbs:NoAccess\n\ + acl:1:/storage/store1:user1@pbs:DatastoreBackup\n\ + ", + )?; + let user1: Authid = "user1@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@pbs:Admin\n\ + acl:0:/storage:user1@pbs:NoAccess\n\ + acl:1:/storage/store1:user1@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@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 = Vec::new(); + tree.write_config(&mut raw)?; + let raw = std::str::from_utf8(&raw)?; + + assert_eq!(raw, "acl:1:/:user1@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@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@pbs:Admin\n\ + acl:1:/store/store2/store31/store4/store6:user2@pbs:DatastoreReader\n\ + acl:0:/store/store2/store3:user1@pbs:Admin\n\ + ", + ) + .expect("failed to parse acl tree"); + + let user1: Authid = "user1@pbs".parse()?; + let user2: Authid = "user2@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@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@pbs".parse()?; + let user2: Authid = "user2@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..a8dfa24e --- /dev/null +++ b/proxmox-access-control/src/init.rs @@ -0,0 +1,74 @@ +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 = 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>( + 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>(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().join("acl.cfg") +} + +pub(crate) fn acl_config_lock() -> PathBuf { + conf_dir().join(".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 _______________________________________________ pbs-devel mailing list pbs-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel