all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Shannon Sterz <s.sterz@proxmox.com>
To: pbs-devel@lists.proxmox.com
Subject: [pbs-devel] [PATCH proxmox v3 4/7] access-control: factor out user config handling
Date: Wed, 19 Jun 2024 11:54:15 +0200	[thread overview]
Message-ID: <20240619095418.126368-5-s.sterz@proxmox.com> (raw)
In-Reply-To: <20240619095418.126368-1-s.sterz@proxmox.com>

this commit factors out the user config. it also add two new functions
to the `AccessControlConfig` trait to handle caching in a more
generalized way.

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 Cargo.toml                                    |   1 +
 proxmox-access-control/Cargo.toml             |   3 +
 proxmox-access-control/src/acl.rs             |   4 +
 .../src/cached_user_info.rs                   | 246 ++++++++++++++++++
 proxmox-access-control/src/init.rs            |  42 +++
 proxmox-access-control/src/lib.rs             |   4 +
 proxmox-access-control/src/user.rs            | 180 +++++++++++++
 7 files changed, 480 insertions(+)
 create mode 100644 proxmox-access-control/src/cached_user_info.rs
 create mode 100644 proxmox-access-control/src/user.rs

diff --git a/Cargo.toml b/Cargo.toml
index ca6bf62f..2a70050c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -127,6 +127,7 @@ proxmox-router = { version = "2.1.3", path = "proxmox-router" }
 proxmox-schema = { version = "3.1.1", path = "proxmox-schema" }
 proxmox-section-config = { version = "2.0.0", path = "proxmox-section-config" }
 proxmox-serde = { version = "0.1.1", path = "proxmox-serde", features = [ "serde_json" ] }
+proxmox-shared-memory = { version = "0.3.0", path = "proxmox-shared-memory" }
 proxmox-sortable-macro = { version = "0.1.3", path = "proxmox-sortable-macro" }
 proxmox-sys = { version = "0.5.5", path = "proxmox-sys" }
 proxmox-tfa = { version = "4.0.4", path = "proxmox-tfa" }
diff --git a/proxmox-access-control/Cargo.toml b/proxmox-access-control/Cargo.toml
index 01ab5f5a..239dcc91 100644
--- a/proxmox-access-control/Cargo.toml
+++ b/proxmox-access-control/Cargo.toml
@@ -21,7 +21,10 @@ serde_json.workspace = true
 
 # proxmox-notify.workspace = true
 proxmox-auth-api = { workspace = true, features = [ "api-types" ] }
+proxmox-router = { workspace = true }
 proxmox-schema.workspace = true
+proxmox-section-config.workspace = true
 proxmox-product-config.workspace = true
+proxmox-shared-memory.workspace = true
 proxmox-sys = { workspace = true, features = [ "crypt" ] }
 proxmox-time.workspace = true
diff --git a/proxmox-access-control/src/acl.rs b/proxmox-access-control/src/acl.rs
index 6c845ea3..d0449d9a 100644
--- a/proxmox-access-control/src/acl.rs
+++ b/proxmox-access-control/src/acl.rs
@@ -665,6 +665,10 @@ mod test {
             &self.roles
         }
 
+        fn privileges(&self) -> &HashMap<&str, u64> {
+            unreachable!("acl tests don't need privileges")
+        }
+
         fn role_no_access(&self) -> Option<&'static str> {
             Some("NoAccess")
         }
diff --git a/proxmox-access-control/src/cached_user_info.rs b/proxmox-access-control/src/cached_user_info.rs
new file mode 100644
index 00000000..00f22a6b
--- /dev/null
+++ b/proxmox-access-control/src/cached_user_info.rs
@@ -0,0 +1,246 @@
+//! Cached user info for fast ACL permission checks
+
+use std::sync::{Arc, OnceLock, RwLock};
+
+use anyhow::{bail, Error};
+
+use proxmox_auth_api::types::{Authid, Userid};
+use proxmox_router::UserInformation;
+use proxmox_section_config::SectionConfigData;
+use proxmox_time::epoch_i64;
+
+use crate::acl::AclTree;
+use crate::init::access_conf;
+use crate::types::{ApiToken, User};
+
+/// Cache User/Group/Token/Acl configuration data for fast permission tests
+pub struct CachedUserInfo {
+    user_cfg: Arc<SectionConfigData>,
+    acl_tree: Arc<AclTree>,
+}
+
+struct ConfigCache {
+    data: Option<Arc<CachedUserInfo>>,
+    last_update: i64,
+    last_user_cache_generation: usize,
+}
+
+impl CachedUserInfo {
+    /// Returns a cached instance (up to 5 seconds old).
+    pub fn new() -> Result<Arc<Self>, Error> {
+        let now = epoch_i64();
+
+        let cache_generation = access_conf().cache_generation();
+
+        static CACHED_CONFIG: OnceLock<RwLock<ConfigCache>> = OnceLock::new();
+        let cached_config = CACHED_CONFIG.get_or_init(|| {
+            RwLock::new(ConfigCache {
+                data: None,
+                last_update: 0,
+                last_user_cache_generation: 0,
+            })
+        });
+
+        {
+            // limit scope
+            let cache = cached_config.read().unwrap();
+            if let Some(current_generation) = cache_generation {
+                if (current_generation == cache.last_user_cache_generation)
+                    && ((now - cache.last_update) < 5)
+                {
+                    if let Some(ref config) = cache.data {
+                        return Ok(config.clone());
+                    }
+                }
+            }
+        }
+
+        let config = Arc::new(CachedUserInfo {
+            user_cfg: crate::user::cached_config()?,
+            acl_tree: crate::acl::cached_config()?,
+        });
+
+        let mut cache = cached_config.write().unwrap();
+
+        if let Some(current_generation) = cache_generation {
+            cache.last_user_cache_generation = current_generation;
+        }
+
+        cache.last_update = now;
+        cache.data = Some(config.clone());
+
+        Ok(config)
+    }
+
+    pub fn is_superuser(&self, auth_id: &Authid) -> bool {
+        access_conf().is_superuser(auth_id)
+    }
+
+    pub fn is_group_member(&self, user_id: &Userid, group: &str) -> bool {
+        access_conf().is_group_member(user_id, group)
+    }
+
+    /// Test if a user_id is enabled and not expired
+    pub fn is_active_user_id(&self, userid: &Userid) -> bool {
+        if let Ok(info) = self.user_cfg.lookup::<User>("user", userid.as_str()) {
+            info.is_active()
+        } else {
+            false
+        }
+    }
+
+    /// Test if a authentication id is enabled and not expired
+    pub fn is_active_auth_id(&self, auth_id: &Authid) -> bool {
+        let userid = auth_id.user();
+
+        if !self.is_active_user_id(userid) {
+            return false;
+        }
+
+        if auth_id.is_token() {
+            if let Ok(info) = self
+                .user_cfg
+                .lookup::<ApiToken>("token", &auth_id.to_string())
+            {
+                return info.is_active();
+            } else {
+                return false;
+            }
+        }
+
+        true
+    }
+
+    pub fn check_privs(
+        &self,
+        auth_id: &Authid,
+        path: &[&str],
+        required_privs: u64,
+        partial: bool,
+    ) -> Result<(), Error> {
+        let privs = self.lookup_privs(auth_id, path);
+        let allowed = if partial {
+            (privs & required_privs) != 0
+        } else {
+            (privs & required_privs) == required_privs
+        };
+        if !allowed {
+            // printing the path doesn't leak any information as long as we
+            // always check privilege before resource existence
+            let priv_names = privs_to_priv_names(required_privs);
+            let priv_names = if partial {
+                priv_names.join("|")
+            } else {
+                priv_names.join("&")
+            };
+            bail!(
+                "missing permissions '{priv_names}' on '/{}'",
+                path.join("/")
+            );
+        }
+        Ok(())
+    }
+
+    pub fn lookup_privs(&self, auth_id: &Authid, path: &[&str]) -> u64 {
+        let (privs, _) = self.lookup_privs_details(auth_id, path);
+        privs
+    }
+
+    pub fn lookup_privs_details(&self, auth_id: &Authid, path: &[&str]) -> (u64, u64) {
+        if self.is_superuser(auth_id) {
+            let acm_config = access_conf();
+            if let Some(admin) = acm_config.role_admin() {
+                if let Some(admin) = acm_config.roles().get(admin) {
+                    return (*admin, *admin);
+                }
+            }
+        }
+
+        let roles = self.acl_tree.roles(auth_id, path);
+        let mut privs: u64 = 0;
+        let mut propagated_privs: u64 = 0;
+        for (role, propagate) in roles {
+            if let Some(role_privs) = access_conf().roles().get(role.as_str()) {
+                if propagate {
+                    propagated_privs |= role_privs;
+                }
+                privs |= role_privs;
+            }
+        }
+
+        if auth_id.is_token() {
+            // limit privs to that of owning user
+            let user_auth_id = Authid::from(auth_id.user().clone());
+            let (owner_privs, owner_propagated_privs) =
+                self.lookup_privs_details(&user_auth_id, path);
+            privs &= owner_privs;
+            propagated_privs &= owner_propagated_privs;
+        }
+
+        (privs, propagated_privs)
+    }
+
+    /// Checks whether the `auth_id` has any of the privilegs `privs` on any object below `path`.
+    pub fn any_privs_below(
+        &self,
+        auth_id: &Authid,
+        path: &[&str],
+        privs: u64,
+    ) -> Result<bool, Error> {
+        // if the anchor path itself has matching propagated privs, we skip checking children
+        let (_privs, propagated_privs) = self.lookup_privs_details(auth_id, path);
+        if propagated_privs & privs != 0 {
+            return Ok(true);
+        }
+
+        // get all sub-paths with roles defined for `auth_id`
+        let paths = self.acl_tree.get_child_paths(auth_id, path)?;
+
+        for path in paths.iter() {
+            // early return if any sub-path has any of the privs we are looking for
+            if privs & self.lookup_privs(auth_id, &[path.as_str()]) != 0 {
+                return Ok(true);
+            }
+        }
+
+        // no paths or no matching paths
+        Ok(false)
+    }
+}
+
+impl UserInformation for CachedUserInfo {
+    fn is_superuser(&self, userid: &str) -> bool {
+        if let Ok(authid) = userid.parse() {
+            return self.is_superuser(&authid);
+        }
+
+        false
+    }
+
+    fn is_group_member(&self, userid: &str, group: &str) -> bool {
+        if let Ok(userid) = userid.parse() {
+            return self.is_group_member(&userid, group);
+        }
+
+        false
+    }
+
+    fn lookup_privs(&self, auth_id: &str, path: &[&str]) -> u64 {
+        match auth_id.parse::<Authid>() {
+            Ok(auth_id) => Self::lookup_privs(self, &auth_id, path),
+            Err(_) => 0,
+        }
+    }
+}
+
+pub fn privs_to_priv_names(privs: u64) -> Vec<&'static str> {
+    access_conf()
+        .privileges()
+        .iter()
+        .fold(Vec::new(), |mut priv_names, (name, value)| {
+            if value & privs != 0 {
+                priv_names.push(name);
+            }
+            priv_names
+        })
+}
diff --git a/proxmox-access-control/src/init.rs b/proxmox-access-control/src/init.rs
index 2f5593ea..75bcf8a4 100644
--- a/proxmox-access-control/src/init.rs
+++ b/proxmox-access-control/src/init.rs
@@ -1,4 +1,5 @@
 use anyhow::{format_err, Error};
