From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from ronja.mits.lan by ronja.mits.lan with LMTP id QJJNK+KqcmbWLwAAxxbTJA (envelope-from ); Wed, 19 Jun 2024 11:54:42 +0200 Received: from proxmox-new.maurer-it.com (unknown [192.168.2.33]) by ronja.mits.lan (Postfix) with ESMTPS id 9C964F63D90; Wed, 19 Jun 2024 11:54:42 +0200 (CEST) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 813FB47C8B; Wed, 19 Jun 2024 11:54:42 +0200 (CEST) Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (4096 bits)) (No client certificate requested) by proxmox-new.maurer-it.com (Proxmox) with ESMTPS; Wed, 19 Jun 2024 11:54:40 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 6727A1816; Wed, 19 Jun 2024 11:54:39 +0200 (CEST) From: Shannon Sterz To: pbs-devel@lists.proxmox.com Date: Wed, 19 Jun 2024 11:54:15 +0200 Message-Id: <20240619095418.126368-5-s.sterz@proxmox.com> X-Mailer: git-send-email 2.39.2 In-Reply-To: <20240619095418.126368-1-s.sterz@proxmox.com> References: <20240619095418.126368-1-s.sterz@proxmox.com> MIME-Version: 1.0 Subject: [pbs-devel] [PATCH proxmox v3 4/7] access-control: factor out user config handling X-BeenThere: pbs-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Backup Server development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Backup Server development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pbs-devel-bounces@lists.proxmox.com Sender: "pbs-devel" X-SPAM-LEVEL: Spam detection results: 0 DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment KAM_LAZY_DOMAIN_SECURITY 1 Sending domain does not have any anti-forgery methods MAILING_LIST_MULTI -2 Multiple indicators imply a widely-seen list manager RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_NONE 0.001 SPF: sender does not publish an SPF Record T_SCC_BODY_TEXT_LINE -0.01 - 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 --- 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, + acl_tree: Arc, +} + +struct ConfigCache { + data: Option>, + last_update: i64, + last_user_cache_generation: usize, +} + +impl CachedUserInfo { + /// Returns a cached instance (up to 5 seconds old). + pub fn new() -> Result, Error> { + let now = epoch_i64(); + + let cache_generation = access_conf().cache_generation(); + + static CACHED_CONFIG: OnceLock> = 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", 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::("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 { + // 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::() { + 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 { + 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 = 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 { + 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, Error> { + struct ConfigCache { + data: Option>, + last_mtime: i64, + last_mtime_nsec: i64, + } + + static CACHED_CONFIG: OnceLock> = 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) -> Vec { + 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) -> Vec { + 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) -> Vec { + let data = match config() { + Ok((data, _digest)) => data, + Err(_) => return Vec::new(), + }; + + match param.get("userid") { + Some(userid) => { + let user = data.lookup::("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