all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Thomas Lamprecht <t.lamprecht@proxmox.com>
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	[thread overview]
Message-ID: <20260923210000.4031318-2-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20260923210000.4031318-1-t.lamprecht@proxmox.com>

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





  reply	other threads:[~2026-09-23 21:00 UTC|newest]

Thread overview: 10+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
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 [this message]
2026-09-23 20:59 ` [PATCH access-control 2/9] fix #7805: api: token: enforce datacenter " 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

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260923210000.4031318-2-t.lamprecht@proxmox.com \
    --to=t.lamprecht@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal