[pdm-devel] [PATCH proxmox v2 2/6] access-control: add acl api feature

Shannon Sterz s.sterz at proxmox.com
Fri Apr 11 15:44:26 CEST 2025


this moves this commonly re-implemented api endpoints to this shared
crate so they can be easily re-used.

for this to function a user of the crate needs to extend the
`AccessControlConfig` with the following three functions:

- `acl_audit_privilege()`: returns the privilege necessary to see all
  acl entries
- `acl_modify_privilege()`: returns the privilege necessary to edit
  the acl beyond a user's api token privileges
- `check_acl_path()`: checks whether a path is a valid acl path in the
  context of the product using this crate. by default all paths are
  considered valid.

all three provide default implementations so that users that only use
the `impl` feature don't need to change anything.

Signed-off-by: Shannon Sterz <s.sterz at proxmox.com>
---

this was moved and adapted from pdm & pbs:

- pdm: server/src/api/access/acl.rs
- pbs: src/api2/access/acl.rs

 proxmox-access-control/Cargo.toml  |   5 +
 proxmox-access-control/src/api.rs  | 278 +++++++++++++++++++++++++++++
 proxmox-access-control/src/init.rs |  22 +++
 proxmox-access-control/src/lib.rs  |   3 +
 4 files changed, 308 insertions(+)
 create mode 100644 proxmox-access-control/src/api.rs

diff --git a/proxmox-access-control/Cargo.toml b/proxmox-access-control/Cargo.toml
index 23be7fcb..03f5c9fd 100644
--- a/proxmox-access-control/Cargo.toml
+++ b/proxmox-access-control/Cargo.toml
@@ -17,6 +17,7 @@ const_format.workspace = true
 nix = { workspace = true, optional = true }
 openssl = { workspace = true, optional = true }
 regex.workspace = true
+hex = { workspace = true, optional = true }
 serde.workspace = true
 serde_json = { workspace = true, optional = true }
 serde_plain.workspace = true
@@ -33,6 +34,10 @@ proxmox-time = { workspace = true }

 [features]
 default = []