+use proxmox_auth_api::types::{Authid, Userid};
 use std::{
     collections::HashMap,
     path::{Path, PathBuf},
@@ -17,6 +18,39 @@ pub trait AccessControlConfig: Send + Sync {
     /// Returns a mapping of all recognized roles and their corresponding `u64` value.
     fn roles(&self) -> &HashMap<&str, u64>;
 
+    /// Checks whether an `Authid` has super user privileges or not.
+    ///
+    /// Default: Always returns `false`.
+    fn is_superuser(&self, _auth_id: &Authid) -> bool {
+        false
+    }
+
+    /// Checks whether a user is part of a group.
+    ///
+    /// Default: Always returns `false`.
+    fn is_group_member(&self, _user_id: &Userid, _group: &str) -> bool {
+        false
+    }
+
+    /// Returns the current cache generation of the user and acl configs. If the generation was
+    /// incremented since the last time the cache was queried, the configs are loaded again from
+    /// disk.
+    ///
+    /// Returning `None` will always reload the cache.
+    ///
+    /// Default: Always returns `None`.
+    fn cache_generation(&self) -> Option<usize> {
+        None
+    }
+
+    /// Increment the cache generation of user and acl configs. This indicates that they were
+    /// changed on disk.
+    ///
+    /// Default: Does nothing.
+    fn increment_cache_generation(&self) -> Result<(), Error> {
+        Ok(())
+    }
+
     /// Optionally returns a role that has no access to any resource.
     ///
     /// Default: Returns `None`.
@@ -72,6 +106,14 @@ pub(crate) fn acl_config_lock() -> PathBuf {
     conf_dir().join(".acl.lck")
 }
 
+pub(crate) fn user_config() -> PathBuf {
+    conf_dir().join("user.cfg")
+}
+
+pub(crate) fn user_config_lock() -> PathBuf {
+    conf_dir().join(".user.lck")
+}
+
 pub(crate) fn token_shadow() -> PathBuf {
     conf_dir().join("token.shadow")
 }
diff --git a/proxmox-access-control/src/lib.rs b/proxmox-access-control/src/lib.rs
index 524b0e60..16132072 100644
--- a/proxmox-access-control/src/lib.rs
+++ b/proxmox-access-control/src/lib.rs
@@ -2,3 +2,7 @@ pub mod acl;
 pub mod init;
 pub mod token_shadow;
 pub mod types;
+pub mod user;
+
+mod cached_user_info;
+pub use cached_user_info::CachedUserInfo;
diff --git a/proxmox-access-control/src/user.rs b/proxmox-access-control/src/user.rs
new file mode 100644
index 00000000..fe5d6ff5
--- /dev/null
+++ b/proxmox-access-control/src/user.rs
@@ -0,0 +1,180 @@
+use std::collections::HashMap;
+use std::sync::{Arc, OnceLock, RwLock};
+
+use anyhow::{bail, Error};
+
+use proxmox_auth_api::types::Authid;
+use proxmox_product_config::{open_api_lockfile, replace_privileged_config, ApiLockGuard};
+use proxmox_schema::*;
+use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin};
+
+use crate::init::{access_conf, user_config, user_config_lock};
+use crate::types::{ApiToken, User};
+
+fn get_or_init_config() -> &'static SectionConfig {
+    static CONFIG: OnceLock<SectionConfig> = OnceLock::new();
+    CONFIG.get_or_init(|| {
+        let mut config = SectionConfig::new(&Authid::API_SCHEMA);
+
+        let user_schema = match User::API_SCHEMA {
+            Schema::Object(ref user_schema) => user_schema,
+            _ => unreachable!(),
+        };
+        let user_plugin =
+            SectionConfigPlugin::new("user".to_string(), Some("userid".to_string()), user_schema);
+        config.register_plugin(user_plugin);
+
+        let token_schema = match ApiToken::API_SCHEMA {
+            Schema::Object(ref token_schema) => token_schema,
+            _ => unreachable!(),
+        };
+        let token_plugin = SectionConfigPlugin::new(
+            "token".to_string(),
+            Some("tokenid".to_string()),
+            token_schema,
+        );
+        config.register_plugin(token_plugin);
+
+        config
+    })
+}
+
+/// Get exclusive lock
+pub fn lock_config() -> Result<ApiLockGuard, Error> {
+    open_api_lockfile(user_config_lock(), None, true)
+}
+
+pub fn config() -> Result<(SectionConfigData, [u8; 32]), Error> {
+    let content = proxmox_sys::fs::file_read_optional_string(user_config())?.unwrap_or_default();
+
+    let digest = openssl::sha::sha256(content.as_bytes());
+    let data = get_or_init_config().parse(user_config(), &content)?;
+
+    Ok((data, digest))
+}
+
+pub fn cached_config() -> Result<Arc<SectionConfigData>, Error> {
+    struct ConfigCache {
+        data: Option<Arc<SectionConfigData>>,
+        last_mtime: i64,
+        last_mtime_nsec: i64,
+    }
+
+    static CACHED_CONFIG: OnceLock<RwLock<ConfigCache>> = OnceLock::new();
+    let cached_config = CACHED_CONFIG.get_or_init(|| {
+        RwLock::new(ConfigCache {
+            data: None,
+            last_mtime: 0,
+            last_mtime_nsec: 0,
+        })
+    });
+
+    let stat = match nix::sys::stat::stat(&user_config()) {
+        Ok(stat) => Some(stat),
+        Err(nix::errno::Errno::ENOENT) => None,
+        Err(err) => bail!("unable to stat '{}' - {err}", user_config().display()),
+    };
+
+    {
+        // limit scope
+        let cache = cached_config.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_config.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)
+}
+
+pub fn save_config(config: &SectionConfigData) -> Result<(), Error> {
+    let config_file = user_config();
+    let raw = get_or_init_config().write(&config_file, config)?;
+    replace_privileged_config(config_file, raw.as_bytes())?;
+
+    // increase cache generation so we reload it next time we access it
+    access_conf().increment_cache_generation()?;
+
+    Ok(())
+}
+
+/// Only exposed for testing
+#[doc(hidden)]
+pub fn test_cfg_from_str(raw: &str) -> Result<(SectionConfigData, [u8; 32]), Error> {
+    let cfg = get_or_init_config();
+    let parsed = cfg.parse("test_user_cfg", raw)?;
+
+    Ok((parsed, [0; 32]))
+}
+
+// shell completion helper
+pub fn complete_userid(_arg: &str, _param: &HashMap<String, String>) -> Vec<String> {
+    match config() {
+        Ok((data, _digest)) => data
+            .sections
+            .iter()
+            .filter_map(|(id, (section_type, _))| {
+                if section_type == "user" {
+                    Some(id.to_string())
+                } else {
+                    None
+                }
+            })
+            .collect(),
+        Err(_) => Vec::new(),
+    }
+}
+
+// shell completion helper
+pub fn complete_authid(_arg: &str, _param: &HashMap<String, String>) -> Vec<String> {
+    match config() {
+        Ok((data, _digest)) => data.sections.keys().map(|id| id.to_string()).collect(),
+        Err(_) => vec![],
+    }
+}
+
+// shell completion helper
+pub fn complete_token_name(_arg: &str, param: &HashMap<String, String>) -> Vec<String> {
+    let data = match config() {
+        Ok((data, _digest)) => data,
+        Err(_) => return Vec::new(),
+    };
+
+    match param.get("userid") {
+        Some(userid) => {
+            let user = data.lookup::<User>("user", userid);
+            let tokens = data.convert_to_typed_array("token");
+            match (user, tokens) {
+                (Ok(_), Ok(tokens)) => tokens
+                    .into_iter()
+                    .filter_map(|token: ApiToken| {
+                        let tokenid = token.tokenid;
+                        if tokenid.is_token() && tokenid.user() == userid {
+                            Some(tokenid.tokenname().unwrap().as_str().to_string())
+                        } else {
+                            None
+                        }
+                    })
+                    .collect(),
+                _ => vec![],
+            }
+        }
+        None => vec![],
+    }
+}
-- 
2.39.2



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




  parent reply	other threads:[~2024-06-19  9:54 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 ` [pbs-devel] [PATCH proxmox v3 1/7] access-control: add the proxmox-access crate to reuse acl trees Shannon Sterz
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 ` Shannon Sterz [this message]
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-5-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.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal