From: Thomas Lamprecht <t.lamprecht@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH manager 7/9] ui: token edit: adapt to the datacenter API token policy
Date: Wed, 23 Sep 2026 22:59:56 +0200 [thread overview]
Message-ID: <20260923210000.4031318-8-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20260923210000.4031318-1-t.lamprecht@proxmox.com>
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
next prev parent reply other threads:[~2026-09-23 21:01 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 ` [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 ` Thomas Lamprecht [this message]
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-8-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.