+api = [
+    "impl",
+    "dep:hex",
+]
 impl = [
     "dep:nix",
     "dep:openssl",
diff --git a/proxmox-access-control/src/api.rs b/proxmox-access-control/src/api.rs
new file mode 100644
index 00000000..4a6aabf5
--- /dev/null
+++ b/proxmox-access-control/src/api.rs
@@ -0,0 +1,278 @@
+use anyhow::{bail, format_err, Error};
+
+use proxmox_auth_api::types::{Authid, PROXMOX_GROUP_ID_SCHEMA};
+use proxmox_config_digest::{ConfigDigest, PROXMOX_CONFIG_DIGEST_SCHEMA};
+use proxmox_router::{Permission, Router, RpcEnvironment};
+use proxmox_schema::api;
+
+use crate::acl::AclTreeNode;
+use crate::init::access_conf;
+use crate::types::{AclListItem, AclUgidType, ACL_PATH_SCHEMA, ACL_PROPAGATE_SCHEMA};
+use crate::CachedUserInfo;
+
+#[api(
+    input: {
+        properties: {
+            path: {
+                schema: ACL_PATH_SCHEMA,
+                optional: true,
+            },
+            exact: {
+                description: "If set, returns only ACL for the exact path.",
+                type: bool,
+                optional: true,
+                default: false,
+            },
+        },
+    },
+    returns: {
+        description: "ACL entry list.",
+        type: Array,
+        items: {
+            type: AclListItem,
+        }
+    },
+    access: {
+        permission: &Permission::Anybody,
+        description: "Returns all ACLs if user has sufficient privileges on this endpoint, otherwise it is limited to the user's API tokens.",
+    },
+)]
+/// Get ACL entries, can be filter by path.
+pub fn read_acl(
+    path: Option<String>,
+    exact: bool,
+    rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Vec<AclListItem>, Error> {
+    let auth_id = rpcenv
+        .get_auth_id()
+        .ok_or_else(|| format_err!("endpoint called without an auth id"))?
+        .parse()?;
+
+    let top_level_privs = CachedUserInfo::new()?.lookup_privs(&auth_id, &["access", "acl"]);
+
+    let filter = if top_level_privs & access_conf().acl_audit_privileges() == 0 {
+        Some(auth_id)
+    } else {
+        None
+    };
+
+    let (mut tree, digest) = crate::acl::config()?;
+
+    let node = if let Some(path) = &path {
+        if let Some(node) = tree.find_node(path) {
+            node
+        } else {
+            return Ok(Vec::new());
+        }
+    } else {
+        &tree.root
+    };
+
+    rpcenv["digest"] = hex::encode(digest).into();
+
+    Ok(extract_acl_node_data(node, path.as_deref(), exact, &filter))
+}
+
+#[api(
+    protected: true,
+    input: {
+        properties: {
+            path: {
+                schema: ACL_PATH_SCHEMA,
+            },
+            role: {
+                type: String,
+                description: "Name of a role that the auth id will be granted.",
+            },
+            propagate: {
+                optional: true,
+                schema: ACL_PROPAGATE_SCHEMA,
+            },
+            "auth-id": {
+                optional: true,
+                type: Authid,
+            },
+            group: {
+                optional: true,
+                schema: PROXMOX_GROUP_ID_SCHEMA,
+            },
+            delete: {
+                optional: true,
+                description: "Remove permissions (instead of adding it).",
+                type: bool,
+                default: false,
+            },
+            digest: {
+                optional: true,
+                schema: PROXMOX_CONFIG_DIGEST_SCHEMA,
+            },
+       },
+    },
+    access: {
+        permission: &Permission::Anybody,
+        description: "Requires sufficient permissions to edit the ACL, otherwise only editing the current user's API token permissions is allowed."
+    },
+)]
+/// Update ACL
+#[allow(clippy::too_many_arguments)]
+pub fn update_acl(
+    path: String,
+    role: String,
+    propagate: Option<bool>,
+    auth_id: Option<Authid>,
+    group: Option<String>,
+    delete: bool,
+    digest: Option<ConfigDigest>,
+    rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+    let access_conf = access_conf();
+
+    if !access_conf.roles().contains_key(role.as_str()) {
+        bail!("Role does not exist, please make sure to specify a valid role!")
+    }
+
+    let current_auth_id: Authid = rpcenv
+        .get_auth_id()
+        .expect("auth id could not be determined")
+        .parse()?;
+
+    let user_info = CachedUserInfo::new()?;
+    let top_level_privs = user_info.lookup_privs(&current_auth_id, &["access", "acl"]);
+
+    if top_level_privs & access_conf.acl_modify_privileges() == 0 {
+        if group.is_some() {
+            bail!("Unprivileged users are not allowed to create group ACL item.");
+        }
+
+        match &auth_id {
+            Some(auth_id) => {
+                if current_auth_id.is_token() {
+                    bail!("Unprivileged API tokens can't set ACL items.");
+                } else if !auth_id.is_token() {
+                    bail!("Unprivileged users can only set ACL items for API tokens.");
+                } else if auth_id.user() != current_auth_id.user() {
+                    bail!("Unprivileged users can only set ACL items for their own API tokens.");
+                }
+            }
+            None => {
+                bail!("Unprivileged user needs to provide auth_id to update ACL item.");
+            }
+        };
+    }
+
+    // FIXME: add support for group
+    if group.is_some() {
+        bail!("parameter 'group' - groups are currently not supported");
+    } else if let Some(auth_id) = &auth_id {
+        // only allow deleting non-existing auth id's, not adding them
+        if !delete {
+            let exists = crate::user::cached_config()?
+                .sections
+                .contains_key(&auth_id.to_string());
+
+            if !exists {
+                if auth_id.is_token() {
+                    bail!("no such API token");
+                } else {
+                    bail!("no such user.")
+                }
+            }
+        }
+    } else {
+        // FIXME: suggest groups here once they exist
+        bail!("missing 'userid' parameter");
+    }
+
+    // allow deleting invalid acl paths
+    if !delete {
+        access_conf.check_acl_path(&path)?;
+    }
+
+    let _guard = crate::acl::lock_config()?;
+    let (mut tree, expected_digest) = crate::acl::config()?;
+    expected_digest.detect_modification(digest.as_ref())?;
+
+    let propagate = propagate.unwrap_or(true);
+
+    if let Some(auth_id) = &auth_id {
+        if delete {
+            tree.delete_user_role(&path, auth_id, &role);
+        } else {
+            tree.insert_user_role(&path, auth_id, &role, propagate);
+        }
+    } else if let Some(group) = &group {
+        if delete {
+            tree.delete_group_role(&path, group, &role);
+        } else {
+            tree.insert_group_role(&path, group, &role, propagate);
+        }
+    }
+
+    crate::acl::save_config(&tree)?;
+
+    Ok(())
+}
+
+fn extract_acl_node_data(
+    node: &AclTreeNode,
+    path: Option<&str>,
+    exact: bool,
+    auth_id_filter: &Option<Authid>,
+) -> Vec<AclListItem> {
+    // tokens can't have tokens, so we can early return
+    if let Some(auth_id_filter) = auth_id_filter {
+        if auth_id_filter.is_token() {
+            return Vec::new();
+        }
+    }
+
+    let mut list = Vec::new();
+    let path_str = path.unwrap_or("/");
+
+    for (user, roles) in &node.users {
+        if let Some(auth_id_filter) = auth_id_filter {
+            if !user.is_token() || user.user() != auth_id_filter.user() {
+                continue;
+            }
+        }
+
+        for (role, propagate) in roles {
+            list.push(AclListItem {
+                path: path_str.to_owned(),
+                propagate: *propagate,
+                ugid_type: AclUgidType::User,
+                ugid: user.to_string(),
+                roleid: role.to_string(),
+            });
+        }
+    }
+
+    for (group, roles) in &node.groups {
+        if auth_id_filter.is_some() {
+            continue;
+        }
+
+        for (role, propagate) in roles {
+            list.push(AclListItem {
+                path: path_str.to_owned(),
+                propagate: *propagate,
+                ugid_type: AclUgidType::Group,
+                ugid: group.to_string(),
+                roleid: role.to_string(),
+            });
+        }
+    }
+
+    if !exact {
+        list.extend(node.children.iter().flat_map(|(comp, child)| {
+            let new_path = format!("{}/{comp}", path.unwrap_or(""));
+            extract_acl_node_data(child, Some(&new_path), exact, auth_id_filter)
+        }));
+    }
+
+    list
+}
+
+pub const ACL_ROUTER: Router = Router::new()
+    .get(&API_METHOD_READ_ACL)
+    .put(&API_METHOD_UPDATE_ACL);
diff --git a/proxmox-access-control/src/init.rs b/proxmox-access-control/src/init.rs
index b0cf1a3e..a6d36780 100644
--- a/proxmox-access-control/src/init.rs
+++ b/proxmox-access-control/src/init.rs
@@ -72,6 +72,28 @@ pub trait AccessControlConfig: Send + Sync {
         let _ = config;
         Ok(())
     }
+
+    /// This is used to determined what access control list entries a user is allowed to read.
+    ///
+    /// Override this if you want to use the `api` feature.
+    fn acl_audit_privileges(&self) -> u64 {
+        0
+    }
+
+    /// This is used to determine what privileges are needed to modify the access control list.
+    ///
+    /// Override this if you want to use the `api` feature.
+    fn acl_modify_privileges(&self) -> u64 {
+        0
+    }
+
+    /// Used to determine which paths are valid in a given `AclTree`.
+    ///
+    /// Override this if you want to use the `api` feature.
+    fn check_acl_path(&self, path: &str) -> Result<(), Error> {
+        let _ = path;
+        Ok(())
+    }
 }

 pub fn init<P: AsRef<Path>>(
diff --git a/proxmox-access-control/src/lib.rs b/proxmox-access-control/src/lib.rs
index c3aeb9db..62683924 100644
--- a/proxmox-access-control/src/lib.rs
+++ b/proxmox-access-control/src/lib.rs
@@ -5,6 +5,9 @@ pub mod types;
 #[cfg(feature = "impl")]
 pub mod acl;

+#[cfg(feature = "api")]
+pub mod api;
+
 #[cfg(feature = "impl")]
 pub mod init;

--
2.39.5





More information about the pdm-devel mailing list