From: Shannon Sterz <s.sterz@proxmox.com>
To: pbs-devel@lists.proxmox.com
Subject: [pbs-devel] [PATCH proxmox v3 1/7] access-control: add the proxmox-access crate to reuse acl trees
Date: Wed, 19 Jun 2024 11:54:12 +0200 [thread overview]
Message-ID: <20240619095418.126368-2-s.sterz@proxmox.com> (raw)
In-Reply-To: <20240619095418.126368-1-s.sterz@proxmox.com>
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@proxmox.com>
---
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<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@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@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<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@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<u8> = 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<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().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
next prev parent reply other threads:[~2024-06-19 9:55 UTC|newest]
Thread overview: 9+ messages / expand[flat|nested] mbox.gz Atom feed top
2024-06-19 9:54 [pbs-devel] [PATCH proxmox v3 0/7] add proxmox-access-control crate Shannon Sterz
2024-06-19 9:54 ` Shannon Sterz [this message]
2024-06-19 9:54 ` [pbs-devel] [PATCH proxmox v3 2/7] access-control: define `User`, `UserWithTokens` and `ApiTokens` types Shannon Sterz
2024-06-19 9:54 ` [pbs-devel] [PATCH proxmox v3 3/7] access-control: make token shadow implementation re-usable Shannon Sterz
2024-06-19 9:54 ` [pbs-devel] [PATCH proxmox v3 4/7] access-control: factor out user config handling Shannon Sterz
2024-06-19 9:54 ` [pbs-devel] [PATCH proxmox v3 5/7] access: increment user cache generation when saving acl config Shannon Sterz
2024-06-19 9:54 ` [pbs-devel] [PATCH proxmox v3 6/7] access: move to flatten `User` into `UserWithToken` Shannon Sterz
2024-06-19 9:54 ` [pbs-devel] [PATCH proxmox v3 7/7] access-control: split crate in `default` and `impl` features Shannon Sterz
2024-06-19 12:48 ` [pbs-devel] applied-series: [PATCH proxmox v3 0/7] add proxmox-access-control crate Wolfgang Bumiller
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20240619095418.126368-2-s.sterz@proxmox.com \
--to=s.sterz@proxmox.com \
--cc=pbs-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.