From mboxrd@z Thu Jan  1 00:00:00 1970
Return-Path: <pdm-devel-bounces@lists.proxmox.com>
Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68])
	by lore.proxmox.com (Postfix) with ESMTPS id 7F1551FF16B
	for <inbox@lore.proxmox.com>; Thu,  3 Apr 2025 16:19:00 +0200 (CEST)
Received: from firstgate.proxmox.com (localhost [127.0.0.1])
	by firstgate.proxmox.com (Proxmox) with ESMTP id 384523DF2;
	Thu,  3 Apr 2025 16:18:49 +0200 (CEST)
From: Shannon Sterz <s.sterz@proxmox.com>
To: pdm-devel@lists.proxmox.com
Date: Thu,  3 Apr 2025 16:17:59 +0200
Message-Id: <20250403141806.402974-3-s.sterz@proxmox.com>
X-Mailer: git-send-email 2.39.5
In-Reply-To: <20250403141806.402974-1-s.sterz@proxmox.com>
References: <20250403141806.402974-1-s.sterz@proxmox.com>
MIME-Version: 1.0
X-SPAM-LEVEL: Spam detection results:  0
 AWL 0.018 Adjusted score from AWL reputation of From: address
 BAYES_00                 -1.9 Bayes spam probability is 0 to 1%
 DMARC_MISSING             0.1 Missing DMARC policy
 KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment
 SPF_HELO_NONE           0.001 SPF: HELO does not publish an SPF Record
 SPF_PASS               -0.001 SPF: sender matches SPF record
Subject: [pdm-devel] [PATCH proxmox 2/4] access-control: add acl api feature
X-BeenThere: pdm-devel@lists.proxmox.com
X-Mailman-Version: 2.1.29
Precedence: list
List-Id: Proxmox Datacenter Manager development discussion
 <pdm-devel.lists.proxmox.com>
List-Unsubscribe: <https://lists.proxmox.com/cgi-bin/mailman/options/pdm-devel>, 
 <mailto:pdm-devel-request@lists.proxmox.com?subject=unsubscribe>
List-Archive: <http://lists.proxmox.com/pipermail/pdm-devel/>
List-Post: <mailto:pdm-devel@lists.proxmox.com>
List-Help: <mailto:pdm-devel-request@lists.proxmox.com?subject=help>
List-Subscribe: <https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel>, 
 <mailto:pdm-devel-request@lists.proxmox.com?subject=subscribe>
Reply-To: Proxmox Datacenter Manager development discussion
 <pdm-devel@lists.proxmox.com>
Content-Type: text/plain; charset="us-ascii"
Content-Transfer-Encoding: 7bit
Errors-To: pdm-devel-bounces@lists.proxmox.com
Sender: "pdm-devel" <pdm-devel-bounces@lists.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>
---
 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