From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id DA3191FF0AB for ; Wed, 23 Sep 2026 23:00:32 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 0D80F2164F; Wed, 23 Sep 2026 23:00:14 +0200 (CEST) From: Thomas Lamprecht To: pve-devel@lists.proxmox.com Subject: [PATCH cluster 1/9] datacenter config: add token-policy option Date: Wed, 23 Sep 2026 22:59:50 +0200 Message-ID: <20260923210000.4031318-2-t.lamprecht@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260923210000.4031318-1-t.lamprecht@proxmox.com> References: <20260923210000.4031318-1-t.lamprecht@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1790197207169 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.694 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: EYNTRRUOHYLPZJHCXZOEAMFS4CGGPMY6 X-Message-ID-Hash: EYNTRRUOHYLPZJHCXZOEAMFS4CGGPMY6 X-MailFrom: t.lamprecht@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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 --- 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