public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Shannon Sterz <s.sterz@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH proxmox v2 2/6] access-control: add acl api feature
Date: Fri, 11 Apr 2025 15:44:26 +0200	[thread overview]
Message-ID: <20250411134435.269524-3-s.sterz@proxmox.com> (raw)
In-Reply-To: <20250411134435.269524-1-s.sterz@proxmox.com>

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@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



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


  parent reply	other threads:[~2025-04-11 13:44 UTC|newest]

Thread overview: 14+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-04-11 13:44 [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp v2 00/11] ACL edit api and ui components Shannon Sterz
2025-04-11 13:44 ` [pdm-devel] [PATCH proxmox v2 1/6] access-control: add more types to prepare for api feature Shannon Sterz
2025-04-11 13:44 ` Shannon Sterz [this message]
2025-04-11 13:44 ` [pdm-devel] [PATCH proxmox v2 3/6] access-control: add comments to roles function of AccessControlConfig Shannon Sterz
2025-04-11 13:44 ` [pdm-devel] [PATCH proxmox v2 4/6] access-control: add generic roles endpoint to `api` feature Shannon Sterz
2025-04-11 13:44 ` [pdm-devel] [PATCH proxmox v2 5/6] access-control: api: refactor validation checks to re-use existing code Shannon Sterz
2025-04-11 13:44 ` [pdm-devel] [PATCH proxmox v2 6/6] access-control: api: refactor extract_acl_node_data to be non-recursive Shannon Sterz
2025-04-11 13:44 ` [pdm-devel] [PATCH yew-comp v2 1/3] api-types/role_selector: depend on common `RoleInfo` type Shannon Sterz
2025-04-11 13:44 ` [pdm-devel] [PATCH yew-comp v2 2/3] acl: add a view and semi-generic `EditWindow` for acl entries Shannon Sterz
2025-04-11 13:44 ` [pdm-devel] [PATCH yew-comp v2 3/3] role_selector/acl_edit: make api endpoint and default role configurable Shannon Sterz
2025-04-11 13:44 ` [pdm-devel] [PATCH datacenter-manager v2 1/2] server: use proxmox-access-control api implementations Shannon Sterz
2025-04-11 13:44 ` [pdm-devel] [PATCH datacenter-manager v2 2/2] ui: configuration: add panel for viewing and editing acl entries Shannon Sterz
2025-04-17 15:46 ` [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp v2 00/11] ACL edit api and ui components Thomas Lamprecht
2025-04-22  8:12   ` Shannon Sterz

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=20250411134435.269524-3-s.sterz@proxmox.com \
    --to=s.sterz@proxmox.com \
    --cc=pdm-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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal