* [PATCH cluster/access-control/manager/docs/proxmox 0/9] fix #7805: add a datacenter-wide API token policy
@ 2026-09-23 20:59 Thomas Lamprecht
2026-09-23 20:59 ` [PATCH cluster 1/9] datacenter config: add token-policy option Thomas Lamprecht
` (8 more replies)
0 siblings, 9 replies; 10+ messages in thread
From: Thomas Lamprecht @ 2026-09-23 20:59 UTC (permalink / raw)
To: pve-devel
Compliance rules like PCI DSS, SOC 2, or ISO 27001 often require
that credentials have a limited lifetime, but there is no central way
to enforce that for API tokens.
Add an optional token-policy option to datacenter.cfg to require an
expiration date, limit the maximum token lifetime, forbid changing
the expiration date of existing tokens, and require privilege
separation.
The policy is only checked when a token is created or updated, and
only for values that actually change, so existing tokens stay valid even
if their lifetime exceeds a newly configured maximum one.
Extending the expiration date within the limit stays possible, unless
the opt-in 'disallow-expiry-changes' switch is set (see [0] for the
rationale).
The web UI exposes these options under Datacenter -> Options and
adjusts the token dialogs to the policy. Changing the policy requires
Sys.Modify on '/', like any other datacenter option; an extra
auth-specific privilege check was skipped for now, for the current
built-in roles it would land in the same roles as Sys.Modify, adding new
built-in roles here is IMO scope creep and this can still be done later
(with some more thoughts on how to handle all these datacenter options).
FWIW, the first manager patch (4/9 overall) fixes a pre-existing issue
w.r.t. only submitting the expiration date when changed, which the
policy checks would expose more often, but it'd also make sense on its
own and could be applied independently already.
The last two patches port the backend part to the common rust stack, so
PBS and PDM can opt in through their AccessControlConfig, and to prepare
moving pve-access-control over to rust later. Only the expiry parts
apply there, tokens in the rust stack always use separate ACLs, i.e. no
sharing with the underlying users there.
Dependencies: access-control needs the cluster patch, manager needs
both, and building manager needs the pve-doc-generator with the new
online-help anchor from the docs patch (or ALLOW_MISSING=1), which is
why the docs patch comes before the manager ones; the proxmox patches
are independent. d/control needs the respective versioned dependency
bumps added on applying, and the generated datacenter option reference
in pve-docs needs the usual make update once the cluster side is
packaged.
[0]: https://bugzilla.proxmox.com/show_bug.cgi?id=7805
cluster:
Thomas Lamprecht (1):
datacenter config: add token-policy option
src/PVE/DataCenterConfig.pm | 51 ++++++++++++++++++++++++++++++
src/test/Makefile | 6 +++-
src/test/test_token_policy.pl | 72 +++++++++++++++++++++++++++++++++++++++++++
3 files changed, 128 insertions(+), 1 deletion(-)
access-control:
Thomas Lamprecht (1):
fix #7805: api: token: enforce datacenter token policy
src/PVE/API2/User.pm | 32 +++++-
src/PVE/AccessControl.pm | 59 +++++++++++
src/test/Makefile | 2 +
src/test/token-policy-api-test.pl | 102 ++++++++++++++++++
src/test/token-policy-test.pl | 217 ++++++++++++++++++++++++++++++++++++++
5 files changed, 409 insertions(+), 3 deletions(-)
docs:
Thomas Lamprecht (1):
user management: document the API token policy
pveum.adoc | 34 ++++++++++++++++++++++++++++++++++
1 file changed, 34 insertions(+)
manager:
Thomas Lamprecht (4):
ui: token edit: only submit the expiration date when changed
api: cluster options: return token-policy without Sys.Audit
ui: dc options: allow editing the API token policy
ui: token edit: adapt to the datacenter API token policy
PVE/API2/Cluster.pm | 2 +-
www/manager6/UIOptions.js | 14 ++++-
www/manager6/dc/OptionView.js | 108 +++++++++++++++++++++++++++++++++
www/manager6/dc/TokenEdit.js | 136 +++++++++++++++++++++++++++++++++++++++++-
www/manager6/dc/TokenView.js | 36 ++++++-----
5 files changed, 278 insertions(+), 18 deletions(-)
proxmox:
Thomas Lamprecht (2):
access-control: add API token policy type with expiry checks
access-control: enforce token policy on token create and update
proxmox-access-control/src/api/tokens.rs | 17 +++
proxmox-access-control/src/init.rs | 15 ++
proxmox-access-control/src/types.rs | 227 +++++++++++++++++++++++++++++++
3 files changed, 259 insertions(+)
--
2.47.3
^ permalink raw reply [flat|nested] 10+ messages in thread
* [PATCH cluster 1/9] datacenter config: add token-policy option
2026-09-23 20:59 [PATCH cluster/access-control/manager/docs/proxmox 0/9] fix #7805: add a datacenter-wide API token policy Thomas Lamprecht
@ 2026-09-23 20:59 ` Thomas Lamprecht
2026-09-23 20:59 ` [PATCH access-control 2/9] fix #7805: api: token: enforce datacenter token policy Thomas Lamprecht
` (7 subsequent siblings)
8 siblings, 0 replies; 10+ messages in thread
From: Thomas Lamprecht @ 2026-09-23 20:59 UTC (permalink / raw)
To: pve-devel
Compliance rules like PCI DSS, SOC 2, or ISO 27001 commonly mandate
bounded credential lifetimes, but without a central policy every
token creator must remember to set an expiration date manually.
Add an optional cluster-wide policy that can require an expiration
date, bound the maximum token lifetime, forbid changing the expiration
date of existing tokens, and require privilege separation for API
tokens. It is enforced by pve-access-control on token creation and
update only; existing tokens deliberately stay untouched, so opting in
does not invalidate already deployed tokens, and (by default)
prolonging a token within the bounds stays possible as a deliberate
act (see #7805).
Note that an unparsable policy is dropped with a warning and the rest
of datacenter.cfg still loads, the same as for every other property
string here, so a typo leaves tokens unrestricted rather than locked
down.
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
src/PVE/DataCenterConfig.pm | 51 +++++++++++++++++++++++++
src/test/Makefile | 6 ++-
src/test/test_token_policy.pl | 72 +++++++++++++++++++++++++++++++++++
3 files changed, 128 insertions(+), 1 deletion(-)
create mode 100644 src/test/test_token_policy.pl
diff --git a/src/PVE/DataCenterConfig.pm b/src/PVE/DataCenterConfig.pm
index 004122e..a01ef47 100644
--- a/src/PVE/DataCenterConfig.pm
+++ b/src/PVE/DataCenterConfig.pm
@@ -268,6 +268,41 @@ my $webauthn_format = {
},
};
+my $token_policy_format = {
+ 'require-expiry' => {
+ type => 'boolean',
+ optional => 1,
+ default => 0,
+ description => "Require an expiration date for new API tokens and when changing the"
+ . " expiration date of existing ones.",
+ },
+ 'max-lifetime' => {
+ type => 'integer',
+ optional => 1,
+ minimum => 1,
+ format_description => 'seconds',
+ description => "Maximum lifetime of API tokens in seconds, counted from when the"
+ . " expiration date is set, that is on creation or when an update changes it."
+ . " Implies 'require-expiry'.",
+ },
+ 'disallow-expiry-changes' => {
+ type => 'boolean',
+ optional => 1,
+ default => 0,
+ description => "Disallow changing the expiration date of existing API tokens, so that"
+ . " 'max-lifetime' cannot be circumvented by extending tokens repeatedly. Such"
+ . " tokens can still be deleted and recreated.",
+ },
+ 'require-privilege-separation' => {
+ type => 'boolean',
+ optional => 1,
+ default => 0,
+ description => "Require privilege separation for new API tokens and when changing that"
+ . " setting on existing ones, that is, disallow tokens with the full privileges of"
+ . " their user.",
+ },
+};
+
PVE::JSONSchema::register_format('mac-prefix', \&pve_verify_mac_prefix);
sub pve_verify_mac_prefix {
@@ -510,6 +545,13 @@ my $datacenter_schema = {
format => $webauthn_format,
description => 'webauthn configuration',
},
+ 'token-policy' => {
+ optional => 1,
+ type => 'string',
+ format => $token_policy_format,
+ description => "Cluster-wide policy for creating and updating API tokens, for"
+ . " example to enforce an expiration date or privilege separation.",
+ },
description => {
type => 'string',
description =>
@@ -614,6 +656,10 @@ sub parse_datacenter_config {
$res->{webauthn} = parse_property_string($webauthn_format, $webauthn);
}
+ if (my $token_policy = $res->{'token-policy'}) {
+ $res->{'token-policy'} = parse_property_string($token_policy_format, $token_policy);
+ }
+
if (my $tag_style = $res->{'tag-style'}) {
$res->{'tag-style'} = parse_property_string($tag_style_format, $tag_style);
}
@@ -707,6 +753,11 @@ sub write_datacenter_config {
$cfg->{webauthn} = PVE::JSONSchema::print_property_string($webauthn, $webauthn_format);
}
+ if (ref(my $token_policy = $cfg->{'token-policy'})) {
+ $cfg->{'token-policy'} =
+ PVE::JSONSchema::print_property_string($token_policy, $token_policy_format);
+ }
+
if (ref(my $tag_style = $cfg->{'tag-style'})) {
$cfg->{'tag-style'} = PVE::JSONSchema::print_property_string($tag_style, $tag_style_format);
}
diff --git a/src/test/Makefile b/src/test/Makefile
index cdd37d0..3a47b8c 100644
--- a/src/test/Makefile
+++ b/src/test/Makefile
@@ -4,7 +4,7 @@ cpgtest: cpgtest.c
gcc -Wall cpgtest.c $(shell pkg-config --cflags --libs libcpg libqb) -o cpgtest
.PHONY: check install clean distclean
-check: corosync-parser-test test-mac-prefix
+check: corosync-parser-test test-mac-prefix test-token-policy
.PHONY: corosync-parser-test
corosync-parser-test:
@@ -14,5 +14,9 @@ corosync-parser-test:
test-mac-prefix:
perl test_mac_prefix.pl
+.PHONY: test-token-policy
+test-token-policy:
+ perl test_token_policy.pl
+
distclean: clean
clean:
diff --git a/src/test/test_token_policy.pl b/src/test/test_token_policy.pl
new file mode 100644
index 0000000..269dc14
--- /dev/null
+++ b/src/test/test_token_policy.pl
@@ -0,0 +1,72 @@
+use strict;
+use warnings;
+
+use Test::More;
+
+use lib ('.', '..');
+
+use PVE::DataCenterConfig;
+
+# the policy is a property string, so it has to survive a parse and write round trip and it must
+# not accept values that the enforcing side would not be able to interpret
+
+my $parse = sub {
+ my ($value) = @_;
+ my $raw = defined($value) ? "token-policy: $value\n" : '';
+ my $cfg = PVE::DataCenterConfig::parse_datacenter_config('datacenter.cfg',
+ $raw . "keyboard: en-us\n");
+ return $cfg;
+};
+
+my $policy = $parse->('require-expiry=1,max-lifetime=86400')->{'token-policy'};
+is($policy->{'require-expiry'}, 1, 'require-expiry is parsed');
+is($policy->{'max-lifetime'}, 86400, 'max-lifetime is parsed');
+
+$policy = $parse->('disallow-expiry-changes=1,require-privilege-separation=1')->{'token-policy'};
+is($policy->{'disallow-expiry-changes'}, 1, 'disallow-expiry-changes is parsed');
+is($policy->{'require-privilege-separation'}, 1, 'require-privilege-separation is parsed');
+
+is($parse->(undef)->{'token-policy'}, undef, 'an absent token-policy is not invented');
+
+# an unusable policy has to be dropped, never half applied, and must not take the rest of the
+# configuration down with it
+for my $invalid (
+ 'max-lifetime=0',
+ 'max-lifetime=-1',
+ 'max-lifetime=forever',
+ 'require-expiry=maybe',
+ 'unknown-key=1',
+) {
+ my $cfg;
+ {
+ local $SIG{__WARN__} = sub { };
+ $cfg = $parse->($invalid);
+ }
+ is($cfg->{'token-policy'}, undef, "'$invalid' does not yield a policy");
+ is($cfg->{keyboard}, 'en-us', "'$invalid' does not break the rest of the config");
+}
+
+# writing has to turn the parsed hash back into the property string
+my $raw = PVE::DataCenterConfig::write_datacenter_config(
+ 'datacenter.cfg',
+ {
+ 'token-policy' => {
+ 'require-expiry' => 1,
+ 'max-lifetime' => 86400,
+ },
+ },
+);
+like(
+ $raw,
+ qr/^token-policy:\s*(require-expiry=1,max-lifetime=86400|max-lifetime=86400,require-expiry=1)$/m,
+ 'the policy is written back as a property string',
+);
+
+my $round_tripped = PVE::DataCenterConfig::parse_datacenter_config('datacenter.cfg', $raw);
+is_deeply(
+ $round_tripped->{'token-policy'},
+ { 'require-expiry' => 1, 'max-lifetime' => 86400 },
+ 'the policy survives a write and parse round trip',
+);
+
+done_testing();
--
2.47.3
^ permalink raw reply related [flat|nested] 10+ messages in thread
* [PATCH access-control 2/9] fix #7805: api: token: enforce datacenter token policy
2026-09-23 20:59 [PATCH cluster/access-control/manager/docs/proxmox 0/9] fix #7805: add a datacenter-wide API token policy Thomas Lamprecht
2026-09-23 20:59 ` [PATCH cluster 1/9] datacenter config: add token-policy option Thomas Lamprecht
@ 2026-09-23 20:59 ` Thomas Lamprecht
2026-09-23 20:59 ` [PATCH docs 3/9] user management: document the API " Thomas Lamprecht
` (6 subsequent siblings)
8 siblings, 0 replies; 10+ messages in thread
From: Thomas Lamprecht @ 2026-09-23 20:59 UTC (permalink / raw)
To: pve-devel
Check the new datacenter.cfg token-policy option on API token
creation and update, so admins can centrally require expiration
dates, bound the maximum token lifetime, forbid changing existing
expiration dates, and require privilege separation, as commonly
mandated by compliance rules like PCI DSS, SOC 2, or ISO 27001.
Existing tokens stay valid, and an update only checks settings it
actually changes, so unrelated edits or clients resubmitting
unchanged fields are not rejected for tokens that predate the policy.
The check runs before the secret is generated to avoid leaving an
orphaned entry in token.cfg on rejection. Regenerating the secret of
an existing token is deliberately not restricted, rotation does not
extend validity and blocking it would only discourage rotating old
credentials.
Changing the policy itself requires Sys.Modify on '/', like any
other datacenter option. An extra auth-specific privilege check was
skipped for now, for the current built-in roles it would land in the
same roles as Sys.Modify anyway, and anyone allowed to change
datacenter options can adapt the policy in any case.
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
src/PVE/API2/User.pm | 32 ++++-
src/PVE/AccessControl.pm | 59 ++++++++
src/test/Makefile | 2 +
src/test/token-policy-api-test.pl | 102 ++++++++++++++
src/test/token-policy-test.pl | 217 ++++++++++++++++++++++++++++++
5 files changed, 409 insertions(+), 3 deletions(-)
create mode 100644 src/test/token-policy-api-test.pl
create mode 100644 src/test/token-policy-test.pl
diff --git a/src/PVE/API2/User.pm b/src/PVE/API2/User.pm
index 579783b..0155b6b 100644
--- a/src/PVE/API2/User.pm
+++ b/src/PVE/API2/User.pm
@@ -806,14 +806,20 @@ __PACKAGE__->register_method({
die "Token already exists.\n"
if defined(PVE::AccessControl::check_token_exist($usercfg, $userid, $tokenid, 1));
- $full_tokenid = PVE::AccessControl::join_tokenid($userid, $tokenid);
- $value = PVE::TokenConfig::generate_token($full_tokenid);
-
$token = {};
$token->{privsep} = defined($param->{privsep}) ? $param->{privsep} : 1;
$token->{expire} = $param->{expire} if defined($param->{expire});
$token->{comment} = $param->{comment} if $param->{comment};
+ # check policy before generating the secret to avoid leaving an orphan in token.cfg
+ PVE::AccessControl::assert_token_policy({
+ expire => $token->{expire} // 0,
+ 'privilege-separation' => $token->{privsep},
+ });
+
+ $full_tokenid = PVE::AccessControl::join_tokenid($userid, $tokenid);
+ $value = PVE::TokenConfig::generate_token($full_tokenid);
+
$usercfg->{users}->{$userid}->{tokens}->{$tokenid} = $token;
cfs_write_file("user.cfg", $usercfg);
};
@@ -899,11 +905,31 @@ __PACKAGE__->register_method({
$usercfg = cfs_read_file("user.cfg");
$token = PVE::AccessControl::check_token_exist($usercfg, $userid, $tokenid);
+ my $old_expire = $token->{expire} // 0;
+ my $old_privilege_separation = $token->{privsep} // 0;
+
$token->{privsep} = $param->{privsep} if defined($param->{privsep});
$token->{expire} = $param->{expire} if defined($param->{expire});
$token->{comment} = $param->{comment} if defined($param->{comment});
delete $token->{comment} if (!length $token->{comment});
+ my $new_expire = $token->{expire} // 0;
+ my $new_privilege_separation = $token->{privsep} // 0;
+
+ # enforce the policy only on the dimensions this update actually changes, so
+ # unrelated edits (or a client that resubmits unchanged fields) do not
+ # retroactively reject tokens that predate the policy
+ my $expire_change = $new_expire != $old_expire ? $new_expire : undef;
+ my $privilege_separation_change =
+ $new_privilege_separation != $old_privilege_separation
+ ? $new_privilege_separation
+ : undef;
+ PVE::AccessControl::assert_token_policy({
+ expire => $expire_change,
+ 'privilege-separation' => $privilege_separation_change,
+ 'is-update' => 1,
+ });
+
my $deletable = {
comment => 1,
};
diff --git a/src/PVE/AccessControl.pm b/src/PVE/AccessControl.pm
index 8879ab8..48e02cb 100644
--- a/src/PVE/AccessControl.pm
+++ b/src/PVE/AccessControl.pm
@@ -21,6 +21,7 @@ use PVE::OTP;
use PVE::Ticket;
use PVE::Tools qw(run_command lock_file file_get_contents split_list safe_print);
use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_write_file cfs_lock_file);
+use PVE::DataCenterConfig; # ensure the 'datacenter.cfg' cfs parser is registered
use PVE::JSONSchema qw(register_standard_option get_standard_option);
use PVE::RS::TFA;
@@ -716,6 +717,64 @@ sub check_token_exist {
return undef;
}
+# Assert that a token's expiration and privilege-separation setting conform to the cluster-wide
+# token policy from datacenter.cfg. Dies with a descriptive message on violation.
+#
+# Takes a hash reference with the following keys:
+# - expire: the effective expiration date (epoch seconds, 0 == never); undef skips all
+# expiration-related checks
+# - privilege-separation: the effective privilege separation flag; undef skips that dimension
+# - is-update: whether an existing token is updated; with 'disallow-expiry-changes' configured,
+# any passed (i.e. actually changed) expiration date is then rejected outright
+# - ctime, policy: override the reference time to bound the expiration date against and the
+# policy from the datacenter config, mainly for testing
+#
+# The checks are independent per dimension and only run for values that are actually passed. This
+# lets callers enforce a dimension only when the current operation sets it, so unrelated updates
+# (e.g. a comment change) do not retroactively reject tokens that predate the policy.
+sub assert_token_policy {
+ my ($param) = @_;
+
+ # integer-typed API parameters keep their submitted string spelling, and zero spellings
+ # like '00', '+0', or '-0' are true as Perl scalars, so numify to not mistake them for a
+ # set expiration date in the truthiness checks below
+ my $expire = defined($param->{expire}) ? int($param->{expire}) : undef;
+ my $privilege_separation = $param->{'privilege-separation'};
+
+ return if !defined($expire) && !defined($privilege_separation);
+
+ my $policy = $param->{policy} // cfs_read_file('datacenter.cfg')->{'token-policy'};
+ return if !$policy;
+
+ if (defined($expire)) {
+ die "the datacenter token policy does not allow changing the expiration date of an"
+ . " existing token\n"
+ if $param->{'is-update'} && $policy->{'disallow-expiry-changes'};
+
+ my $ctime = $param->{ctime} // time();
+ my $max_lifetime = $policy->{'max-lifetime'};
+ # a token that never expires trivially exceeds any finite maximum lifetime, so a set
+ # maximum lifetime also requires an expiration date
+ my $require_expiry = $policy->{'require-expiry'} || $max_lifetime;
+
+ die "the datacenter token policy requires an expiration date\n"
+ if $require_expiry && !$expire;
+
+ if ($max_lifetime && $expire && $expire > $ctime + $max_lifetime) {
+ die "the expiration date exceeds the maximum token lifetime of $max_lifetime seconds"
+ . " set by the datacenter token policy\n";
+ }
+ }
+
+ if (
+ defined($privilege_separation)
+ && $policy->{'require-privilege-separation'}
+ && !$privilege_separation
+ ) {
+ die "the datacenter token policy requires privilege separation\n";
+ }
+}
+
# deprecated
sub verify_one_time_pw {
my ($type, $username, $keys, $tfa_cfg, $otp) = @_;
diff --git a/src/test/Makefile b/src/test/Makefile
index 53f2da7..51db8cb 100644
--- a/src/test/Makefile
+++ b/src/test/Makefile
@@ -13,4 +13,6 @@ check:
perl -I.. perm-test7.pl
perl -I.. perm-test8.pl
perl -I.. realm_sync_test.pl
+ perl -I.. token-policy-test.pl
+ perl -I.. token-policy-api-test.pl
perl -I.. api-tests.pl
diff --git a/src/test/token-policy-api-test.pl b/src/test/token-policy-api-test.pl
new file mode 100644
index 0000000..dece4db
--- /dev/null
+++ b/src/test/token-policy-api-test.pl
@@ -0,0 +1,102 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+
+use Test::More;
+use Test::MockModule;
+
+use PVE::AccessControl;
+use PVE::API2::User;
+
+# Exercise the token create and update API handlers with the real schema validation and user
+# config parser/writer, mocking out the cluster file system and secret generation. The integer
+# schema accepts zero spellings like '00', '+0', or '-0' without canonicalizing them, so the
+# policy enforcement at the API boundary must not mistake them for a set expiration date.
+
+my ($policy, $user_cfg_raw, $secret_calls);
+
+my $read = sub {
+ my ($file) = @_;
+ return PVE::AccessControl::parse_user_config($file, $user_cfg_raw) if $file eq 'user.cfg';
+ return { 'token-policy' => $policy } if $file eq 'datacenter.cfg';
+ die "unexpected cfs_read_file('$file') call\n";
+};
+
+my $access_control_module = Test::MockModule->new('PVE::AccessControl');
+$access_control_module->mock(cfs_read_file => $read);
+$access_control_module->mock(lock_user_config => sub { $_[0]->() });
+
+my $api_user_module = Test::MockModule->new('PVE::API2::User');
+$api_user_module->mock(cfs_read_file => $read);
+$api_user_module->mock(
+ cfs_write_file => sub {
+ my ($file, $cfg) = @_;
+ die "unexpected cfs_write_file('$file') call\n" if $file ne 'user.cfg';
+ $user_cfg_raw = PVE::AccessControl::write_user_config($file, $cfg);
+ },
+);
+
+my $token_config_module = Test::MockModule->new('PVE::TokenConfig');
+$token_config_module->mock(
+ generate_token => sub {
+ $secret_calls++;
+ return 'test-secret';
+ },
+);
+
+my $reset = sub {
+ my ($test_policy, $expire) = @_;
+ $policy = $test_policy;
+ $user_cfg_raw = "user:test\@pve:1:0::::::\n";
+ $user_cfg_raw .= "token:test\@pve!old:$expire:1:some comment:\n" if defined($expire);
+ $secret_calls = 0;
+};
+
+my $invoke = sub {
+ my ($method, $param) = @_;
+ my $ok = eval {
+ PVE::API2::User->$method({ userid => 'test@pve', %$param });
+ 1;
+ };
+ return ($ok, $@);
+};
+
+my $now = time();
+
+for my $policy_variant ({ 'require-expiry' => 1 }, { 'max-lifetime' => 86400 }) {
+ my $desc = join(',', map { "$_=$policy_variant->{$_}" } sort keys $policy_variant->%*);
+
+ for my $zero ('0', '00', '+0', '-0') {
+ $reset->($policy_variant);
+ my ($ok, $err) = $invoke->('generate_token', { tokenid => 'new', expire => $zero });
+ ok(
+ !$ok && $err =~ /requires an expiration date/,
+ "$desc: create with expire=$zero rejected",
+ );
+ is($secret_calls, 0, "$desc: rejected create generates no secret");
+ ok($user_cfg_raw !~ /!new/, "$desc: rejected create writes no token");
+
+ $reset->($policy_variant, $now + 600);
+ my $before = $user_cfg_raw;
+ ($ok, $err) =
+ $invoke->('update_token_info', { tokenid => 'old', expire => $zero, regenerate => 1 });
+ ok(
+ !$ok && $err =~ /requires an expiration date/, "$desc: update to expire=$zero rejected",
+ );
+ is($secret_calls, 0, "$desc: rejected update rotates no secret");
+ is($user_cfg_raw, $before, "$desc: rejected update writes nothing");
+ }
+
+ $reset->($policy_variant);
+ my ($ok, $err) = $invoke->('generate_token', { tokenid => 'new', expire => $now + 600 });
+ ok($ok, "$desc: compliant create accepted") or diag($err);
+ is($secret_calls, 1, "$desc: accepted create generates a secret");
+}
+
+# unrelated updates of a token that predates the policy must keep working
+$reset->({ 'require-expiry' => 1 }, 0);
+my ($ok, $err) = $invoke->('update_token_info', { tokenid => 'old', comment => 'changed' });
+ok($ok, 'unrelated update of a non-conforming token stays allowed') or diag($err);
+
+done_testing();
diff --git a/src/test/token-policy-test.pl b/src/test/token-policy-test.pl
new file mode 100644
index 0000000..f4c8fa5
--- /dev/null
+++ b/src/test/token-policy-test.pl
@@ -0,0 +1,217 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+
+use Test::More;
+use Test::MockModule;
+
+use PVE::AccessControl;
+
+# tests pass the policy explicitly; keep the fallback read of datacenter.cfg from hitting a
+# real pmxcfs so the cases without a policy exercise that path against an empty config
+my $access_control_module = Test::MockModule->new('PVE::AccessControl');
+$access_control_module->mock(
+ cfs_read_file => sub {
+ my ($file) = @_;
+ die "unexpected cfs_read_file('$file') call\n" if $file ne 'datacenter.cfg';
+ return {};
+ },
+);
+
+# fixed reference time and a 90-day maximum lifetime for deterministic bounds checks
+my $now = 1_000_000_000;
+my $max = 90 * 24 * 60 * 60; # 7776000 seconds
+
+# assert_token_policy({...}) dies on a policy violation. The 'expire' and
+# 'privilege-separation' keys may be undef to skip that dimension (used for partial updates);
+# 'is-update' marks an update of an existing token (relevant for 'disallow-expiry-changes').
+sub check {
+ my ($desc, $policy, $param, $should_die) = @_;
+
+ my $err;
+ eval {
+ PVE::AccessControl::assert_token_policy({
+ ctime => $now,
+ policy => $policy,
+ $param->%*,
+ });
+ 1;
+ } or $err = $@;
+
+ if ($should_die) {
+ ok($err, "$desc: rejected") or diag("expected rejection but call succeeded");
+ } else {
+ ok(!$err, "$desc: accepted") or diag("unexpected rejection: $err");
+ }
+}
+
+# no policy configured -> everything is allowed
+check(
+ 'no policy, never-expiring full-priv token',
+ undef,
+ { expire => 0, 'privilege-separation' => 0 },
+ 0,
+);
+check('no policy, undef dimensions', undef, {}, 0);
+check(
+ 'empty policy, never-expiring full-priv token',
+ {},
+ { expire => 0, 'privilege-separation' => 0 },
+ 0,
+);
+
+# require-expiry
+my $req_expire = { 'require-expiry' => 1 };
+check('require-expiry, no expiry', $req_expire, { expire => 0 }, 1);
+check('require-expiry, future expiry', $req_expire, { expire => $now + 100 }, 0);
+check('require-expiry, expiry dimension not touched', $req_expire, {}, 0);
+check(
+ 'require-expiry, update removing the expiry',
+ $req_expire,
+ { expire => 0, 'is-update' => 1 },
+ 1,
+);
+check(
+ 'require-expiry, does not constrain privsep',
+ $req_expire,
+ { expire => $now + 100, 'privilege-separation' => 0 },
+ 0,
+);
+
+# max-lifetime implies an expiry is required and bounds how far it may reach
+my $max_life = { 'max-lifetime' => $max };
+check('max-lifetime, never-expiring is rejected', $max_life, { expire => 0 }, 1);
+check('max-lifetime, within bound', $max_life, { expire => $now + 100 }, 0);
+check('max-lifetime, exactly at bound', $max_life, { expire => $now + $max }, 0);
+check('max-lifetime, one second past bound', $max_life, { expire => $now + $max + 1 }, 1);
+check('max-lifetime, expiry dimension not touched', $max_life, {}, 0);
+check(
+ 'max-lifetime, update prolonging past the bound',
+ $max_life,
+ { expire => $now + $max + 1, 'is-update' => 1 },
+ 1,
+);
+check('max-lifetime, expiry in the past accepted', $max_life, { expire => $now - 100 }, 0);
+check(
+ 'max-lifetime with explicit require-expiry=0 still requires an expiry',
+ { 'require-expiry' => 0, 'max-lifetime' => $max },
+ { expire => 0 },
+ 1,
+);
+
+# the integer schema passes zero spellings like '00' through with their submitted string
+# spelling, and those are true as Perl scalars; they must still count as "no expiration date"
+for my $zero ('0', '00', '+0', '-0') {
+ check("require-expiry, create with zero spelling '$zero'", $req_expire, { expire => $zero }, 1);
+ check(
+ "require-expiry, update removing the expiry via zero spelling '$zero'",
+ $req_expire,
+ { expire => $zero, 'is-update' => 1 },
+ 1,
+ );
+ check("max-lifetime, create with zero spelling '$zero'", $max_life, { expire => $zero }, 1);
+}
+
+# disallow-expiry-changes rejects any changed expiration date on update, but neither the
+# initial expiration date on creation nor updates that leave the expiration date untouched
+my $fixed_expiry = { 'disallow-expiry-changes' => 1 };
+check(
+ 'disallow-expiry-changes, update changing expiry',
+ $fixed_expiry,
+ { expire => $now + 100, 'is-update' => 1 },
+ 1,
+);
+check(
+ 'disallow-expiry-changes, update shortening expiry',
+ $fixed_expiry,
+ { expire => $now - 100, 'is-update' => 1 },
+ 1,
+);
+check(
+ 'disallow-expiry-changes, update removing expiry',
+ $fixed_expiry,
+ { expire => 0, 'is-update' => 1 },
+ 1,
+);
+check('disallow-expiry-changes, update not touching expiry', $fixed_expiry, { 'is-update' => 1 },
+ 0);
+check('disallow-expiry-changes, creation sets expiry', $fixed_expiry, { expire => $now + 100 }, 0);
+check('disallow-expiry-changes, creation without expiry', $fixed_expiry, { expire => 0 }, 0);
+check(
+ 'disallow-expiry-changes, does not constrain privsep',
+ $fixed_expiry,
+ { 'privilege-separation' => 0, 'is-update' => 1 },
+ 0,
+);
+check(
+ 'disallow-expiry-changes wins over a compliant new expiry under require-expiry',
+ { 'disallow-expiry-changes' => 1, 'require-expiry' => 1 },
+ { expire => $now + 100, 'is-update' => 1 },
+ 1,
+);
+
+# require-privilege-separation
+my $require_privilege_separation = { 'require-privilege-separation' => 1 };
+check(
+ 'require-privilege-separation, full-priv token rejected',
+ $require_privilege_separation,
+ { 'privilege-separation' => 0 },
+ 1,
+);
+check(
+ 'require-privilege-separation, privsep token accepted',
+ $require_privilege_separation,
+ { 'privilege-separation' => 1 },
+ 0,
+);
+check(
+ 'require-privilege-separation, privsep dimension not touched',
+ $require_privilege_separation,
+ {},
+ 0,
+);
+check(
+ 'require-privilege-separation, does not constrain expiry',
+ $require_privilege_separation,
+ { expire => 0, 'privilege-separation' => 1 },
+ 0,
+);
+
+# combined policy, as an admin would set for a compliance baseline
+my $full = { 'require-expiry' => 1, 'max-lifetime' => $max, 'require-privilege-separation' => 1 };
+check(
+ 'full policy, compliant token',
+ $full,
+ { expire => $now + 100, 'privilege-separation' => 1 },
+ 0,
+);
+check('full policy, missing expiry', $full, { expire => 0, 'privilege-separation' => 1 }, 1);
+check(
+ 'full policy, expiry too far out',
+ $full,
+ { expire => $now + $max + 1, 'privilege-separation' => 1 },
+ 1,
+);
+check(
+ 'full policy, full-priv token',
+ $full,
+ { expire => $now + 100, 'privilege-separation' => 0 },
+ 1,
+);
+# partial update: only the touched dimension is enforced (decision to not retroactively
+# reject tokens that predate the policy on unrelated edits)
+check(
+ 'full policy, update touching only a valid expiry',
+ $full,
+ { expire => $now + 100, 'is-update' => 1 },
+ 0,
+);
+check(
+ 'full policy, update touching only privsep',
+ $full,
+ { 'privilege-separation' => 1, 'is-update' => 1 },
+ 0,
+);
+
+done_testing();
--
2.47.3
^ permalink raw reply related [flat|nested] 10+ messages in thread
* [PATCH docs 3/9] user management: document the API token policy
2026-09-23 20:59 [PATCH cluster/access-control/manager/docs/proxmox 0/9] fix #7805: add a datacenter-wide API token policy Thomas Lamprecht
2026-09-23 20:59 ` [PATCH cluster 1/9] datacenter config: add token-policy option Thomas Lamprecht
2026-09-23 20:59 ` [PATCH access-control 2/9] fix #7805: api: token: enforce datacenter token policy Thomas Lamprecht
@ 2026-09-23 20:59 ` Thomas Lamprecht
2026-09-23 20:59 ` [PATCH manager 4/9] ui: token edit: only submit the expiration date when changed Thomas Lamprecht
` (5 subsequent siblings)
8 siblings, 0 replies; 10+ messages in thread
From: Thomas Lamprecht @ 2026-09-23 20:59 UTC (permalink / raw)
To: pve-devel
Describe the new datacenter token-policy option next to the general
API token section, so admins who need to fulfill compliance
requirements find the enforcement options in one place. The option
reference itself is generated from the datacenter.cfg schema.
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
pveum.adoc | 34 ++++++++++++++++++++++++++++++++++
1 file changed, 34 insertions(+)
diff --git a/pveum.adoc b/pveum.adoc
index d089cb6..0849657 100644
--- a/pveum.adoc
+++ b/pveum.adoc
@@ -115,6 +115,40 @@ To use an API token, set the HTTP header 'Authorization' to the displayed value
of the form `PVEAPIToken=USER@REALM!TOKENID=UUID` when making API requests, or
refer to your API client's documentation.
+[[pveum_token_policy]]
+Token Policy
+~~~~~~~~~~~~
+
+The optional `token-policy` datacenter option sets cluster-wide rules for
+creating and updating API tokens, as compliance standards like PCI DSS,
+SOC 2, or ISO 27001 often require, most notably a limited credential
+lifetime. The policy can require an expiration date for new tokens, limit
+how far in the future the expiration date can be set, forbid changing the
+expiration date of existing tokens, and require privilege separation.
+
+Changing the policy requires `Sys.Modify` on `/`, like any other datacenter
+option. Configure it in the web interface under *Datacenter -> Options ->
+API Token Policy*, or on the command line:
+
+[source,bash]
+----
+pvesh set /cluster/options --token-policy max-lifetime=7776000
+----
+
+`max-lifetime` is set in seconds (7776000 above is 90 days), while the web
+interface takes days. It counts from when the expiration date is set, that
+is on token creation or when an update changes it, and implies
+`require-expiry`, as a token without an expiration date would exceed any
+maximum lifetime.
+
+The policy only applies when a token is created or updated, and only to
+values that actually change. Existing tokens stay valid even if they do not
+conform to a policy configured later, and unrelated updates of such tokens
+keep working. Extending the expiration date within the limit stays possible
+too, unless `disallow-expiry-changes` is set, which forbids any change of
+the expiration date after creation; such tokens can still be deleted and
+recreated.
+
[[pveum_resource_pools]]
Resource Pools
--------------
--
2.47.3
^ permalink raw reply related [flat|nested] 10+ messages in thread
* [PATCH manager 4/9] ui: token edit: only submit the expiration date when changed
2026-09-23 20:59 [PATCH cluster/access-control/manager/docs/proxmox 0/9] fix #7805: add a datacenter-wide API token policy Thomas Lamprecht
` (2 preceding siblings ...)
2026-09-23 20:59 ` [PATCH docs 3/9] user management: document the API " Thomas Lamprecht
@ 2026-09-23 20:59 ` Thomas Lamprecht
2026-09-23 20:59 ` [PATCH manager 5/9] api: cluster options: return token-policy without Sys.Audit Thomas Lamprecht
` (4 subsequent siblings)
8 siblings, 0 replies; 10+ messages in thread
From: Thomas Lamprecht @ 2026-09-23 20:59 UTC (permalink / raw)
To: pve-devel
The day-granular date field resubmits the stored expiration date
truncated to the start of its day, so any unrelated edit, for example
of the comment, silently shortened the stored date by up to a day.
Drop the value from the submission if the selected day was not
actually changed.
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
www/manager6/dc/TokenEdit.js | 29 +++++++++++++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/www/manager6/dc/TokenEdit.js b/www/manager6/dc/TokenEdit.js
index fc9f0cc2c..2baeb5b74 100644
--- a/www/manager6/dc/TokenEdit.js
+++ b/www/manager6/dc/TokenEdit.js
@@ -25,6 +25,21 @@ Ext.define('PVE.dc.TokenEdit', {
delete values.userid;
delete values.tokenid;
+ // the day-granular date field resubmits the expiration date truncated to the
+ // start of its day, drop it if the selected day was not actually changed to
+ // avoid silently shortening the stored date on unrelated edits
+ if (!win.isCreate && win.originalExpire !== undefined) {
+ let sameDay = 0;
+ if (win.originalExpire) {
+ let day = new Date(win.originalExpire * 1000);
+ day.setHours(0, 0, 0, 0);
+ sameDay = Math.floor(day.getTime() / 1000);
+ }
+ if (Number(values.expire) === sameDay) {
+ delete values.expire;
+ }
+ }
+
win.url += `${uid}/token/${tid}`;
return values;
},
@@ -91,6 +106,20 @@ Ext.define('PVE.dc.TokenEdit', {
});
}
},
+
+ setValues: function (values) {
+ let me = this;
+ // remember the stored expiration date to detect a real change on submission; the
+ // token grid's record carries it as a Date object, the API as epoch seconds
+ if (!me.isCreate && values.expire !== undefined) {
+ me.originalExpire =
+ values.expire instanceof Date
+ ? Math.floor(values.expire.getTime() / 1000)
+ : Number(values.expire);
+ }
+ me.callParent([values]);
+ },
+
apiCallDone: function (success, response, options) {
let res = response.result.data;
if (!success || !res.value) {
--
2.47.3
^ permalink raw reply related [flat|nested] 10+ messages in thread
* [PATCH manager 5/9] api: cluster options: return token-policy without Sys.Audit
2026-09-23 20:59 [PATCH cluster/access-control/manager/docs/proxmox 0/9] fix #7805: add a datacenter-wide API token policy Thomas Lamprecht
` (3 preceding siblings ...)
2026-09-23 20:59 ` [PATCH manager 4/9] ui: token edit: only submit the expiration date when changed Thomas Lamprecht
@ 2026-09-23 20:59 ` Thomas Lamprecht
2026-09-23 20:59 ` [PATCH manager 6/9] ui: dc options: allow editing the API token policy Thomas Lamprecht
` (3 subsequent siblings)
8 siblings, 0 replies; 10+ messages in thread
From: Thomas Lamprecht @ 2026-09-23 20:59 UTC (permalink / raw)
To: pve-devel
Any user can create API tokens for themselves, so the token dialogs
need the new datacenter token policy to adapt up front, but reading
the full datacenter config requires Sys.Audit. Return the policy like
the console and tag-style options; it only contains restrictions that
are enforced on the token create and update calls anyway.
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
PVE/API2/Cluster.pm | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/PVE/API2/Cluster.pm b/PVE/API2/Cluster.pm
index 4e5efbfd9..fe7cb7766 100644
--- a/PVE/API2/Cluster.pm
+++ b/PVE/API2/Cluster.pm
@@ -813,7 +813,7 @@ __PACKAGE__->register_method({
if ($rpcenv->check($authuser, '/', ['Sys.Audit'], 1)) {
$res = $datacenter_config;
} else {
- for my $k (qw(console tag-style)) {
+ for my $k (qw(console tag-style token-policy)) {
$res->{$k} = $datacenter_config->{$k} if exists $datacenter_config->{$k};
}
}
--
2.47.3
^ permalink raw reply related [flat|nested] 10+ messages in thread
* [PATCH manager 6/9] ui: dc options: allow editing the API token policy
2026-09-23 20:59 [PATCH cluster/access-control/manager/docs/proxmox 0/9] fix #7805: add a datacenter-wide API token policy Thomas Lamprecht
` (4 preceding siblings ...)
2026-09-23 20:59 ` [PATCH manager 5/9] api: cluster options: return token-policy without Sys.Audit Thomas Lamprecht
@ 2026-09-23 20:59 ` Thomas Lamprecht
2026-09-23 20:59 ` [PATCH manager 7/9] ui: token edit: adapt to the datacenter " Thomas Lamprecht
` (2 subsequent siblings)
8 siblings, 0 replies; 10+ messages in thread
From: Thomas Lamprecht @ 2026-09-23 20:59 UTC (permalink / raw)
To: pve-devel
Render and edit the new datacenter token-policy option. The maximum
lifetime is entered in days, fractional values are allowed for
sub-day lifetimes, and the exact stored value is kept when the field
is left untouched, so a finer grained value set via the API survives
unrelated edits.
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
www/manager6/dc/OptionView.js | 107 ++++++++++++++++++++++++++++++++++
1 file changed, 107 insertions(+)
diff --git a/www/manager6/dc/OptionView.js b/www/manager6/dc/OptionView.js
index dc12aa7e1..595a8f070 100644
--- a/www/manager6/dc/OptionView.js
+++ b/www/manager6/dc/OptionView.js
@@ -384,6 +384,113 @@ Ext.define('PVE.dc.OptionView', {
},
],
});
+ me.rows['token-policy'] = {
+ required: true,
+ header: gettext('API Token Policy'),
+ renderer: function (policy) {
+ if (!policy) {
+ return Proxmox.Utils.NoneText;
+ }
+ let parts = [];
+ if (Number(policy['require-expiry'])) {
+ parts.push(gettext('Require Expiration Date'));
+ }
+ if (policy['max-lifetime'] !== undefined) {
+ let lifetime = Proxmox.Utils.format_duration_human(
+ Number(policy['max-lifetime']),
+ );
+ parts.push(Ext.String.format(gettext('Max. Lifetime: {0}'), lifetime));
+ }
+ if (Number(policy['disallow-expiry-changes'])) {
+ parts.push(gettext('Disallow Expiration Changes'));
+ }
+ if (Number(policy['require-privilege-separation'])) {
+ parts.push(gettext('Require Privilege Separation'));
+ }
+ return parts.length ? parts.join(', ') : Proxmox.Utils.NoneText;
+ },
+ editor: {
+ xtype: 'proxmoxWindowEdit',
+ subject: gettext('API Token Policy'),
+ onlineHelp: 'pveum_token_policy',
+ url: '/api2/extjs/cluster/options',
+ fieldDefaults: {
+ labelWidth: 150,
+ },
+ setValues: function (values) {
+ let me = this;
+ // work on a copy, max-lifetime is stored in seconds but entered in days,
+ // fractional values are allowed; round up at two decimals for display and
+ // remember the exact stored value so that saving with the field untouched
+ // does not alter it
+ let policy = Ext.apply({}, values['token-policy'] || {});
+ me.storedMaxLifetime = policy['max-lifetime'];
+ if (policy['max-lifetime'] !== undefined) {
+ policy['max-lifetime'] =
+ Math.ceil((Number(policy['max-lifetime']) / 86400) * 100) / 100;
+ }
+ Ext.Array.each(me.query('inputpanel'), (panel) => panel.setValues(policy));
+ },
+ items: [
+ {
+ xtype: 'inputpanel',
+ onGetValues: function (values) {
+ let me = this;
+ let win = me.up('proxmoxWindowEdit');
+ let policy = {};
+ if (values['require-expiry']) {
+ policy['require-expiry'] = 1;
+ }
+ if (values['disallow-expiry-changes']) {
+ policy['disallow-expiry-changes'] = 1;
+ }
+ if (values['require-privilege-separation']) {
+ policy['require-privilege-separation'] = 1;
+ }
+ let days = values['max-lifetime'];
+ if (days !== undefined && days !== null && days !== '') {
+ let untouched = !me.down('field[name=max-lifetime]').isDirty();
+ policy['max-lifetime'] =
+ untouched && win.storedMaxLifetime !== undefined
+ ? win.storedMaxLifetime
+ : Math.round(Number(days) * 86400);
+ }
+ if (Object.keys(policy).length === 0) {
+ return { delete: 'token-policy' };
+ }
+ return { 'token-policy': PVE.Parser.printPropertyString(policy) };
+ },
+ items: [
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'require-expiry',
+ uncheckedValue: 0,
+ fieldLabel: gettext('Require Expiration Date'),
+ },
+ {
+ xtype: 'numberfield',
+ name: 'max-lifetime',
+ minValue: 0.01,
+ emptyText: gettext('No limit'),
+ fieldLabel: gettext('Maximum Lifetime (days)'),
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'disallow-expiry-changes',
+ uncheckedValue: 0,
+ fieldLabel: gettext('Disallow Expiration Changes'),
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'require-privilege-separation',
+ uncheckedValue: 0,
+ fieldLabel: gettext('Require Privilege Separation'),
+ },
+ ],
+ },
+ ],
+ },
+ };
me.rows['tag-style'] = {
required: true,
renderer: (value) => {
--
2.47.3
^ permalink raw reply related [flat|nested] 10+ messages in thread
* [PATCH manager 7/9] ui: token edit: adapt to the datacenter API token policy
2026-09-23 20:59 [PATCH cluster/access-control/manager/docs/proxmox 0/9] fix #7805: add a datacenter-wide API token policy Thomas Lamprecht
` (5 preceding siblings ...)
2026-09-23 20:59 ` [PATCH manager 6/9] ui: dc options: allow editing the API token policy Thomas Lamprecht
@ 2026-09-23 20:59 ` Thomas Lamprecht
2026-09-23 20:59 ` [PATCH proxmox 8/9] access-control: add API token policy type with expiry checks Thomas Lamprecht
2026-09-23 20:59 ` [PATCH proxmox 9/9] access-control: enforce token policy on token create and update Thomas Lamprecht
8 siblings, 0 replies; 10+ messages in thread
From: Thomas Lamprecht @ 2026-09-23 20:59 UTC (permalink / raw)
To: pve-devel
Adapt the token dialogs up front when a datacenter token policy is
set: require and pre-fill a compliant expiration date, bound the
date picker, lock the expiration date when the policy forbids changing
it, and lock privilege separation on. The policy is fetched anew right
before a token dialog opens, so a policy changed elsewhere in the
meantime is applied without needing a session reload; if that fetch
fails the last known policy is used, the backend enforces it on the
actual calls.
As the date field is day-granular, it is no use for a sub-day maximum
lifetime: at best a single future midnight fits the bound, covering
only an arbitrary fraction of it, and on the 25 hour day of a DST
transition even a one day maximum can lack one. In both cases the
dialog switches to a seconds-granular lifetime field, resolved to an
absolute expiration date only on submission to not lose validity
time; on edit it stays empty to keep the current expiration date.
On edit the date picker is only bounded if the current expiration date
conforms, as tokens predating the policy would otherwise render the
form permanently invalid and block edits the backend explicitly
allows, since it only enforces settings that actually change.
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
www/manager6/UIOptions.js | 14 ++++-
www/manager6/dc/OptionView.js | 1 +
www/manager6/dc/TokenEdit.js | 107 +++++++++++++++++++++++++++++++++-
www/manager6/dc/TokenView.js | 36 +++++++-----
4 files changed, 141 insertions(+), 17 deletions(-)
diff --git a/www/manager6/UIOptions.js b/www/manager6/UIOptions.js
index 8c4674af1..8cd951963 100644
--- a/www/manager6/UIOptions.js
+++ b/www/manager6/UIOptions.js
@@ -10,7 +10,7 @@ Ext.define('PVE.UIOptions', {
url: '/cluster/options',
method: 'GET',
success: function (response) {
- for (const option of ['allowed-tags', 'console', 'tag-style']) {
+ for (const option of ['allowed-tags', 'console', 'tag-style', 'token-policy']) {
PVE.UIOptions.options[option] = response?.result?.data?.[option];
}
@@ -21,6 +21,18 @@ Ext.define('PVE.UIOptions', {
});
},
+ // the token policy can change independently of the tag settings, so refresh only that
+ // without triggering the resource store refresh a full update does; await this to act on
+ // a current policy
+ updateTokenPolicy: async function () {
+ try {
+ let { result } = await Proxmox.Async.api2({ url: '/cluster/options', method: 'GET' });
+ PVE.UIOptions.options['token-policy'] = result?.data?.['token-policy'];
+ } catch {
+ // best effort, keep the last known policy, the backend enforces it in any case
+ }
+ },
+
tagList: [],
updateTagList: function (tags) {
diff --git a/www/manager6/dc/OptionView.js b/www/manager6/dc/OptionView.js
index 595a8f070..c3e97cf80 100644
--- a/www/manager6/dc/OptionView.js
+++ b/www/manager6/dc/OptionView.js
@@ -724,6 +724,7 @@ Ext.define('PVE.dc.OptionView', {
}
PVE.UIOptions.options['tag-style'] = store.getById('tag-style')?.data?.value;
+ PVE.UIOptions.options['token-policy'] = store.getById('token-policy')?.data?.value;
PVE.UIOptions.updateTagSettings(PVE.UIOptions.options['tag-style']);
PVE.UIOptions.fireUIConfigChanged();
});
diff --git a/www/manager6/dc/TokenEdit.js b/www/manager6/dc/TokenEdit.js
index 2baeb5b74..767e09219 100644
--- a/www/manager6/dc/TokenEdit.js
+++ b/www/manager6/dc/TokenEdit.js
@@ -40,6 +40,14 @@ Ext.define('PVE.dc.TokenEdit', {
}
}
+ // sub-day maximum lifetimes cannot be expressed with the day-granular date
+ // field, so a lifetime in seconds is entered instead and resolved to an
+ // absolute expiration date here, as late as possible to not lose validity time
+ let lifetimeField = me.down('field[name=token-lifetime]');
+ if (!lifetimeField.isDisabled() && lifetimeField.getValue()) {
+ values.expire = Math.floor(Date.now() / 1000) + lifetimeField.getValue();
+ }
+
win.url += `${uid}/token/${tid}`;
return values;
},
@@ -83,6 +91,17 @@ Ext.define('PVE.dc.TokenEdit', {
xtype: 'pmxExpireDate',
name: 'expire',
},
+ {
+ // used instead of the date field for sub-day maximum lifetimes
+ xtype: 'numberfield',
+ name: 'token-lifetime',
+ submitValue: false,
+ hidden: true,
+ disabled: true,
+ allowDecimals: false,
+ minValue: 1,
+ fieldLabel: gettext('Lifetime (seconds)'),
+ },
],
columnB: [
{
@@ -98,10 +117,96 @@ Ext.define('PVE.dc.TokenEdit', {
me.callParent();
- if (!me.isCreate) {
+ // check any token-policy in the UI already for better UX
+ let policy = PVE.UIOptions.options['token-policy'] || {};
+ let maxLifetime =
+ policy['max-lifetime'] !== undefined ? Number(policy['max-lifetime']) : undefined;
+ // compute the bound once so the pre-filled default cannot drift past the max
+ let maxDate =
+ maxLifetime !== undefined ? new Date(Date.now() + maxLifetime * 1000) : undefined;
+ // for a sub-day maximum the day-granular date field is no use, at best one future
+ // midnight fits the bound and covers only an arbitrary fraction of it; a one day
+ // maximum can even lack such a midnight on the 25 hour day of a DST transition, so
+ // switch to a seconds-granular lifetime field in both cases
+ let useLifetimeField = false;
+ if (maxDate !== undefined) {
+ let nextMidnight = new Date();
+ nextMidnight.setHours(24, 0, 0, 0);
+ useLifetimeField = maxLifetime < 86400 || nextMidnight > maxDate;
+ }
+
+ let expireField = me.down('field[name=expire]');
+ let lifetimeField = me.down('field[name=token-lifetime]');
+
+ if (me.isCreate) {
+ if (useLifetimeField) {
+ expireField.setVisible(false);
+ expireField.setDisabled(true);
+ lifetimeField.setVisible(true);
+ lifetimeField.setDisabled(false);
+ lifetimeField.setMaxValue(maxLifetime);
+ lifetimeField.allowBlank = false;
+ } else {
+ if (maxDate !== undefined) {
+ expireField.setMaxValue(maxDate);
+ expireField.setValue(maxDate);
+ }
+ if (Number(policy['require-expiry']) || maxLifetime !== undefined) {
+ expireField.allowBlank = false;
+ }
+ }
+ if (Number(policy['require-privilege-separation'])) {
+ let privilegeSeparationField = me.down('field[name=privsep]');
+ privilegeSeparationField.setValue(true);
+ privilegeSeparationField.setDisabled(true);
+ }
+ } else {
me.load({
success: function (response, options) {
me.setValues(response.result.data);
+ // a compliant privilege separation setting cannot be disabled again,
+ // lock it like on create; enabling it on a legacy token stays possible
+ if (Number(policy['require-privilege-separation'])) {
+ let privilegeSeparationField = me.down('field[name=privsep]');
+ if (privilegeSeparationField.getValue()) {
+ privilegeSeparationField.setDisabled(true);
+ }
+ }
+ if (Number(policy['disallow-expiry-changes'])) {
+ // the policy forbids changing the expiration date; a disabled field
+ // is not submitted, so the stored value stays untouched
+ expireField.setDisabled(true);
+ return;
+ }
+ if (useLifetimeField) {
+ // keep the stored date visible but read-only; a new lifetime
+ // relative to now can be entered in seconds, empty keeps the
+ // current expiration date
+ expireField.setDisabled(true);
+ lifetimeField.setVisible(true);
+ lifetimeField.setDisabled(false);
+ lifetimeField.setMaxValue(maxLifetime);
+ lifetimeField.setEmptyText(gettext('unchanged'));
+ return;
+ }
+ // clearing an existing expiration date would submit a change the policy
+ // rejects, so require a value then; a token without any date stays valid
+ // as blank, unchanged it is dropped from submission and not enforced
+ if (
+ me.originalExpire &&
+ (Number(policy['require-expiry']) || maxLifetime !== undefined)
+ ) {
+ expireField.allowBlank = false;
+ }
+ // bound prolonging only if the current date conforms; else the form would
+ // be stuck invalid, blocking edits the backend allows for tokens that
+ // predate the policy (it only enforces dimensions that actually change)
+ if (maxDate !== undefined) {
+ let current = expireField.getValue();
+ if (!current || current <= maxDate) {
+ expireField.setMaxValue(maxDate);
+ }
+ }
},
});
}
diff --git a/www/manager6/dc/TokenView.js b/www/manager6/dc/TokenView.js
index 8272729af..e228947dc 100644
--- a/www/manager6/dc/TokenView.js
+++ b/www/manager6/dc/TokenView.js
@@ -61,20 +61,32 @@ Ext.define('PVE.dc.TokenView', {
return userid === Proxmox.UserName || !!caps.access['User.Modify'];
};
- let run_editor = function (rec) {
- if (!hasTokenCRUDPermissions(rec.data.userid)) {
- return;
+ let openPending = false;
+ let run_token_editor = async function (editorConfig, values) {
+ if (openPending) {
+ return; // a slow policy refresh must not spawn duplicate windows
+ }
+ openPending = true;
+ // fetch the current policy first, the dialog adapts its fields to it
+ await PVE.UIOptions.updateTokenPolicy();
+ openPending = false;
+ if (me.destroyed || !me.isVisible(true)) {
+ return; // the user navigated away while the policy was being fetched
}
- let win = Ext.create('PVE.dc.TokenEdit', {
- method: 'PUT',
- url: urlFromRecord(rec),
- });
- win.setValues(rec.data);
+ let win = Ext.create('PVE.dc.TokenEdit', editorConfig);
+ win.setValues(values);
win.on('destroy', reload);
win.show();
};
+ let run_editor = function (rec) {
+ if (!hasTokenCRUDPermissions(rec.data.userid)) {
+ return;
+ }
+ run_token_editor({ method: 'PUT', url: urlFromRecord(rec) }, rec.data);
+ };
+
let regenerate_token = function (_btn, _event, rec) {
if (!hasTokenCRUDPermissions(rec.data.userid)) {
return;
@@ -99,13 +111,7 @@ Ext.define('PVE.dc.TokenView', {
{
text: gettext('Add'),
handler: function (btn, e) {
- let data = {};
- let win = Ext.create('PVE.dc.TokenEdit', {
- isCreate: true,
- });
- win.setValues(data);
- win.on('destroy', reload);
- win.show();
+ run_token_editor({ isCreate: true }, {});
},
},
{
--
2.47.3
^ permalink raw reply related [flat|nested] 10+ messages in thread
* [PATCH proxmox 8/9] access-control: add API token policy type with expiry checks
2026-09-23 20:59 [PATCH cluster/access-control/manager/docs/proxmox 0/9] fix #7805: add a datacenter-wide API token policy Thomas Lamprecht
` (6 preceding siblings ...)
2026-09-23 20:59 ` [PATCH manager 7/9] ui: token edit: adapt to the datacenter " Thomas Lamprecht
@ 2026-09-23 20:59 ` Thomas Lamprecht
2026-09-23 20:59 ` [PATCH proxmox 9/9] access-control: enforce token policy on token create and update Thomas Lamprecht
8 siblings, 0 replies; 10+ messages in thread
From: Thomas Lamprecht @ 2026-09-23 20:59 UTC (permalink / raw)
To: pve-devel
Counterpart of the datacenter-wide token-policy option introduced
for PVE with #7805, so that products building on this crate can
centrally require expiration dates for API tokens, limit the maximum
token lifetime, and forbid changing the expiration date of existing
tokens. Compliance rules like PCI DSS, SOC 2, or ISO 27001 often
require such limits.
Products opt in by overriding the new AccessControlConfig method to
return the policy from their configuration, by default none is
enforced. The check is meant to be called only with an expiration
date an operation actually sets or changes, existing tokens stay
valid on purpose, so opting into a policy does not invalidate already
deployed tokens. Only the expiry-related settings are ported, tokens
in this stack always use separate ACLs, so PVE's privilege
separation setting only becomes relevant once it moves over to
this implementation.
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
proxmox-access-control/src/init.rs | 15 ++
proxmox-access-control/src/types.rs | 227 ++++++++++++++++++++++++++++
2 files changed, 242 insertions(+)
diff --git a/proxmox-access-control/src/init.rs b/proxmox-access-control/src/init.rs
index 52c3393a..b8bcdafd 100644
--- a/proxmox-access-control/src/init.rs
+++ b/proxmox-access-control/src/init.rs
@@ -6,6 +6,8 @@ use anyhow::{Error, format_err};
use proxmox_auth_api::types::{Authid, Userid};
use proxmox_section_config::SectionConfigData;
+use crate::types::TokenPolicy;
+
static ACCESS_CONF: OnceLock<&'static dyn AccessControlConfig> = OnceLock::new();
/// This trait specifies the functions a product needs to implement to get ACL tree based access
@@ -101,6 +103,19 @@ pub trait AccessControlConfig: Send + Sync {
fn allow_partial_permission_match(&self) -> bool {
true
}
+
+ /// Returns the token policy to enforce when API tokens are created or updated, if any.
+ ///
+ /// Existing tokens are deliberately never re-validated against a policy, so enabling one
+ /// does not invalidate already deployed tokens. Override this to return the policy from the
+ /// product's configuration. Return an error if that configuration cannot be loaded, so token
+ /// creation and expiration-date updates fail instead of silently proceeding without the
+ /// policy.
+ ///
+ /// Default: Returns `Ok(None)`, no policy is enforced.
+ fn token_policy(&self) -> Result<Option<TokenPolicy>, Error> {
+ Ok(None)
+ }
}
pub fn init_access_config(config: &'static dyn AccessControlConfig) -> Result<(), Error> {
diff --git a/proxmox-access-control/src/types.rs b/proxmox-access-control/src/types.rs
index 875b3d93..7fef5adc 100644
--- a/proxmox-access-control/src/types.rs
+++ b/proxmox-access-control/src/types.rs
@@ -1,3 +1,4 @@
+use anyhow::{Error, bail};
use serde::{Deserialize, Serialize};
use const_format::concatcp;
@@ -174,6 +175,82 @@ pub struct TokenApiEntry {
pub token: ApiToken,
}
+#[api(
+ properties: {
+ "require-expiry": {
+ optional: true,
+ default: false,
+ },
+ "max-lifetime": {
+ optional: true,
+ minimum: 1,
+ },
+ "disallow-expiry-changes": {
+ optional: true,
+ default: false,
+ },
+ },
+)]
+#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize, Updater)]
+#[serde(rename_all = "kebab-case")]
+/// Policy for the creation and update of API tokens.
+///
+/// The policy is enforced when API tokens are created or updated only, existing tokens are
+/// deliberately not affected, so enabling a policy does not invalidate already deployed tokens.
+pub struct TokenPolicy {
+ /// Require an expiration date for new API tokens and when changing the expiration date of
+ /// existing ones.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub require_expiry: Option<bool>,
+
+ /// Maximum lifetime of API tokens in seconds, counted from when the expiration date is set,
+ /// that is on creation or when an update changes it. Implies `require-expiry`.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub max_lifetime: Option<i64>,
+
+ /// Disallow changing the expiration date of existing API tokens, so that `max-lifetime`
+ /// cannot be circumvented by extending tokens repeatedly. Such tokens can still be deleted
+ /// and recreated.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub disallow_expiry_changes: Option<bool>,
+}
+
+impl TokenPolicy {
+ /// Check a new or changed token expiration date against the policy.
+ ///
+ /// Must only be called when an operation actually sets or changes the expiration date, with
+ /// an `expire` of 0 meaning no expiration date. This lets callers enforce the policy only for
+ /// the expiration date an operation sets, so unrelated updates do not retroactively reject
+ /// tokens that predate the policy. `is_update` distinguishes updating an existing token from
+ /// creating a new one, `now` is the reference time the maximum lifetime is bound against.
+ pub fn check_expiry_change(&self, expire: i64, is_update: bool, now: i64) -> Result<(), Error> {
+ if is_update && self.disallow_expiry_changes.unwrap_or(false) {
+ bail!(
+ "the token policy does not allow changing the expiration date of an existing token"
+ );
+ }
+
+ // a token that never expires trivially exceeds any finite maximum lifetime, so a set
+ // maximum lifetime also requires an expiration date
+ if expire == 0 && (self.require_expiry.unwrap_or(false) || self.max_lifetime.is_some()) {
+ bail!("the token policy requires an expiration date");
+ }
+
+ if let Some(max_lifetime) = self.max_lifetime {
+ // an extreme configured lifetime must not overflow, which panics in debug builds and
+ // wraps into a bound rejecting everything otherwise
+ if expire > 0 && expire > now.saturating_add(max_lifetime) {
+ bail!(
+ "the expiration date exceeds the maximum token lifetime of {max_lifetime} \
+ seconds set by the token policy"
+ );
+ }
+ }
+
+ Ok(())
+ }
+}
+
#[api(
properties: {
userid: {
@@ -316,3 +393,153 @@ pub const REGENERATE_TOKEN_SCHEMA: Schema =
BooleanSchema::new("Regenerate token secret while keeping permissions.")
.default(false)
.schema();
+
+#[cfg(test)]
+mod tests {
+ use super::TokenPolicy;
+
+ // fixed reference time and a 90 day maximum lifetime for deterministic bounds checks
+ const NOW: i64 = 1_000_000_000;
+ const MAX: i64 = 90 * 24 * 60 * 60;
+
+ const EMPTY: TokenPolicy = TokenPolicy {
+ require_expiry: None,
+ max_lifetime: None,
+ disallow_expiry_changes: None,
+ };
+
+ const REQUIRE_EXPIRY: TokenPolicy = TokenPolicy {
+ require_expiry: Some(true),
+ ..EMPTY
+ };
+
+ const MAX_LIFETIME: TokenPolicy = TokenPolicy {
+ max_lifetime: Some(MAX),
+ ..EMPTY
+ };
+
+ const FIXED_EXPIRY: TokenPolicy = TokenPolicy {
+ disallow_expiry_changes: Some(true),
+ ..EMPTY
+ };
+
+ #[test]
+ fn empty_policy_allows_everything() {
+ assert!(EMPTY.check_expiry_change(0, false, NOW).is_ok());
+ assert!(EMPTY.check_expiry_change(NOW + 100, false, NOW).is_ok());
+ assert!(EMPTY.check_expiry_change(0, true, NOW).is_ok());
+ }
+
+ #[test]
+ fn require_expiry() {
+ assert!(REQUIRE_EXPIRY.check_expiry_change(0, false, NOW).is_err());
+ assert!(
+ REQUIRE_EXPIRY
+ .check_expiry_change(NOW + 100, false, NOW)
+ .is_ok()
+ );
+ // an update removing the expiration date is a change and gets rejected as well
+ assert!(REQUIRE_EXPIRY.check_expiry_change(0, true, NOW).is_err());
+ // while a compliant expiration date change on update stays allowed
+ assert!(
+ REQUIRE_EXPIRY
+ .check_expiry_change(NOW + 100, true, NOW)
+ .is_ok()
+ );
+ }
+
+ #[test]
+ fn max_lifetime_implies_require_expiry() {
+ assert!(MAX_LIFETIME.check_expiry_change(0, false, NOW).is_err());
+ let explicitly_not_required = TokenPolicy {
+ require_expiry: Some(false),
+ ..MAX_LIFETIME
+ };
+ assert!(
+ explicitly_not_required
+ .check_expiry_change(0, false, NOW)
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn max_lifetime_bounds() {
+ assert!(
+ MAX_LIFETIME
+ .check_expiry_change(NOW + 100, false, NOW)
+ .is_ok()
+ );
+ assert!(
+ MAX_LIFETIME
+ .check_expiry_change(NOW + MAX, false, NOW)
+ .is_ok()
+ );
+ assert!(
+ MAX_LIFETIME
+ .check_expiry_change(NOW + MAX + 1, false, NOW)
+ .is_err()
+ );
+ // prolonging past the bound on update is rejected just the same
+ assert!(
+ MAX_LIFETIME
+ .check_expiry_change(NOW + MAX + 1, true, NOW)
+ .is_err()
+ );
+ // an expiration date in the past is odd, but within any bound
+ assert!(
+ MAX_LIFETIME
+ .check_expiry_change(NOW - 100, false, NOW)
+ .is_ok()
+ );
+ // prolonging within the bound on update stays allowed
+ assert!(
+ MAX_LIFETIME
+ .check_expiry_change(NOW + 100, true, NOW)
+ .is_ok()
+ );
+ }
+
+ #[test]
+ fn extreme_max_lifetime_does_not_overflow() {
+ let policy = TokenPolicy {
+ max_lifetime: Some(i64::MAX),
+ ..EMPTY
+ };
+ assert!(policy.check_expiry_change(NOW + 100, false, NOW).is_ok());
+ assert!(policy.check_expiry_change(i64::MAX, false, NOW).is_ok());
+ }
+
+ #[test]
+ fn disallow_expiry_changes() {
+ // any change on update is rejected: prolonging, shortening and removal
+ assert!(
+ FIXED_EXPIRY
+ .check_expiry_change(NOW + 100, true, NOW)
+ .is_err()
+ );
+ assert!(
+ FIXED_EXPIRY
+ .check_expiry_change(NOW - 100, true, NOW)
+ .is_err()
+ );
+ assert!(FIXED_EXPIRY.check_expiry_change(0, true, NOW).is_err());
+ // creation is unaffected, with or without an expiration date
+ assert!(
+ FIXED_EXPIRY
+ .check_expiry_change(NOW + 100, false, NOW)
+ .is_ok()
+ );
+ assert!(FIXED_EXPIRY.check_expiry_change(0, false, NOW).is_ok());
+ }
+
+ #[test]
+ fn disallow_expiry_changes_wins_over_compliant_expiry() {
+ let policy = TokenPolicy {
+ require_expiry: Some(true),
+ disallow_expiry_changes: Some(true),
+ ..EMPTY
+ };
+ assert!(policy.check_expiry_change(NOW + 100, true, NOW).is_err());
+ assert!(policy.check_expiry_change(NOW + 100, false, NOW).is_ok());
+ }
+}
--
2.47.3
^ permalink raw reply related [flat|nested] 10+ messages in thread
* [PATCH proxmox 9/9] access-control: enforce token policy on token create and update
2026-09-23 20:59 [PATCH cluster/access-control/manager/docs/proxmox 0/9] fix #7805: add a datacenter-wide API token policy Thomas Lamprecht
` (7 preceding siblings ...)
2026-09-23 20:59 ` [PATCH proxmox 8/9] access-control: add API token policy type with expiry checks Thomas Lamprecht
@ 2026-09-23 20:59 ` Thomas Lamprecht
8 siblings, 0 replies; 10+ messages in thread
From: Thomas Lamprecht @ 2026-09-23 20:59 UTC (permalink / raw)
To: pve-devel
An update only checks the expiration date if its value actually
changes, so unrelated edits or clients resubmitting unchanged fields
are not rejected for tokens that predate the policy. The check runs
before the secret is generated, or rotated on update, to avoid
leaving an orphaned entry in the token shadow file, or losing the
old secret, for a rejected request.
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
proxmox-access-control/src/api/tokens.rs | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/proxmox-access-control/src/api/tokens.rs b/proxmox-access-control/src/api/tokens.rs
index f7417934..58295793 100644
--- a/proxmox-access-control/src/api/tokens.rs
+++ b/proxmox-access-control/src/api/tokens.rs
@@ -97,6 +97,12 @@ pub fn generate_token(
);
}
+ // check the policy before generating the secret to avoid leaving an orphaned entry in the
+ // token shadow file on rejection
+ if let Some(policy) = crate::init::access_conf().token_policy()? {
+ policy.check_expiry_change(expire.unwrap_or(0), false, proxmox_time::epoch_i64())?;
+ }
+
let secret = token_shadow::generate_and_set_secret(&tokenid)?;
let token = ApiToken {
@@ -180,6 +186,7 @@ pub fn update_token(
let tokenid_string = tokenid.to_string();
let mut data: ApiToken = config.lookup("token", &tokenid_string)?;
+ let old_expire = data.expire.unwrap_or(0);
if let Some(delete) = delete {
for delete_prop in delete {
@@ -206,6 +213,16 @@ pub fn update_token(
data.expire = if expire > 0 { Some(expire) } else { None };
}
+ // enforce the policy only if this update actually changes the expiration date, so unrelated
+ // edits do not retroactively reject tokens that predate the policy; check before a requested
+ // secret regeneration so a rejected update does not rotate the secret either
+ let new_expire = data.expire.unwrap_or(0);
+ if new_expire != old_expire {
+ if let Some(policy) = crate::init::access_conf().token_policy()? {
+ policy.check_expiry_change(new_expire, true, proxmox_time::epoch_i64())?;
+ }
+ }
+
let new_secret = if regenerate == Some(true) {
let secret = token_shadow::generate_and_set_secret(&tokenid)?;
Some(ApiTokenSecret { tokenid, secret })
--
2.47.3
^ permalink raw reply related [flat|nested] 10+ messages in thread
end of thread, other threads:[~2026-09-23 21:01 UTC | newest]
Thread overview: 10+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-09-23 20:59 [PATCH cluster/access-control/manager/docs/proxmox 0/9] fix #7805: add a datacenter-wide API token policy Thomas Lamprecht
2026-09-23 20:59 ` [PATCH cluster 1/9] datacenter config: add token-policy option Thomas Lamprecht
2026-09-23 20:59 ` [PATCH access-control 2/9] fix #7805: api: token: enforce datacenter token policy Thomas Lamprecht
2026-09-23 20:59 ` [PATCH docs 3/9] user management: document the API " Thomas Lamprecht
2026-09-23 20:59 ` [PATCH manager 4/9] ui: token edit: only submit the expiration date when changed Thomas Lamprecht
2026-09-23 20:59 ` [PATCH manager 5/9] api: cluster options: return token-policy without Sys.Audit Thomas Lamprecht
2026-09-23 20:59 ` [PATCH manager 6/9] ui: dc options: allow editing the API token policy Thomas Lamprecht
2026-09-23 20:59 ` [PATCH manager 7/9] ui: token edit: adapt to the datacenter " Thomas Lamprecht
2026-09-23 20:59 ` [PATCH proxmox 8/9] access-control: add API token policy type with expiry checks Thomas Lamprecht
2026-09-23 20:59 ` [PATCH proxmox 9/9] access-control: enforce token policy on token create and update Thomas Lamprecht
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox