* [PATCH proxmox v8 1/6] token shadow: split AccessControlConfig and add token.shadow generation
2026-04-09 15:54 [PATCH proxmox{,-datacenter-manager} v8 0/9] token-shadow: reduce api token verification overhead Samuel Rufinatscha
@ 2026-04-09 15:54 ` Samuel Rufinatscha
2026-04-09 15:54 ` [PATCH proxmox v8 2/6] token shadow: cache verified API token secrets Samuel Rufinatscha
` (7 subsequent siblings)
8 siblings, 0 replies; 10+ messages in thread
From: Samuel Rufinatscha @ 2026-04-09 15:54 UTC (permalink / raw)
To: pbs-devel
Splits implementation hooks from AccessControlConfig and introduces
AccessControlBackend to keep AccessControlConfig focused on ACL metadata and
validation.
Also introduces generation hooks in AccessControlBackend to support token.shadow
caching.
Signed-off-by: Samuel Rufinatscha <s.rufinatscha@proxmox.com>
---
Changes from v7 to v8:
* Split into AccessControlConfig + AccessControlBackend instead of
AccessControlConfig + AccessControlPermissions
* Gate AccessControlBackend behind #[cfg(feature = "impl")]
* Move init_user_config and cache_generation/increment_cache_generation
into AccessControlBackend
* Remove delegation methods on AccessControlConfig (no longer needed
with this split)
* Add init_separate() for cases where config and backend are different
objects; constrain init() with T: AccessControlConfig +
AccessControlBackend
* Callers use access_backend() instead of access_conf() for
cache/backend hooks
Changes from v6 to v7:
* Rebased
Changes from v5 to v6:
* Rebased
Changes from v4 to v5:
* Rebased
proxmox-access-control/src/acl.rs | 4 +-
.../src/cached_user_info.rs | 4 +-
proxmox-access-control/src/init.rs | 113 +++++++++++++-----
proxmox-access-control/src/lib.rs | 2 +-
proxmox-access-control/src/user.rs | 6 +-
5 files changed, 91 insertions(+), 38 deletions(-)
diff --git a/proxmox-access-control/src/acl.rs b/proxmox-access-control/src/acl.rs
index 38cb7edf..e4c35e02 100644
--- a/proxmox-access-control/src/acl.rs
+++ b/proxmox-access-control/src/acl.rs
@@ -660,7 +660,7 @@ mod impl_feature {
use proxmox_product_config::{open_api_lockfile, replace_privileged_config, ApiLockGuard};
use crate::acl::AclTree;
- use crate::init::access_conf;
+ use crate::init::access_backend;
use crate::init::impl_feature::{acl_config, acl_config_lock};
/// Get exclusive lock
@@ -741,7 +741,7 @@ mod impl_feature {
replace_privileged_config(conf, &raw)?;
// increase cache generation so we reload it next time we access it
- access_conf().increment_cache_generation()?;
+ access_backend().increment_cache_generation()?;
Ok(())
}
diff --git a/proxmox-access-control/src/cached_user_info.rs b/proxmox-access-control/src/cached_user_info.rs
index 8db37727..81df561f 100644
--- a/proxmox-access-control/src/cached_user_info.rs
+++ b/proxmox-access-control/src/cached_user_info.rs
@@ -10,7 +10,7 @@ use proxmox_section_config::SectionConfigData;
use proxmox_time::epoch_i64;
use crate::acl::AclTree;
-use crate::init::access_conf;
+use crate::init::{access_backend, access_conf};
use crate::types::{ApiToken, User};
/// Cache User/Group/Token/Acl configuration data for fast permission tests
@@ -30,7 +30,7 @@ impl CachedUserInfo {
pub fn new() -> Result<Arc<Self>, Error> {
let now = epoch_i64();
- let cache_generation = access_conf().cache_generation();
+ let cache_generation = access_backend().cache_generation();
static CACHED_CONFIG: OnceLock<RwLock<ConfigCache>> = OnceLock::new();
let cached_config = CACHED_CONFIG.get_or_init(|| {
diff --git a/proxmox-access-control/src/init.rs b/proxmox-access-control/src/init.rs
index e64398e8..07b5df11 100644
--- a/proxmox-access-control/src/init.rs
+++ b/proxmox-access-control/src/init.rs
@@ -7,6 +7,8 @@ use proxmox_auth_api::types::{Authid, Userid};
use proxmox_section_config::SectionConfigData;
static ACCESS_CONF: OnceLock<&'static dyn AccessControlConfig> = OnceLock::new();
+#[cfg(feature = "impl")]
+static ACCESS_BACKEND: OnceLock<&'static dyn AccessControlBackend> = OnceLock::new();
/// This trait specifies the functions a product needs to implement to get ACL tree based access
/// control management from this plugin.
@@ -32,25 +34,6 @@ pub trait AccessControlConfig: Send + Sync {
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`.
@@ -65,13 +48,6 @@ pub trait AccessControlConfig: Send + Sync {
None
}
- /// Called after the user configuration is loaded to potentially re-add fixed users, such as a
- /// `root@pam` user.
- fn init_user_config(&self, config: &mut SectionConfigData) -> Result<(), Error> {
- 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.
@@ -103,6 +79,53 @@ pub trait AccessControlConfig: Send + Sync {
}
}
+/// Backend hooks for loading and caching access control state.
+#[cfg(feature = "impl")]
+pub trait AccessControlBackend: Send + Sync {
+ /// Called after the user configuration is loaded to potentially re-add fixed users, such as a
+ /// `root@pam` user.
+ fn init_user_config(&self, config: &mut SectionConfigData) -> Result<(), Error> {
+ let _ = config;
+ Ok(())
+ }
+
+ /// 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(())
+ }
+
+ /// Returns the current cache generation of the token shadow cache. If the generation was
+ /// incremented since the last time the cache was queried, the token shadow cache is reloaded
+ /// from disk.
+ ///
+ /// Default: Always returns `None`.
+ fn token_shadow_cache_generation(&self) -> Option<usize> {
+ None
+ }
+
+ /// Increment the cache generation of the token shadow cache and return the previous value.
+ /// This indicates that it was changed on disk.
+ ///
+ /// Default: Returns an error as token shadow generation is not supported.
+ fn increment_token_shadow_cache_generation(&self) -> Result<usize, Error> {
+ anyhow::bail!("token shadow generation not supported");
+ }
+}
+
pub fn init_access_config(config: &'static dyn AccessControlConfig) -> Result<(), Error> {
ACCESS_CONF
.set(config)
@@ -115,8 +138,24 @@ pub(crate) fn access_conf() -> &'static dyn AccessControlConfig {
.expect("please initialize the acm config before using it!")
}
+#[cfg(feature = "impl")]
+pub fn init_access_backend(config: &'static dyn AccessControlBackend) -> Result<(), Error> {
+ ACCESS_BACKEND
+ .set(config)
+ .map_err(|_| format_err!("cannot initialize access control backend twice!"))
+}
+
+#[cfg(feature = "impl")]
+pub(crate) fn access_backend() -> &'static dyn AccessControlBackend {
+ *ACCESS_BACKEND
+ .get()
+ .expect("please initialize the access control backend before using it!")
+}
+
#[cfg(feature = "impl")]
pub use impl_feature::init;
+#[cfg(feature = "impl")]
+pub use impl_feature::init_separate;
#[cfg(feature = "impl")]
pub(crate) mod impl_feature {
@@ -125,15 +164,29 @@ pub(crate) mod impl_feature {
use anyhow::{format_err, Error};
- use crate::init::{init_access_config, AccessControlConfig};
+ use crate::init::{
+ init_access_backend, init_access_config, AccessControlBackend, AccessControlConfig,
+ };
static ACCESS_CONF_DIR: OnceLock<PathBuf> = OnceLock::new();
- pub fn init<P: AsRef<Path>>(
- acm_config: &'static dyn AccessControlConfig,
+ pub fn init<T, P>(config: &'static T, config_dir: P) -> Result<(), Error>
+ where
+ T: AccessControlConfig + AccessControlBackend,
+ P: AsRef<Path>,
+ {
+ init_access_config(config)?;
+ init_access_backend(config)?;
+ init_access_config_dir(config_dir)
+ }
+
+ pub fn init_separate<P: AsRef<Path>>(
+ acl_config: &'static dyn AccessControlConfig,
+ backend: &'static dyn AccessControlBackend,
config_dir: P,
) -> Result<(), Error> {
- init_access_config(acm_config)?;
+ init_access_config(acl_config)?;
+ init_access_backend(backend)?;
init_access_config_dir(config_dir)
}
diff --git a/proxmox-access-control/src/lib.rs b/proxmox-access-control/src/lib.rs
index 9195c999..dc17da2a 100644
--- a/proxmox-access-control/src/lib.rs
+++ b/proxmox-access-control/src/lib.rs
@@ -8,7 +8,7 @@ pub mod acl;
#[cfg(feature = "api")]
pub mod api;
-#[cfg(feature = "acl")]
+#[cfg(any(feature = "acl", feature = "impl"))]
pub mod init;
#[cfg(feature = "impl")]
diff --git a/proxmox-access-control/src/user.rs b/proxmox-access-control/src/user.rs
index a4b59edc..ec5336d2 100644
--- a/proxmox-access-control/src/user.rs
+++ b/proxmox-access-control/src/user.rs
@@ -9,7 +9,7 @@ use proxmox_product_config::{open_api_lockfile, replace_privileged_config, ApiLo
use proxmox_schema::*;
use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin};
-use crate::init::access_conf;
+use crate::init::access_backend;
use crate::init::impl_feature::{user_config, user_config_lock};
use crate::types::{ApiToken, User};
@@ -52,7 +52,7 @@ pub fn config() -> Result<(SectionConfigData, ConfigDigest), Error> {
let digest = ConfigDigest::from_slice(content.as_bytes());
let mut data = get_or_init_config().parse(user_config(), &content)?;
- access_conf().init_user_config(&mut data)?;
+ access_backend().init_user_config(&mut data)?;
Ok((data, digest))
}
@@ -113,7 +113,7 @@ pub fn save_config(config: &SectionConfigData) -> Result<(), Error> {
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()?;
+ access_backend().increment_cache_generation()?;
Ok(())
}
--
2.47.3
^ permalink raw reply [flat|nested] 10+ messages in thread* [PATCH proxmox v8 2/6] token shadow: cache verified API token secrets
2026-04-09 15:54 [PATCH proxmox{,-datacenter-manager} v8 0/9] token-shadow: reduce api token verification overhead Samuel Rufinatscha
2026-04-09 15:54 ` [PATCH proxmox v8 1/6] token shadow: split AccessControlConfig and add token.shadow generation Samuel Rufinatscha
@ 2026-04-09 15:54 ` Samuel Rufinatscha
2026-04-09 15:54 ` [PATCH proxmox v8 3/6] token shadow: invalidate token-secret cache on token.shadow changes Samuel Rufinatscha
` (6 subsequent siblings)
8 siblings, 0 replies; 10+ messages in thread
From: Samuel Rufinatscha @ 2026-04-09 15:54 UTC (permalink / raw)
To: pbs-devel
Adds an in-memory cache of successfully verified token secrets.
Subsequent requests for the same token+secret combination only perform
a comparison using openssl::memcmp::eq and avoid re-running the
password hash. The cache is updated when a token secret is set and
cleared when a token is deleted. A shared generation counter (via
ConfigVersionCache) is used to invalidate caches across processes when
token secrets are modified or deleted. This keeps privileged and
unprivileged daemons in sync.
Signed-off-by: Samuel Rufinatscha <s.rufinatscha@proxmox.com>
---
Changes from v7 to v8:
* Rename shared_gen -> cached_gen
* Rename token_shadow_shared_gen() -> token_shadow_generation()
* Rename bump_token_shadow_shared_gen() -> bump_token_shadow_generation()
* Use access_backend() instead of access_conf() for generation hooks
Changes from v6 to v7:
* Rebased
* Rename "gen" variables to be compatible with Rust 2024 keyword
changes
Changes from v5 to v6:
* Rebased
* Check that the input byte lengths are equal before calling
openssl::memcmp::eq(..).
Changes from v4 to v5:
* Rebased
* Fix wrong type compilation issue; replaced with ApiLockGuard
* Move invalidate_cache_state_and_set_gen into cache object impl
rename to reset_and_set_gen
* Add additional insert/remove helpers which set/update the generation
directly
* Clarified the usage of shared generation counter in the commit
message
Changes from v3 to v4:
* Add gen param to invalidate_cache_state()
* Validates the generation bump after obtaining write lock in
apply_api_mutation
* Pass lock to apply_api_mutation
* Remove unnecessary gen check cache_try_secret_matches
* Adjusted commit message
Changes from v2 to v3:
* Replaced process-local cache invalidation (AtomicU64
API_MUTATION_GENERATION) with a cross-process shared generation via
ConfigVersionCache.
* Validate shared generation before/after the constant-time secret
compare; only insert into cache if the generation is unchanged.
* invalidate_cache_state() on insert if shared generation changed.
Changes from v1 to v2:
* Replace OnceCell with LazyLock, and std::sync::RwLock with
parking_lot::RwLock.
* Add API_MUTATION_GENERATION and guard cache inserts
to prevent “zombie inserts” across concurrent set/delete.
* Refactor cache operations into cache_try_secret_matches,
cache_try_insert_secret, and centralize write-side behavior in
apply_api_mutation.
* Switch fast-path cache access to try_read/try_write (best-effort).
Cargo.toml | 1 +
proxmox-access-control/Cargo.toml | 1 +
proxmox-access-control/src/token_shadow.rs | 170 ++++++++++++++++++++-
3 files changed, 169 insertions(+), 3 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index 02ff7f81..cf55653f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -115,6 +115,7 @@ native-tls = "0.2"
nix = "0.29"
openssl = "0.10"
pam-sys = "0.5"
+parking_lot = "0.12"
percent-encoding = "2.1"
pin-utils = "0.1.0"
proc-macro2 = "1.0"
diff --git a/proxmox-access-control/Cargo.toml b/proxmox-access-control/Cargo.toml
index ec189664..1de2842c 100644
--- a/proxmox-access-control/Cargo.toml
+++ b/proxmox-access-control/Cargo.toml
@@ -16,6 +16,7 @@ anyhow.workspace = true
const_format.workspace = true
nix = { workspace = true, optional = true }
openssl = { workspace = true, optional = true }
+parking_lot.workspace = true
regex.workspace = true
hex = { workspace = true, optional = true }
serde.workspace = true
diff --git a/proxmox-access-control/src/token_shadow.rs b/proxmox-access-control/src/token_shadow.rs
index c586d834..d0bf43d7 100644
--- a/proxmox-access-control/src/token_shadow.rs
+++ b/proxmox-access-control/src/token_shadow.rs
@@ -1,13 +1,28 @@
use std::collections::HashMap;
+use std::sync::LazyLock;
use anyhow::{bail, format_err, Error};
+use parking_lot::RwLock;
use serde_json::{from_value, Value};
use proxmox_auth_api::types::Authid;
use proxmox_product_config::{open_api_lockfile, replace_config, ApiLockGuard};
+use crate::init::access_backend;
use crate::init::impl_feature::{token_shadow, token_shadow_lock};
+/// Global in-memory cache for successfully verified API token secrets.
+/// The cache stores plain text secrets for token Authids that have already been
+/// verified against the hashed values in `token.shadow`. This allows for cheap
+/// subsequent authentications for the same token+secret combination, avoiding
+/// recomputing the password hash on every request.
+static TOKEN_SECRET_CACHE: LazyLock<RwLock<ApiTokenSecretCache>> = LazyLock::new(|| {
+ RwLock::new(ApiTokenSecretCache {
+ secrets: HashMap::new(),
+ cached_gen: 0,
+ })
+});
+
// Get exclusive lock
fn lock_config() -> Result<ApiLockGuard, Error> {
open_api_lockfile(token_shadow_lock(), None, true)
@@ -36,9 +51,27 @@ pub fn verify_secret(tokenid: &Authid, secret: &str) -> Result<(), Error> {
bail!("not an API token ID");
}
+ // Fast path
+ if cache_try_secret_matches(tokenid, secret) {
+ return Ok(());
+ }
+
+ // Slow path
+ // First, capture the generation before doing the hash verification.
+ let gen_before = token_shadow_generation();
+
let data = read_file()?;
match data.get(tokenid) {
- Some(hashed_secret) => proxmox_sys::crypt::verify_crypt_pw(secret, hashed_secret),
+ Some(hashed_secret) => {
+ proxmox_sys::crypt::verify_crypt_pw(secret, hashed_secret)?;
+
+ // Try to cache only if nothing changed while verifying the secret.
+ if let Some(gen_before) = gen_before {
+ cache_try_insert_secret(tokenid.clone(), secret.to_owned(), gen_before);
+ }
+
+ Ok(())
+ }
None => bail!("invalid API token"),
}
}
@@ -49,13 +82,15 @@ pub fn set_secret(tokenid: &Authid, secret: &str) -> Result<(), Error> {
bail!("not an API token ID");
}
- let _guard = lock_config()?;
+ let guard = lock_config()?;
let mut data = read_file()?;
let hashed_secret = proxmox_sys::crypt::encrypt_pw(secret)?;
data.insert(tokenid.clone(), hashed_secret);
write_file(data)?;
+ apply_api_mutation(guard, tokenid, Some(secret));
+
Ok(())
}
@@ -65,12 +100,14 @@ pub fn delete_secret(tokenid: &Authid) -> Result<(), Error> {
bail!("not an API token ID");
}
- let _guard = lock_config()?;
+ let guard = lock_config()?;
let mut data = read_file()?;
data.remove(tokenid);
write_file(data)?;
+ apply_api_mutation(guard, tokenid, None);
+
Ok(())
}
@@ -81,3 +118,130 @@ pub fn generate_and_set_secret(tokenid: &Authid) -> Result<String, Error> {
set_secret(tokenid, &secret)?;
Ok(secret)
}
+
+/// Cached secret.
+struct CachedSecret {
+ secret: String,
+}
+
+struct ApiTokenSecretCache {
+ /// Keys are token Authids, values are the corresponding plain text secrets.
+ /// Entries are added after a successful on-disk verification in
+ /// `verify_secret` or when a new token secret is generated by
+ /// `generate_and_set_secret`. Used to avoid repeated
+ /// password-hash computation on subsequent authentications.
+ secrets: HashMap<Authid, CachedSecret>,
+ /// token.shadow generation of cached secrets.
+ cached_gen: usize,
+}
+
+impl ApiTokenSecretCache {
+ /// Resets all local cache contents and sets/updates the cached generation.
+ fn reset_and_set_gen(&mut self, new_gen: usize) {
+ self.secrets.clear();
+ self.cached_gen = new_gen;
+ }
+
+ /// Caches a secret and sets/updates the cache generation.
+ fn insert_and_set_gen(&mut self, tokenid: Authid, secret: CachedSecret, new_gen: usize) {
+ self.secrets.insert(tokenid, secret);
+ self.cached_gen = new_gen;
+ }
+
+ /// Evicts a cached secret and sets/updates the cached generation.
+ fn evict_and_set_gen(&mut self, tokenid: &Authid, new_gen: usize) {
+ self.secrets.remove(tokenid);
+ self.cached_gen = new_gen;
+ }
+}
+
+fn cache_try_insert_secret(tokenid: Authid, secret: String, gen_before: usize) {
+ let Some(mut cache) = TOKEN_SECRET_CACHE.try_write() else {
+ return;
+ };
+
+ let Some(gen_now) = token_shadow_generation() else {
+ return;
+ };
+
+ // If this process missed a generation bump, its cache is stale.
+ if cache.cached_gen != gen_now {
+ cache.reset_and_set_gen(gen_now);
+ }
+
+ // If a mutation happened while we were verifying the secret, do not insert.
+ if gen_now == gen_before {
+ cache.insert_and_set_gen(tokenid, CachedSecret { secret }, gen_now);
+ }
+}
+
+/// Tries to match the given token secret against the cached secret.
+///
+/// Verifies the generation/version before doing the constant-time
+/// comparison to reduce TOCTOU risk. During token rotation or deletion
+/// tokens for in-flight requests may still validate against the previous
+/// generation.
+fn cache_try_secret_matches(tokenid: &Authid, secret: &str) -> bool {
+ let Some(cache) = TOKEN_SECRET_CACHE.try_read() else {
+ return false;
+ };
+ let Some(entry) = cache.secrets.get(tokenid) else {
+ return false;
+ };
+ let Some(current_gen) = token_shadow_generation() else {
+ return false;
+ };
+
+ if current_gen == cache.cached_gen {
+ let cached_secret_bytes = entry.secret.as_bytes();
+ let secret_bytes = secret.as_bytes();
+
+ return cached_secret_bytes.len() == secret_bytes.len()
+ && openssl::memcmp::eq(cached_secret_bytes, secret_bytes);
+ }
+
+ false
+}
+
+fn apply_api_mutation(_guard: ApiLockGuard, tokenid: &Authid, secret: Option<&str>) {
+ // Signal cache invalidation to other processes (best-effort).
+ let bumped_gen = bump_token_shadow_generation();
+ let mut cache = TOKEN_SECRET_CACHE.write();
+
+ // If we cannot get the current generation, we cannot trust the cache
+ let Some(current_gen) = token_shadow_generation() else {
+ cache.reset_and_set_gen(0);
+ return;
+ };
+
+ // If we cannot bump the generation, or if it changed after
+ // obtaining the cache write lock, we cannot trust the cache
+ if bumped_gen != Some(current_gen) {
+ cache.reset_and_set_gen(current_gen);
+ return;
+ }
+
+ // Apply the new mutation.
+ match secret {
+ Some(secret) => {
+ let cached_secret = CachedSecret {
+ secret: secret.to_owned(),
+ };
+ cache.insert_and_set_gen(tokenid.clone(), cached_secret, current_gen);
+ }
+ None => cache.evict_and_set_gen(tokenid, current_gen),
+ }
+}
+
+/// Get the current generation.
+fn token_shadow_generation() -> Option<usize> {
+ access_backend().token_shadow_cache_generation()
+}
+
+/// Bump and return the new generation.
+fn bump_token_shadow_generation() -> Option<usize> {
+ access_backend()
+ .increment_token_shadow_cache_generation()
+ .ok()
+ .map(|prev| prev + 1)
+}
--
2.47.3
^ permalink raw reply [flat|nested] 10+ messages in thread* [PATCH proxmox v8 3/6] token shadow: invalidate token-secret cache on token.shadow changes
2026-04-09 15:54 [PATCH proxmox{,-datacenter-manager} v8 0/9] token-shadow: reduce api token verification overhead Samuel Rufinatscha
2026-04-09 15:54 ` [PATCH proxmox v8 1/6] token shadow: split AccessControlConfig and add token.shadow generation Samuel Rufinatscha
2026-04-09 15:54 ` [PATCH proxmox v8 2/6] token shadow: cache verified API token secrets Samuel Rufinatscha
@ 2026-04-09 15:54 ` Samuel Rufinatscha
2026-04-09 15:54 ` [PATCH proxmox v8 4/6] token shadow: add TTL window to token secret cache Samuel Rufinatscha
` (5 subsequent siblings)
8 siblings, 0 replies; 10+ messages in thread
From: Samuel Rufinatscha @ 2026-04-09 15:54 UTC (permalink / raw)
To: pbs-devel
This patch adds manual/direct file change detection by tracking the
mtime and length of token.shadow and clears the in-memory token secret
cache whenever these values change.
Signed-off-by: Samuel Rufinatscha <s.rufinatscha@proxmox.com>
---
Changes from v7 to v8:
* Merge refresh_cache_if_file_changed() and cache_try_secret_matches()
into a single cached_secret_valid() function
* Move secret comparison logic into secret_matches() method on
ApiTokenSecretCache
* Rename shadow field -> file_info
Changes from v6 to v7:
* Rebased
Changes from v5 to v6:
* Rebased
Changes from v4 to v5:
* Rebased
Changes from v3 to v4:
* make use of .replace() in refresh_cache_if_file_changed to get
previous state
* Group file stats with ShadowFileInfo
* Return false in refresh_cache_if_file_changed to avoid unnecessary cache
queries
* Adjusted commit message
Changes from v2 to v3:
* Cache now tracks last_checked (epoch seconds).
* Simplified refresh_cache_if_file_changed, removed
FILE_GENERATION logic
* On first load, initializes file metadata and keeps empty cache.
Changes from v1 to v2:
* Add file metadata tracking (file_mtime, file_len) and
FILE_GENERATION.
* Store file_gen in CachedSecret and verify it against the current
FILE_GENERATION to ensure cached entries belong to the current file
state.
* Add shadow_mtime_len() helper and convert refresh to best-effort
(try_write, returns bool).
* Pass a pre-write metadata snapshot into apply_api_mutation and
clear/bump generation if the cache metadata indicates missed external
edits.
proxmox-access-control/src/token_shadow.rs | 167 +++++++++++++++++----
1 file changed, 136 insertions(+), 31 deletions(-)
diff --git a/proxmox-access-control/src/token_shadow.rs b/proxmox-access-control/src/token_shadow.rs
index d0bf43d7..810ff0c3 100644
--- a/proxmox-access-control/src/token_shadow.rs
+++ b/proxmox-access-control/src/token_shadow.rs
@@ -1,5 +1,8 @@
use std::collections::HashMap;
+use std::fs;
+use std::io::ErrorKind;
use std::sync::LazyLock;
+use std::time::SystemTime;
use anyhow::{bail, format_err, Error};
use parking_lot::RwLock;
@@ -7,6 +10,7 @@ use serde_json::{from_value, Value};
use proxmox_auth_api::types::Authid;
use proxmox_product_config::{open_api_lockfile, replace_config, ApiLockGuard};
+use proxmox_time::epoch_i64;
use crate::init::access_backend;
use crate::init::impl_feature::{token_shadow, token_shadow_lock};
@@ -20,6 +24,7 @@ static TOKEN_SECRET_CACHE: LazyLock<RwLock<ApiTokenSecretCache>> = LazyLock::new
RwLock::new(ApiTokenSecretCache {
secrets: HashMap::new(),
cached_gen: 0,
+ file_info: None,
})
});
@@ -45,6 +50,62 @@ fn write_file(data: HashMap<Authid, String>) -> Result<(), Error> {
replace_config(token_shadow(), &json)
}
+/// Tries to match the given token secret against the cached secret.
+///
+/// Verifies the generation/version before doing the constant-time
+/// comparison to reduce TOCTOU risk. During token rotation or deletion
+/// tokens for in-flight requests may still validate against the previous
+/// generation.
+///
+/// Returns true if secret is cached and cache is still valid
+fn cached_secret_valid(tokenid: &Authid, secret: &str) -> bool {
+ let now = epoch_i64();
+
+ // Best-effort refresh under write lock.
+ let Some(mut cache) = TOKEN_SECRET_CACHE.try_write() else {
+ return false;
+ };
+
+ let Some(current_gen) = token_shadow_generation() else {
+ return false;
+ };
+
+ // If another process bumped the generation, we don't know what changed -> clear cache
+ if cache.cached_gen != current_gen {
+ cache.reset_and_set_gen(current_gen);
+ }
+
+ // Stat the file to detect manual edits.
+ let Ok((new_mtime, new_len)) = shadow_mtime_len() else {
+ return false;
+ };
+
+ // If the file didn't change, only update last_checked
+ if let Some(shadow) = cache.file_info.as_mut() {
+ if shadow.mtime == new_mtime && shadow.len == new_len {
+ shadow.last_checked = now;
+ return cache.secret_matches(tokenid, secret);
+ }
+ }
+
+ cache.secrets.clear();
+
+ let prev = cache.file_info.replace(ShadowFileInfo {
+ mtime: new_mtime,
+ len: new_len,
+ last_checked: now,
+ });
+
+ if prev.is_some() {
+ // Best-effort propagation to other processes if a change was detected
+ if let Some(new_gen) = bump_token_shadow_generation() {
+ cache.cached_gen = new_gen;
+ }
+ }
+
+ false
+}
+
/// Verifies that an entry for given tokenid / API token secret exists
pub fn verify_secret(tokenid: &Authid, secret: &str) -> Result<(), Error> {
if !tokenid.is_token() {
@@ -52,7 +113,7 @@ pub fn verify_secret(tokenid: &Authid, secret: &str) -> Result<(), Error> {
}
// Fast path
- if cache_try_secret_matches(tokenid, secret) {
+ if cached_secret_valid(tokenid, secret) {
return Ok(());
}
@@ -84,12 +145,15 @@ pub fn set_secret(tokenid: &Authid, secret: &str) -> Result<(), Error> {
let guard = lock_config()?;
+ // Capture state before we write to detect external edits.
+ let pre_meta = shadow_mtime_len().unwrap_or((None, None));
+
let mut data = read_file()?;
let hashed_secret = proxmox_sys::crypt::encrypt_pw(secret)?;
data.insert(tokenid.clone(), hashed_secret);
write_file(data)?;
- apply_api_mutation(guard, tokenid, Some(secret));
+ apply_api_mutation(guard, tokenid, Some(secret), pre_meta);
Ok(())
}
@@ -102,11 +166,14 @@ pub fn delete_secret(tokenid: &Authid) -> Result<(), Error> {
let guard = lock_config()?;
+ // Capture state before we write to detect external edits.
+ let pre_meta = shadow_mtime_len().unwrap_or((None, None));
+
let mut data = read_file()?;
data.remove(tokenid);
write_file(data)?;
- apply_api_mutation(guard, tokenid, None);
+ apply_api_mutation(guard, tokenid, None, pre_meta);
Ok(())
}
@@ -133,6 +200,8 @@ struct ApiTokenSecretCache {
secrets: HashMap<Authid, CachedSecret>,
/// token.shadow generation of cached secrets.
cached_gen: usize,
+ /// Shadow file info to detect changes
+ file_info: Option<ShadowFileInfo>,
}
impl ApiTokenSecretCache {
@@ -140,6 +209,7 @@ impl ApiTokenSecretCache {
fn reset_and_set_gen(&mut self, new_gen: usize) {
self.secrets.clear();
self.cached_gen = new_gen;
+ self.file_info = None;
}
/// Caches a secret and sets/updates the cache generation.
@@ -153,6 +223,28 @@ impl ApiTokenSecretCache {
self.secrets.remove(tokenid);
self.cached_gen = new_gen;
}
+
+ /// Returns true if there is a matching cached entry
+ fn secret_matches(&self, tokenid: &Authid, secret: &str) -> bool {
+ let Some(entry) = self.secrets.get(tokenid) else {
+ return false;
+ };
+ let cached_secret_bytes = entry.secret.as_bytes();
+ let secret_bytes = secret.as_bytes();
+
+ cached_secret_bytes.len() == secret_bytes.len()
+ && openssl::memcmp::eq(cached_secret_bytes, secret_bytes)
+ }
+}
+
+/// Shadow file info
+struct ShadowFileInfo {
+ // shadow file mtime to detect changes
+ mtime: Option<SystemTime>,
+ // shadow file length to detect changes
+ len: Option<u64>,
+ // last time the file metadata was checked
+ last_checked: i64,
}
fn cache_try_insert_secret(tokenid: Authid, secret: String, gen_before: usize) {
@@ -175,35 +267,14 @@ fn cache_try_insert_secret(tokenid: Authid, secret: String, gen_before: usize) {
}
}
-/// Tries to match the given token secret against the cached secret.
-///
-/// Verifies the generation/version before doing the constant-time
-/// comparison to reduce TOCTOU risk. During token rotation or deletion
-/// tokens for in-flight requests may still validate against the previous
-/// generation.
-fn cache_try_secret_matches(tokenid: &Authid, secret: &str) -> bool {
- let Some(cache) = TOKEN_SECRET_CACHE.try_read() else {
- return false;
- };
- let Some(entry) = cache.secrets.get(tokenid) else {
- return false;
- };
- let Some(current_gen) = token_shadow_generation() else {
- return false;
- };
-
- if current_gen == cache.cached_gen {
- let cached_secret_bytes = entry.secret.as_bytes();
- let secret_bytes = secret.as_bytes();
-
- return cached_secret_bytes.len() == secret_bytes.len()
- && openssl::memcmp::eq(cached_secret_bytes, secret_bytes);
- }
-
- false
-}
+fn apply_api_mutation(
+ _guard: ApiLockGuard,
+ tokenid: &Authid,
+ secret: Option<&str>,
+ pre_write_meta: (Option<SystemTime>, Option<u64>),
+) {
+ let now = epoch_i64();
-fn apply_api_mutation(_guard: ApiLockGuard, tokenid: &Authid, secret: Option<&str>) {
// Signal cache invalidation to other processes (best-effort).
let bumped_gen = bump_token_shadow_generation();
let mut cache = TOKEN_SECRET_CACHE.write();
@@ -221,6 +292,16 @@ fn apply_api_mutation(_guard: ApiLockGuard, tokenid: &Authid, secret: Option<&st
return;
}
+ // If our cached file metadata does not match the on-disk state before our write,
+ // we likely missed an external/manual edit. We can no longer trust any cached secrets.
+ if cache
+ .file_info
+ .as_ref()
+ .is_some_and(|s| (s.mtime, s.len) != pre_write_meta)
+ {
+ cache.secrets.clear();
+ }
+
// Apply the new mutation.
match secret {
Some(secret) => {
@@ -231,6 +312,22 @@ fn apply_api_mutation(_guard: ApiLockGuard, tokenid: &Authid, secret: Option<&st
}
None => cache.evict_and_set_gen(tokenid, current_gen),
}
+
+ // Update our view of the file metadata to the post-write state (best-effort).
+ // (If this fails, drop local cache so callers fall back to slow path until refreshed.)
+ match shadow_mtime_len() {
+ Ok((mtime, len)) => {
+ cache.file_info = Some(ShadowFileInfo {
+ mtime,
+ len,
+ last_checked: now,
+ });
+ }
+ Err(_) => {
+ // If we cannot validate state, do not trust cache.
+ cache.reset_and_set_gen(current_gen);
+ }
+ }
}
/// Get the current generation.
@@ -245,3 +342,11 @@ fn bump_token_shadow_generation() -> Option<usize> {
.ok()
.map(|prev| prev + 1)
}
+
+fn shadow_mtime_len() -> Result<(Option<SystemTime>, Option<u64>), Error> {
+ match fs::metadata(token_shadow()) {
+ Ok(meta) => Ok((meta.modified().ok(), Some(meta.len()))),
+ Err(e) if e.kind() == ErrorKind::NotFound => Ok((None, None)),
+ Err(e) => Err(e.into()),
+ }
+}
--
2.47.3
^ permalink raw reply [flat|nested] 10+ messages in thread* [PATCH proxmox v8 4/6] token shadow: add TTL window to token secret cache
2026-04-09 15:54 [PATCH proxmox{,-datacenter-manager} v8 0/9] token-shadow: reduce api token verification overhead Samuel Rufinatscha
` (2 preceding siblings ...)
2026-04-09 15:54 ` [PATCH proxmox v8 3/6] token shadow: invalidate token-secret cache on token.shadow changes Samuel Rufinatscha
@ 2026-04-09 15:54 ` Samuel Rufinatscha
2026-04-09 15:54 ` [PATCH proxmox v8 5/6] token shadow: inline set_secret fn Samuel Rufinatscha
` (4 subsequent siblings)
8 siblings, 0 replies; 10+ messages in thread
From: Samuel Rufinatscha @ 2026-04-09 15:54 UTC (permalink / raw)
To: pbs-devel
cached_secret_valid() currently stats the file on every request, which
performs a metadata() call on token.shadow each time. Under load this
adds unnecessary overhead, considering also the file usually should
rarely change.
This patch introduces a TTL boundary, controlled by
TOKEN_SECRET_CACHE_TTL_SECS. File metadata is only re-loaded once the
TTL has expired.
Signed-off-by: Samuel Rufinatscha <s.rufinatscha@proxmox.com>
---
Changes from v7 to v8:
* TTL fast path (read lock) and write-lock re-check now return
cache.secret_matches(tokenid, secret) instead of just true, following
the cached_secret_valid() merge from the previous patch
* Adjusted commit message
Changes from v6 to v7:
* Rebased
Changes from v5 to v6:
* Rebased
Changes from v4 to v5:
* Rebased
* Introduce shadow_check_within_ttl() helper
Changes from v3 to v4:
* Adjusted commit message
Changes from v2 to v3:
* Refactored refresh_cache_if_file_changed TTL logic.
* Remove had_prior_state check (replaced by last_checked logic).
* Improve TTL bound checks.
* Reword documentation warning for clarity.
Changes from v1 to v2:
* Add TOKEN_SECRET_CACHE_TTL_SECS and last_checked.
* Implement double-checked TTL: check with try_read first; only attempt
refresh with try_write if expired/unknown.
* Fix TTL bookkeeping: update last_checked on the “file unchanged” path
and after API mutations.
* Add documentation warning about TTL-delayed effect of manual
token.shadow edits.
proxmox-access-control/src/token_shadow.rs | 33 +++++++++++++++++++++-
1 file changed, 32 insertions(+), 1 deletion(-)
diff --git a/proxmox-access-control/src/token_shadow.rs b/proxmox-access-control/src/token_shadow.rs
index 810ff0c3..4185351e 100644
--- a/proxmox-access-control/src/token_shadow.rs
+++ b/proxmox-access-control/src/token_shadow.rs
@@ -27,6 +27,8 @@ static TOKEN_SECRET_CACHE: LazyLock<RwLock<ApiTokenSecretCache>> = LazyLock::new
file_info: None,
})
});
+/// Max age in seconds of the token secret cache before checking for file changes.
+const TOKEN_SECRET_CACHE_TTL_SECS: i64 = 60;
// Get exclusive lock
fn lock_config() -> Result<ApiLockGuard, Error> {
@@ -57,15 +59,31 @@ fn write_file(data: HashMap<Authid, String>) -> Result<(), Error> {
/// tokens for in-flight requests may still validate against the previous
/// generation.
///
+/// If the cache file metadata's TTL has expired, will revalidate and invalidate the cache if
+/// needed.
+///
/// Returns true if secret is cached and cache is still valid
fn cached_secret_valid(tokenid: &Authid, secret: &str) -> bool {
let now = epoch_i64();
- // Best-effort refresh under write lock.
+ // Fast path: cache is fresh if generation matches and TTL not expired.
+ if let (Some(cache), Some(read_gen)) =
+ (TOKEN_SECRET_CACHE.try_read(), token_shadow_generation())
+ {
+ if cache.cached_gen == read_gen && cache.shadow_check_within_ttl(now) {
+ return cache.secret_matches(tokenid, secret);
+ }
+ // read lock drops here
+ } else {
+ return false;
+ }
+
+ // Slow path: best-effort refresh under write lock.
let Some(mut cache) = TOKEN_SECRET_CACHE.try_write() else {
return false;
};
+ // Re-read generation after acquiring the lock (may have changed meanwhile).
let Some(current_gen) = token_shadow_generation() else {
return false;
};
@@ -75,6 +93,12 @@ fn cached_secret_valid(tokenid: &Authid, secret: &str) -> bool {
cache.reset_and_set_gen(current_gen);
}
+ // TTL check again after acquiring the lock
+ let now = epoch_i64();
+ if cache.shadow_check_within_ttl(now) {
+ return cache.secret_matches(tokenid, secret);
+ }
+
// Stat the file to detect manual edits.
let Ok((new_mtime, new_len)) = shadow_mtime_len() else {
return false;
@@ -224,6 +248,13 @@ impl ApiTokenSecretCache {
self.cached_gen = new_gen;
}
+ /// Returns true if cached token.shadow metadata exists and was checked within the TTL window.
+ fn shadow_check_within_ttl(&self, now: i64) -> bool {
+ self.file_info.as_ref().is_some_and(|cached| {
+ now >= cached.last_checked && (now - cached.last_checked) < TOKEN_SECRET_CACHE_TTL_SECS
+ })
+ }
+
/// Returns true if there is a matching cached entry
fn secret_matches(&self, tokenid: &Authid, secret: &str) -> bool {
let Some(entry) = self.secrets.get(tokenid) else {
--
2.47.3
^ permalink raw reply [flat|nested] 10+ messages in thread* [PATCH proxmox v8 5/6] token shadow: inline set_secret fn
2026-04-09 15:54 [PATCH proxmox{,-datacenter-manager} v8 0/9] token-shadow: reduce api token verification overhead Samuel Rufinatscha
` (3 preceding siblings ...)
2026-04-09 15:54 ` [PATCH proxmox v8 4/6] token shadow: add TTL window to token secret cache Samuel Rufinatscha
@ 2026-04-09 15:54 ` Samuel Rufinatscha
2026-04-09 15:54 ` [PATCH proxmox v8 6/6] token shadow: deduplicate more code into apply_api_mutation Samuel Rufinatscha
` (3 subsequent siblings)
8 siblings, 0 replies; 10+ messages in thread
From: Samuel Rufinatscha @ 2026-04-09 15:54 UTC (permalink / raw)
To: pbs-devel
Signed-off-by: Samuel Rufinatscha <s.rufinatscha@proxmox.com>
---
proxmox-access-control/src/token_shadow.rs | 21 ++++++++-------------
1 file changed, 8 insertions(+), 13 deletions(-)
diff --git a/proxmox-access-control/src/token_shadow.rs b/proxmox-access-control/src/token_shadow.rs
index 4185351e..270f3bfa 100644
--- a/proxmox-access-control/src/token_shadow.rs
+++ b/proxmox-access-control/src/token_shadow.rs
@@ -161,8 +161,11 @@ pub fn verify_secret(tokenid: &Authid, secret: &str) -> Result<(), Error> {
}
}
-/// Adds a new entry for the given tokenid / API token secret. The secret is stored as salted hash.
-pub fn set_secret(tokenid: &Authid, secret: &str) -> Result<(), Error> {
+/// Generates a new secret for the given tokenid / API token, sets it then returns it.
+/// The secret is stored as salted hash.
+pub fn generate_and_set_secret(tokenid: &Authid) -> Result<String, Error> {
+ let secret = format!("{:x}", proxmox_uuid::Uuid::generate());
+
if !tokenid.is_token() {
bail!("not an API token ID");
}
@@ -173,13 +176,13 @@ pub fn set_secret(tokenid: &Authid, secret: &str) -> Result<(), Error> {
let pre_meta = shadow_mtime_len().unwrap_or((None, None));
let mut data = read_file()?;
- let hashed_secret = proxmox_sys::crypt::encrypt_pw(secret)?;
+ let hashed_secret = proxmox_sys::crypt::encrypt_pw(&secret)?;
data.insert(tokenid.clone(), hashed_secret);
write_file(data)?;
- apply_api_mutation(guard, tokenid, Some(secret), pre_meta);
+ apply_api_mutation(guard, tokenid, Some(&secret), pre_meta);
- Ok(())
+ Ok(secret)
}
/// Deletes the entry for the given tokenid.
@@ -202,14 +205,6 @@ pub fn delete_secret(tokenid: &Authid) -> Result<(), Error> {
Ok(())
}
-/// Generates a new secret for the given tokenid / API token, sets it then returns it.
-/// The secret is stored as salted hash.
-pub fn generate_and_set_secret(tokenid: &Authid) -> Result<String, Error> {
- let secret = format!("{:x}", proxmox_uuid::Uuid::generate());
- set_secret(tokenid, &secret)?;
- Ok(secret)
-}
-
/// Cached secret.
struct CachedSecret {
secret: String,
--
2.47.3
^ permalink raw reply [flat|nested] 10+ messages in thread* [PATCH proxmox v8 6/6] token shadow: deduplicate more code into apply_api_mutation
2026-04-09 15:54 [PATCH proxmox{,-datacenter-manager} v8 0/9] token-shadow: reduce api token verification overhead Samuel Rufinatscha
` (4 preceding siblings ...)
2026-04-09 15:54 ` [PATCH proxmox v8 5/6] token shadow: inline set_secret fn Samuel Rufinatscha
@ 2026-04-09 15:54 ` Samuel Rufinatscha
2026-04-09 15:54 ` [PATCH proxmox-datacenter-manager v8 1/3] pdm-config: implement access control backend hooks Samuel Rufinatscha
` (2 subsequent siblings)
8 siblings, 0 replies; 10+ messages in thread
From: Samuel Rufinatscha @ 2026-04-09 15:54 UTC (permalink / raw)
To: pbs-devel
Signed-off-by: Samuel Rufinatscha <s.rufinatscha@proxmox.com>
---
proxmox-access-control/src/token_shadow.rs | 71 +++++++++-------------
1 file changed, 29 insertions(+), 42 deletions(-)
diff --git a/proxmox-access-control/src/token_shadow.rs b/proxmox-access-control/src/token_shadow.rs
index 270f3bfa..a8cd4209 100644
--- a/proxmox-access-control/src/token_shadow.rs
+++ b/proxmox-access-control/src/token_shadow.rs
@@ -164,43 +164,13 @@ pub fn verify_secret(tokenid: &Authid, secret: &str) -> Result<(), Error> {
/// Generates a new secret for the given tokenid / API token, sets it then returns it.
/// The secret is stored as salted hash.
pub fn generate_and_set_secret(tokenid: &Authid) -> Result<String, Error> {
- let secret = format!("{:x}", proxmox_uuid::Uuid::generate());
-
- if !tokenid.is_token() {
- bail!("not an API token ID");
- }
-
- let guard = lock_config()?;
-
- // Capture state before we write to detect external edits.
- let pre_meta = shadow_mtime_len().unwrap_or((None, None));
-
- let mut data = read_file()?;
- let hashed_secret = proxmox_sys::crypt::encrypt_pw(&secret)?;
- data.insert(tokenid.clone(), hashed_secret);
- write_file(data)?;
-
- apply_api_mutation(guard, tokenid, Some(&secret), pre_meta);
-
- Ok(secret)
+ apply_api_mutation(tokenid, true)?
+ .ok_or_else(|| format_err!("Failed to generate API token secret"))
}
/// Deletes the entry for the given tokenid.
pub fn delete_secret(tokenid: &Authid) -> Result<(), Error> {
- if !tokenid.is_token() {
- bail!("not an API token ID");
- }
-
- let guard = lock_config()?;
-
- // Capture state before we write to detect external edits.
- let pre_meta = shadow_mtime_len().unwrap_or((None, None));
-
- let mut data = read_file()?;
- data.remove(tokenid);
- write_file(data)?;
-
- apply_api_mutation(guard, tokenid, None, pre_meta);
+ apply_api_mutation(tokenid, false)?;
Ok(())
}
@@ -293,12 +263,28 @@ fn cache_try_insert_secret(tokenid: Authid, secret: String, gen_before: usize) {
}
}
-fn apply_api_mutation(
- _guard: ApiLockGuard,
- tokenid: &Authid,
- secret: Option<&str>,
- pre_write_meta: (Option<SystemTime>, Option<u64>),
-) {
+fn apply_api_mutation(tokenid: &Authid, generate: bool) -> Result<Option<String>, Error> {
+ if !tokenid.is_token() {
+ bail!("not an API token ID");
+ }
+
+ let _guard = lock_config()?;
+
+ // Capture state before we write to detect external edits.
+ let pre_write_meta = shadow_mtime_len().unwrap_or((None, None));
+
+ let mut data = read_file()?;
+ let secret = if generate {
+ let secret = format!("{:x}", proxmox_uuid::Uuid::generate());
+ let hashed_secret = proxmox_sys::crypt::encrypt_pw(&secret)?;
+ data.insert(tokenid.clone(), hashed_secret);
+ Some(secret)
+ } else {
+ data.remove(tokenid);
+ None
+ };
+ write_file(data)?;
+
let now = epoch_i64();
// Signal cache invalidation to other processes (best-effort).
@@ -308,14 +294,14 @@ fn apply_api_mutation(
// If we cannot get the current generation, we cannot trust the cache
let Some(current_gen) = token_shadow_generation() else {
cache.reset_and_set_gen(0);
- return;
+ return Ok(secret);
};
// If we cannot bump the generation, or if it changed after
// obtaining the cache write lock, we cannot trust the cache
if bumped_gen != Some(current_gen) {
cache.reset_and_set_gen(current_gen);
- return;
+ return Ok(secret);
}
// If our cached file metadata does not match the on-disk state before our write,
@@ -329,7 +315,7 @@ fn apply_api_mutation(
}
// Apply the new mutation.
- match secret {
+ match &secret {
Some(secret) => {
let cached_secret = CachedSecret {
secret: secret.to_owned(),
@@ -354,6 +340,7 @@ fn apply_api_mutation(
cache.reset_and_set_gen(current_gen);
}
}
+ Ok(secret)
}
/// Get the current generation.
--
2.47.3
^ permalink raw reply [flat|nested] 10+ messages in thread* [PATCH proxmox-datacenter-manager v8 1/3] pdm-config: implement access control backend hooks
2026-04-09 15:54 [PATCH proxmox{,-datacenter-manager} v8 0/9] token-shadow: reduce api token verification overhead Samuel Rufinatscha
` (5 preceding siblings ...)
2026-04-09 15:54 ` [PATCH proxmox v8 6/6] token shadow: deduplicate more code into apply_api_mutation Samuel Rufinatscha
@ 2026-04-09 15:54 ` Samuel Rufinatscha
2026-04-09 15:54 ` [PATCH proxmox-datacenter-manager v8 2/3] pdm-config: wire user and ACL cache generation Samuel Rufinatscha
2026-04-09 15:54 ` [PATCH proxmox-datacenter-manager v8 3/3] pdm-config: wire token.shadow generation Samuel Rufinatscha
8 siblings, 0 replies; 10+ messages in thread
From: Samuel Rufinatscha @ 2026-04-09 15:54 UTC (permalink / raw)
To: pbs-devel
Implement AccessControlBackend in pdm-config and move
init_user_config() there from the ACL config in pdm-api-types.
Update server and admin initialization to pass ACL config and backend
separately.
Signed-off-by: Samuel Rufinatscha <s.rufinatscha@proxmox.com>
---
cli/admin/src/main.rs | 3 ++-
lib/pdm-api-types/src/acl.rs | 26 +------------------------
lib/pdm-config/Cargo.toml | 1 +
lib/pdm-config/src/access_control.rs | 29 ++++++++++++++++++++++++++++
lib/pdm-config/src/lib.rs | 2 ++
server/src/acl.rs | 10 ++++++++--
6 files changed, 43 insertions(+), 28 deletions(-)
create mode 100644 lib/pdm-config/src/access_control.rs
diff --git a/cli/admin/src/main.rs b/cli/admin/src/main.rs
index f698fa2..d51f211 100644
--- a/cli/admin/src/main.rs
+++ b/cli/admin/src/main.rs
@@ -18,8 +18,9 @@ fn main() {
let priv_user = pdm_config::priv_user().expect("cannot get privileged user");
proxmox_product_config::init(api_user, priv_user);
- proxmox_access_control::init::init(
+ proxmox_access_control::init::init_separate(
&pdm_api_types::AccessControlConfig,
+ &pdm_config::AccessControlBackend,
pdm_buildcfg::configdir!("/access"),
)
.expect("failed to setup access control config");
diff --git a/lib/pdm-api-types/src/acl.rs b/lib/pdm-api-types/src/acl.rs
index 405982a..0868f3d 100644
--- a/lib/pdm-api-types/src/acl.rs
+++ b/lib/pdm-api-types/src/acl.rs
@@ -2,17 +2,15 @@ use std::collections::HashMap;
use std::str::FromStr;
use std::sync::LazyLock;
-use anyhow::{format_err, Context, Error};
+use anyhow::{format_err, Error};
use const_format::concatcp;
use serde::de::{value, IntoDeserializer};
use serde::{Deserialize, Serialize};
-use proxmox_access_control::types::User;
use proxmox_auth_api::types::Authid;
use proxmox_lang::constnamedbitmap;
use proxmox_schema::api_types::SAFE_ID_REGEX_STR;
use proxmox_schema::{api, const_regex, ApiStringFormat, BooleanSchema, Schema, StringSchema};
-use proxmox_section_config::SectionConfigData;
const_regex! {
pub ACL_PATH_REGEX = concatcp!(r"^(?:/|", r"(?:/", SAFE_ID_REGEX_STR, ")+", r")$");
@@ -224,28 +222,6 @@ impl proxmox_access_control::init::AccessControlConfig for AccessControlConfig {
Some("Administrator")
}
- fn init_user_config(&self, config: &mut SectionConfigData) -> Result<(), Error> {
- if !config.sections.contains_key("root@pam") {
- config
- .set_data(
- "root@pam",
- "user",
- User {
- userid: "root@pam".parse().expect("invalid user id"),
- comment: Some("Superuser".to_string()),
- enable: None,
- expire: None,
- firstname: None,
- lastname: None,
- email: None,
- },
- )
- .context("failed to insert default user into user config")?
- }
-
- Ok(())
- }
-
fn acl_audit_privileges(&self) -> u64 {
PRIV_ACCESS_AUDIT
}
diff --git a/lib/pdm-config/Cargo.toml b/lib/pdm-config/Cargo.toml
index d39c2ad..19781d2 100644
--- a/lib/pdm-config/Cargo.toml
+++ b/lib/pdm-config/Cargo.toml
@@ -13,6 +13,7 @@ once_cell.workspace = true
openssl.workspace = true
serde.workspace = true
+proxmox-access-control.workspace = true
proxmox-config-digest = { workspace = true, features = [ "openssl" ] }
proxmox-http = { workspace = true, features = [ "http-helpers" ] }
proxmox-ldap = { workspace = true, features = [ "types" ]}
diff --git a/lib/pdm-config/src/access_control.rs b/lib/pdm-config/src/access_control.rs
new file mode 100644
index 0000000..0c17c99
--- /dev/null
+++ b/lib/pdm-config/src/access_control.rs
@@ -0,0 +1,29 @@
+use anyhow::{Context, Error};
+use proxmox_access_control::types::User;
+use proxmox_section_config::SectionConfigData;
+
+pub struct AccessControlBackend;
+
+impl proxmox_access_control::init::AccessControlBackend for AccessControlBackend {
+ fn init_user_config(&self, config: &mut SectionConfigData) -> Result<(), Error> {
+ if !config.sections.contains_key("root@pam") {
+ config
+ .set_data(
+ "root@pam",
+ "user",
+ User {
+ userid: "root@pam".parse().expect("invalid user id"),
+ comment: Some("Superuser".to_string()),
+ enable: None,
+ expire: None,
+ firstname: None,
+ lastname: None,
+ email: None,
+ },
+ )
+ .context("failed to insert default user into user config")?
+ }
+
+ Ok(())
+ }
+}
diff --git a/lib/pdm-config/src/lib.rs b/lib/pdm-config/src/lib.rs
index 4c49054..6e5e760 100644
--- a/lib/pdm-config/src/lib.rs
+++ b/lib/pdm-config/src/lib.rs
@@ -9,6 +9,8 @@ pub mod remotes;
pub mod setup;
pub mod views;
+mod access_control;
+pub use access_control::AccessControlBackend;
mod config_version_cache;
pub use config_version_cache::ConfigVersionCache;
diff --git a/server/src/acl.rs b/server/src/acl.rs
index f421814..4150ef4 100644
--- a/server/src/acl.rs
+++ b/server/src/acl.rs
@@ -1,7 +1,13 @@
pub(crate) fn init() {
static ACCESS_CONTROL_CONFIG: pdm_api_types::AccessControlConfig =
pdm_api_types::AccessControlConfig;
+ static ACCESS_CONTROL_BACKEND: pdm_config::AccessControlBackend =
+ pdm_config::AccessControlBackend;
- proxmox_access_control::init::init(&ACCESS_CONTROL_CONFIG, pdm_buildcfg::configdir!("/access"))
- .expect("failed to setup access control config");
+ proxmox_access_control::init::init_separate(
+ &ACCESS_CONTROL_CONFIG,
+ &ACCESS_CONTROL_BACKEND,
+ pdm_buildcfg::configdir!("/access"),
+ )
+ .expect("failed to setup access control config");
}
--
2.47.3
^ permalink raw reply [flat|nested] 10+ messages in thread* [PATCH proxmox-datacenter-manager v8 2/3] pdm-config: wire user and ACL cache generation
2026-04-09 15:54 [PATCH proxmox{,-datacenter-manager} v8 0/9] token-shadow: reduce api token verification overhead Samuel Rufinatscha
` (6 preceding siblings ...)
2026-04-09 15:54 ` [PATCH proxmox-datacenter-manager v8 1/3] pdm-config: implement access control backend hooks Samuel Rufinatscha
@ 2026-04-09 15:54 ` Samuel Rufinatscha
2026-04-09 15:54 ` [PATCH proxmox-datacenter-manager v8 3/3] pdm-config: wire token.shadow generation Samuel Rufinatscha
8 siblings, 0 replies; 10+ messages in thread
From: Samuel Rufinatscha @ 2026-04-09 15:54 UTC (permalink / raw)
To: pbs-devel
Enables user.cfg and acl.cfg caching by wiring
proxmox_access_control::init::AccessControlBackend's cache_generation()
and increment_cache_generation() with ConfigVersionCache.
Since the trait expects a single shared generation for both
user.cfg and acl.cfg files, the ConfigVersionCache's
user_cache_generation variable is
renamed to user_and_acl_generation to reflect its actual scope.
Safety: the renamed generation was unused before, no layout change, the
shared-memory size and field order remain unchanged.
Signed-off-by: Samuel Rufinatscha <s.rufinatscha@proxmox.com>
---
Changes from v7 to v8:
* Rebased
* Improve commit message
Changes from v6 to v7:
* Rebased
Changes from v5 to v6:
* Rebased
Changes from v4 to v5:
* Rebased
lib/pdm-config/src/access_control.rs | 11 +++++++++++
lib/pdm-config/src/config_version_cache.rs | 16 ++++++++--------
2 files changed, 19 insertions(+), 8 deletions(-)
diff --git a/lib/pdm-config/src/access_control.rs b/lib/pdm-config/src/access_control.rs
index 0c17c99..6bc6ca6 100644
--- a/lib/pdm-config/src/access_control.rs
+++ b/lib/pdm-config/src/access_control.rs
@@ -26,4 +26,15 @@ impl proxmox_access_control::init::AccessControlBackend for AccessControlBackend
Ok(())
}
+
+ fn cache_generation(&self) -> Option<usize> {
+ crate::ConfigVersionCache::new()
+ .ok()
+ .map(|c| c.user_and_acl_generation())
+ }
+
+ fn increment_cache_generation(&self) -> Result<(), Error> {
+ let c = crate::ConfigVersionCache::new()?;
+ Ok(c.increase_user_and_acl_generation())
+ }
}
diff --git a/lib/pdm-config/src/config_version_cache.rs b/lib/pdm-config/src/config_version_cache.rs
index 36a6a77..d27ec95 100644
--- a/lib/pdm-config/src/config_version_cache.rs
+++ b/lib/pdm-config/src/config_version_cache.rs
@@ -21,8 +21,8 @@ use proxmox_shared_memory::*;
#[repr(C)]
struct ConfigVersionCacheDataInner {
magic: [u8; 8],
- // User (user.cfg) cache generation/version.
- user_cache_generation: AtomicUsize,
+ // User (user.cfg) and ACL (acl.cfg) generation/version.
+ user_and_acl_generation: AtomicUsize,
// Traffic control (traffic-control.cfg) generation/version.
traffic_control_generation: AtomicUsize,
// Tracks updates to the remote/hostname/nodename mapping cache.
@@ -124,19 +124,19 @@ impl ConfigVersionCache {
Ok(Arc::new(Self { shmem }))
}
- /// Returns the user cache generation number.
- pub fn user_cache_generation(&self) -> usize {
+ /// Returns the user and ACL cache generation number.
+ pub fn user_and_acl_generation(&self) -> usize {
self.shmem
.data()
- .user_cache_generation
+ .user_and_acl_generation
.load(Ordering::Acquire)
}
- /// Increase the user cache generation number.
- pub fn increase_user_cache_generation(&self) {
+ /// Increase the user and ACL cache generation number.
+ pub fn increase_user_and_acl_generation(&self) {
self.shmem
.data()
- .user_cache_generation
+ .user_and_acl_generation
.fetch_add(1, Ordering::AcqRel);
}
--
2.47.3
^ permalink raw reply [flat|nested] 10+ messages in thread* [PATCH proxmox-datacenter-manager v8 3/3] pdm-config: wire token.shadow generation
2026-04-09 15:54 [PATCH proxmox{,-datacenter-manager} v8 0/9] token-shadow: reduce api token verification overhead Samuel Rufinatscha
` (7 preceding siblings ...)
2026-04-09 15:54 ` [PATCH proxmox-datacenter-manager v8 2/3] pdm-config: wire user and ACL cache generation Samuel Rufinatscha
@ 2026-04-09 15:54 ` Samuel Rufinatscha
8 siblings, 0 replies; 10+ messages in thread
From: Samuel Rufinatscha @ 2026-04-09 15:54 UTC (permalink / raw)
To: pbs-devel
Wires ConfigVersionCache with AccessControlBackend to support
token.shadow caching.
Safety: the shmem mapping is fixed to 4096 bytes via the #[repr(C)]
union padding, and the new atomic is appended to the end of the
#[repr(C)] inner struct, so all existing field offsets stay unchanged.
Old processes keep accessing the same bytes and new processes consume
previously reserved padding.
Also documents the effects of the added API token-cache in the
proxmox-access-control crate.
Signed-off-by: Samuel Rufinatscha <s.rufinatscha@proxmox.com>
---
docs/access-control.rst | 4 ++++
lib/pdm-config/src/access_control.rs | 11 +++++++++++
lib/pdm-config/src/config_version_cache.rs | 18 ++++++++++++++++++
3 files changed, 33 insertions(+)
diff --git a/docs/access-control.rst b/docs/access-control.rst
index adf26cd..18e57a2 100644
--- a/docs/access-control.rst
+++ b/docs/access-control.rst
@@ -47,6 +47,10 @@ place of the user ID (``user@realm``) and the user password, respectively.
The API token is passed from the client to the server by setting the ``Authorization`` HTTP header
with method ``PDMAPIToken`` to the value ``TOKENID:TOKENSECRET``.
+.. WARNING:: Direct/manual edits to ``token.shadow`` may take up to 60 seconds (or
+ longer in edge cases) to take effect due to caching. Restart services for
+ immediate effect of manual edits.
+
.. _access_control:
Access Control
diff --git a/lib/pdm-config/src/access_control.rs b/lib/pdm-config/src/access_control.rs
index 6bc6ca6..d9fc8ff 100644
--- a/lib/pdm-config/src/access_control.rs
+++ b/lib/pdm-config/src/access_control.rs
@@ -37,4 +37,15 @@ impl proxmox_access_control::init::AccessControlBackend for AccessControlBackend
let c = crate::ConfigVersionCache::new()?;
Ok(c.increase_user_and_acl_generation())
}
+
+ fn token_shadow_cache_generation(&self) -> Option<usize> {
+ crate::ConfigVersionCache::new()
+ .ok()
+ .map(|c| c.token_shadow_generation())
+ }
+
+ fn increment_token_shadow_cache_generation(&self) -> Result<usize, Error> {
+ let c = crate::ConfigVersionCache::new()?;
+ Ok(c.increase_token_shadow_generation())
+ }
}
diff --git a/lib/pdm-config/src/config_version_cache.rs b/lib/pdm-config/src/config_version_cache.rs
index d27ec95..f3d52a0 100644
--- a/lib/pdm-config/src/config_version_cache.rs
+++ b/lib/pdm-config/src/config_version_cache.rs
@@ -27,6 +27,8 @@ struct ConfigVersionCacheDataInner {
traffic_control_generation: AtomicUsize,
// Tracks updates to the remote/hostname/nodename mapping cache.
remote_mapping_cache: AtomicUsize,
+ // Token shadow (token.shadow) generation/version.
+ token_shadow_generation: AtomicUsize,
// Add further atomics here
}
@@ -172,4 +174,20 @@ impl ConfigVersionCache {
.fetch_add(1, Ordering::Relaxed)
+ 1
}
+
+ /// Returns the token shadow generation number.
+ pub fn token_shadow_generation(&self) -> usize {
+ self.shmem
+ .data()
+ .token_shadow_generation
+ .load(Ordering::Acquire)
+ }
+
+ /// Increase the token shadow generation number.
+ pub fn increase_token_shadow_generation(&self) -> usize {
+ self.shmem
+ .data()
+ .token_shadow_generation
+ .fetch_add(1, Ordering::AcqRel)
+ }
}
--
2.47.3
^ permalink raw reply [flat|nested] 10+ messages in thread