public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: "Shannon Sterz" <s.sterz@proxmox.com>
To: "Thomas Lamprecht" <t.lamprecht@proxmox.com>,
	<pve-devel@lists.proxmox.com>
Subject: Re: [PATCH cluster 1/9] datacenter config: add token-policy option
Date: Tue, 10 Nov 2026 16:05:06 +0100	[thread overview]
Message-ID: <DMRMGFI620H5.27V9QXMTG0SPK@proxmox.com> (raw)
In-Reply-To: <20260923210000.4031318-2-t.lamprecht@proxmox.com>

On Wed Sep 23, 2026 at 10:59 PM CEST, Thomas Lamprecht wrote:
> 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.",

currently this also seems to disallow setting an expiry for old tokens
or shortening the lifetime of an existing token with a pre-existing
expiry. while this may be intentional, id argue this makes compliance
less convenient to achieve.

auditors may want an admin to set an expiry asap on a token without one.
this option would allow only removing the old token and creating a new
one as an option. creating a new token with specific permissions may be
cumbersome.

since there is no downside to shortening a token lifetime, im not sure
what the value here is. especially if we do decide to allow lifetime
extensions by secret regeneration.

the rational here also only talks about avoid lifetime extension, so at
the very least it should maybe also mention why lifetime shortening is
also disallowed.

> +    },
> +    '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;

small nit: this could just do `use v5.36` instead. the other test cases
could adopt this as well, but i won't mention it each time as it's
minor.

> +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();





  reply	other threads:[~2026-11-10 15:05 UTC|newest]

Thread overview: 18+ 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 ` [PATCH cluster 1/9] datacenter config: add token-policy option Thomas Lamprecht
2026-11-10 15:05   ` Shannon Sterz [this message]
2026-11-10 16:00     ` 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-11-10 15:05   ` Shannon Sterz
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-11-10 15:05   ` Shannon Sterz
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-11-10 15:05   ` Shannon Sterz
2026-09-23 20:59 ` [PATCH proxmox 9/9] access-control: enforce token policy on token create and update Thomas Lamprecht
2026-11-10 15:04   ` Shannon Sterz
2026-11-10 15:05 ` [PATCH cluster/access-control/manager/docs/proxmox 0/9] fix #7805: add a datacenter-wide API token policy Shannon Sterz
2026-11-10 15:28   ` 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=DMRMGFI620H5.27V9QXMTG0SPK@proxmox.com \
    --to=s.sterz@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    --cc=t.lamprecht@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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal